├── .gitignore ├── .travis.yml ├── CHANGES ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README ├── docs ├── Makefile ├── conf.py ├── index.rst └── make.bat ├── example ├── example.ini └── server.py ├── sanction ├── __init__.py ├── client.py └── test.py ├── setup.py └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | tags 2 | /build 3 | /docs/_build 4 | .coverage 5 | .env 6 | .env3 7 | *.egg-info 8 | *.pyc 9 | *.swp 10 | /dist 11 | *.stackdump 12 | __pycache__ 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.3" 5 | # # command to run tests 6 | script: nosetests 7 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | 4 | sanction 0.4.1 (1-14-2014) 5 | ========================== 6 | * Fixed string formatting for Python 2.6 7 | 8 | sanction 0.4 (8-21-2013) 9 | ========================== 10 | * Breaking: token_transport parameter of Client.__init__ no longer accepts 11 | strings (which was weird to begin with). It only accepts callables. 12 | transport_headers and transport_query have been made public (well, the underscore 13 | prefix has been removed) in light of this. 14 | * Breaking: scope is now expected to be formatted how the provider expects 15 | it. scope_delim has been removed from auth_uri in order to simplify it. This 16 | means that your providers' scope should be set accordingly: i.e. Facebook 17 | may be 'foo,bar' while Google would be 'foo bar'. 18 | * Breaking: removed redirect_uri from Client.__init__ (didn't make sense to 19 | be there. This has been added to auth_uri and request_token. 20 | * Deprecation: sanction.client.Client has been deprecated in favor of 21 | sanction.Client. The deprecated version will likely be removed within 22 | the next couple minor revisions. 23 | * Feature: Added headers param to request 24 | * Feature: Added with_patched_client test decorator 25 | * Brought unit tests up to 100% coverage (yay!) 26 | * Sphinx-ified the docs, added to RTD 27 | * Removed Python 3.2 support (causes unit test breakage right now and I don't 28 | really want to spend more time on it ;)). The client code should work fine, 29 | it's just a test that doesn't pass right now. 30 | 31 | sanction 0.3.1 (4-8-2013) 32 | ========================= 33 | * Added refresh_token, defaulting to None instead of dynamically adding it 34 | to the client instance 35 | 36 | sanction 0.3 (2-21-2013) 37 | ======================== 38 | * Python 3+ support added 39 | * Updated request to support a full range of HTTP methods (was previously POST 40 | and GET 41 | * Added support for multiple token transport types: HTTP headers, GET params 42 | or custom callable 43 | * Added exclude param to request_token in order to prevent certain fields from 44 | token request 45 | * Removed query param from request. These should now be added to the base url. 46 | 47 | sanction 0.1.4 (9-14-2012) 48 | ========================== 49 | * Added support for refresh_token 50 | * Removed check for endpoint errors in request_token. Client code should 51 | deal with that before calling request_token. 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) <2012> 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt *.rst README LICENSE CHANGES 2 | recursive-include . *.py 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test, lint, example 2 | 3 | test: 4 | rm -f .coverage 5 | nosetests -s --with-coverage --cover-package=sanction 6 | 7 | lint: 8 | pylint sanction 9 | 10 | example: 11 | cd example; python server.py 12 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | sanction [sangk-shuhn]: authoritative permission or approval, as for an action. 2 | 3 | sanction is a ridiculously easy to use OAuth 2.0 client intended for rapid 4 | development against OAuth 2.0 providers with minimal keyboard bashing. 5 | 6 | Docs are available at readthedocs: http://sanction.readthedocs.org. 7 | -------------------------------------------------------------------------------- /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 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/sanction.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/sanction.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/sanction" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/sanction" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # sanction documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Aug 20 07:22:31 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 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | sys.path.insert(0, os.path.abspath('../')) 21 | 22 | # -- General configuration ----------------------------------------------------- 23 | 24 | # If your documentation needs a minimal Sphinx version, state it here. 25 | #needs_sphinx = '1.0' 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be extensions 28 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 29 | extensions = ['sphinx.ext.autodoc'] 30 | 31 | # Add any paths that contain templates here, relative to this directory. 32 | templates_path = ['_templates'] 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'sanction' 45 | copyright = u'2013, Demian Brecht' 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 = '0.4' 53 | # The full version, including alpha/beta/rc tags. 54 | release = '0.4' 55 | 56 | # The language for content autogenerated by Sphinx. Refer to documentation 57 | # for a list of supported languages. 58 | #language = None 59 | 60 | # There are two options for replacing |today|: either, you set today to some 61 | # non-false value, then it is used: 62 | #today = '' 63 | # Else, today_fmt is used as the format for a strftime call. 64 | #today_fmt = '%B %d, %Y' 65 | 66 | # List of patterns, relative to source directory, that match files and 67 | # directories to ignore when looking for source files. 68 | exclude_patterns = ['_build'] 69 | 70 | # The reST default role (used for this markup: `text`) to use for all documents. 71 | #default_role = None 72 | 73 | # If true, '()' will be appended to :func: etc. cross-reference text. 74 | #add_function_parentheses = True 75 | 76 | # If true, the current module name will be prepended to all description 77 | # unit titles (such as .. function::). 78 | #add_module_names = True 79 | 80 | # If true, sectionauthor and moduleauthor directives will be shown in the 81 | # output. They are ignored by default. 82 | #show_authors = False 83 | 84 | # The name of the Pygments (syntax highlighting) style to use. 85 | pygments_style = 'sphinx' 86 | 87 | # A list of ignored prefixes for module index sorting. 88 | #modindex_common_prefix = [] 89 | 90 | 91 | # -- Options for HTML output --------------------------------------------------- 92 | 93 | # The theme to use for HTML and HTML Help pages. See the documentation for 94 | # a list of builtin themes. 95 | html_theme = 'default' 96 | 97 | # Theme options are theme-specific and customize the look and feel of a theme 98 | # further. For a list of options available for each theme, see the 99 | # documentation. 100 | #html_theme_options = {} 101 | 102 | # Add any paths that contain custom themes here, relative to this directory. 103 | #html_theme_path = [] 104 | 105 | # The name for this set of Sphinx documents. If None, it defaults to 106 | # " v documentation". 107 | #html_title = None 108 | 109 | # A shorter title for the navigation bar. Default is the same as html_title. 110 | #html_short_title = None 111 | 112 | # The name of an image file (relative to this directory) to place at the top 113 | # of the sidebar. 114 | #html_logo = None 115 | 116 | # The name of an image file (within the static path) to use as favicon of the 117 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 118 | # pixels large. 119 | #html_favicon = None 120 | 121 | # Add any paths that contain custom static files (such as style sheets) here, 122 | # relative to this directory. They are copied after the builtin static files, 123 | # so a file named "default.css" will overwrite the builtin "default.css". 124 | html_static_path = ['_static'] 125 | 126 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 127 | # using the given strftime format. 128 | #html_last_updated_fmt = '%b %d, %Y' 129 | 130 | # If true, SmartyPants will be used to convert quotes and dashes to 131 | # typographically correct entities. 132 | #html_use_smartypants = True 133 | 134 | # Custom sidebar templates, maps document names to template names. 135 | #html_sidebars = {} 136 | 137 | # Additional templates that should be rendered to pages, maps page names to 138 | # template names. 139 | #html_additional_pages = {} 140 | 141 | # If false, no module index is generated. 142 | #html_domain_indices = True 143 | 144 | # If false, no index is generated. 145 | #html_use_index = True 146 | 147 | # If true, the index is split into individual pages for each letter. 148 | #html_split_index = False 149 | 150 | # If true, links to the reST sources are added to the pages. 151 | #html_show_sourcelink = True 152 | 153 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 154 | #html_show_sphinx = True 155 | 156 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 157 | #html_show_copyright = True 158 | 159 | # If true, an OpenSearch description file will be output, and all pages will 160 | # contain a tag referring to it. The value of this option must be the 161 | # base URL from which the finished HTML is served. 162 | #html_use_opensearch = '' 163 | 164 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 165 | #html_file_suffix = None 166 | 167 | # Output file base name for HTML help builder. 168 | htmlhelp_basename = 'sanctiondoc' 169 | 170 | 171 | # -- Options for LaTeX output -------------------------------------------------- 172 | 173 | latex_elements = { 174 | # The paper size ('letterpaper' or 'a4paper'). 175 | #'papersize': 'letterpaper', 176 | 177 | # The font size ('10pt', '11pt' or '12pt'). 178 | #'pointsize': '10pt', 179 | 180 | # Additional stuff for the LaTeX preamble. 181 | #'preamble': '', 182 | } 183 | 184 | # Grouping the document tree into LaTeX files. List of tuples 185 | # (source start file, target name, title, author, documentclass [howto/manual]). 186 | latex_documents = [ 187 | ('index', 'sanction.tex', u'sanction Documentation', 188 | u'Demian Brecht', 'manual'), 189 | ] 190 | 191 | # The name of an image file (relative to this directory) to place at the top of 192 | # the title page. 193 | #latex_logo = None 194 | 195 | # For "manual" documents, if this is true, then toplevel headings are parts, 196 | # not chapters. 197 | #latex_use_parts = False 198 | 199 | # If true, show page references after internal links. 200 | #latex_show_pagerefs = False 201 | 202 | # If true, show URL addresses after external links. 203 | #latex_show_urls = False 204 | 205 | # Documents to append as an appendix to all manuals. 206 | #latex_appendices = [] 207 | 208 | # If false, no module index is generated. 209 | #latex_domain_indices = True 210 | 211 | 212 | # -- Options for manual page output -------------------------------------------- 213 | 214 | # One entry per manual page. List of tuples 215 | # (source start file, name, description, authors, manual section). 216 | man_pages = [ 217 | ('index', 'sanction', u'sanction Documentation', 218 | [u'Demian Brecht'], 1) 219 | ] 220 | 221 | # If true, show URL addresses after external links. 222 | #man_show_urls = False 223 | 224 | 225 | # -- Options for Texinfo output ------------------------------------------------ 226 | 227 | # Grouping the document tree into Texinfo files. List of tuples 228 | # (source start file, target name, title, author, 229 | # dir menu entry, description, category) 230 | texinfo_documents = [ 231 | ('index', 'sanction', u'sanction Documentation', 232 | u'Demian Brecht', 'sanction', 'One line description of project.', 233 | 'Miscellaneous'), 234 | ] 235 | 236 | # Documents to append as an appendix to all manuals. 237 | #texinfo_appendices = [] 238 | 239 | # If false, no module index is generated. 240 | #texinfo_domain_indices = True 241 | 242 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 243 | #texinfo_show_urls = 'footnote' 244 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. sanction documentation master file, created by 2 | sphinx-quickstart on Tue Aug 20 07:22:31 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | sanction 7 | ======== 8 | 9 | .. image:: https://secure.travis-ci.org/demianbrecht/sanction.png?branch=master 10 | :target: http://travis-ci.org/#!/demianbrecht/sanction 11 | 12 | .. raw:: html 13 | 14 | 15 | 16 | sanction [sangk-shuhn]: authoritative permission or approval, as for an action. 17 | 18 | .. contents:: 19 | :depth: 3 20 | 21 | 22 | Overview 23 | -------- 24 | sanction is a lightweight, dead simple client implementation of the 25 | OAuth2 protocol. The major goals of the library are: 26 | 27 | - Grok me 28 | 29 | - Variations on OAuth2 client implementation range from a few hundred 30 | LOC to thousands. In a Pythonic world, there's absolutely no need for 31 | this when simply dealing with the client side of the spec. Currently, 32 | sanction sits at a whopping 65 LOC, one class. This makes the library 33 | tremendously easy to grok. 34 | 35 | - Support multiple providers 36 | 37 | - Most providers have varying levels of diversion from the official 38 | spec. The goal with this library is to either handle these diversions 39 | natively, or expose a method to allow client code to deal with it 40 | efficiently and effectively. 41 | 42 | - Support all server-side OAuth2 flows 43 | 44 | - Three of the four OAuth2 flows should be supported by this library. 45 | Currently, only authorization code and client credential flows have 46 | been tested due to lack of other (known) implementations. 47 | 48 | sanction has been tested with the following OAuth2 providers: 49 | 50 | * Facebook_ (include the test API) 51 | * Google_ 52 | * Foursquare_ 53 | * bitly_ 54 | * GitHub_ 55 | * StackExchange_ 56 | * Instagram_ 57 | * DeviantArt_ 58 | 59 | .. _Facebook: https://developers.facebook.com/docs/authentication/ 60 | .. _Google: https://developers.google.com/accounts/docs/OAuth2 61 | .. _Foursquare: https://developer.foursquare.com/overview/auth 62 | .. _GitHub: http://developer.github.com/v3/oauth/ 63 | .. _Instagram: http://instagram.com/developer/ 64 | .. _bitly: http://dev.bitly.com/api.html 65 | .. _StackExchange: https://api.stackexchange.com/docs 66 | .. _Instagram: http://instagram.com/developer/ 67 | .. _DeviantArt: http://www.deviantart.com/developers/oauth2 68 | 69 | :note: The intention of the sanction library is to not only provide accessibility 70 | to the OAuth2 authorization code flow, but all server-side flows. However, 71 | due to lack of implementations, the only tested currently teseted flows 72 | are authorization code and client credentials. 73 | 74 | 75 | Quickstart 76 | ---------- 77 | 78 | For the quickstart, authorization code grant flow is assumed, as is the 79 | Bearer token type. If you're unfamiliar with these terms, chances are that 80 | they're what you're looking for as it's the default in most public OAuth2 81 | provider implementations (Google, Facebook, Foursquare, etc.). 82 | 83 | Introducing this library should be rather trivial (in the usual basic case). 84 | There are three steps required in the most common use case (Google is assumed 85 | to be the provider throughout sample code): 86 | 87 | You can also take a look at the example code in ``/example``. 88 | 89 | Instantiation 90 | ````````````` 91 | 92 | To access protected resources via the OAuth2 protocol, you must instantiate a 93 | ``Client`` and pass it relevant endpoints for your current operation:: 94 | 95 | from sanction.client import Client 96 | 97 | # instantiating a client to get the auth URI 98 | c = Client(auth_endpoint="https://accounts.google.com/o/oauth2/auth", 99 | client_id=config["google.client_id"], 100 | redirect_uri="http://localhost:8080/login/google") 101 | 102 | # instantiating a client to process OAuth2 response 103 | c = Client(token_endpoint="https://accounts.google.com/o/oauth2/token", 104 | resource_endpoint="https://www.googleapis.com/oauth2/v1", 105 | redirect_uri="http://localhost:8080/login/google", 106 | client_id=config["google.client_id"], 107 | client_secret=config["google.client_secret"]) 108 | 109 | 110 | Of course, you may create the config ``dict`` in your preferred method, the 111 | above is simply for demonstration using the required config settings (the 112 | example project uses ``ConfigParser`` against an ``.ini`` file for settings. 113 | 114 | 115 | Authorization Request 116 | ````````````````````` 117 | The next step is to redirect the user agent to the provider's authentication/ 118 | authorization uri (continuation from previous code block):: 119 | 120 | scope_req = 'scope1,scope2' 121 | my_redirect(c.auth_uri(scope_req)) 122 | 123 | :note: (New in 0.4) The scope is assumed to be consistent with the expectations 124 | of the provider. For example, scope being passed to Facebook should be 125 | a single string with comma-delimited entities while Google should be 126 | delimited by spaces (i.e. Facebook: ``scope_req = 'scope1,scope2'``). 127 | 128 | You can also elect to use the optional ``state`` parameter to pass a CSRF token 129 | that will be included if the provider's response:: 130 | 131 | my_redirect(c.auth_uri(state=my_state)) 132 | 133 | :note: It is **strongly** encouraged that you use the ``state`` parameter to 134 | offer CSRF protection. It is also up to you to process the ``state`` 135 | parameter and handle redirection accordingly *before* calling 136 | ``request_token``. 137 | 138 | 139 | Access Token Request 140 | ```````````````````` 141 | When the user has granted or denied resource access to your application, they 142 | will be redirected to the ``redirect_uri`` as specified by the value of the ``GET`` 143 | param. In order to request an access token from the provider, you must 144 | request an access token from the provider:: 145 | 146 | c.request_token(response_dict) 147 | 148 | 149 | The default parser (``sanction._default_parser``) will attempt to decode JSON, 150 | followed by an attempt to parse querystring-formatted data (i.e. access token 151 | data returned by Facebook). If the provider you're working with has a different 152 | requirement, you can use the ``parser`` parameter of ``request_token`` to pass 153 | another callable that will be responsible for decoding the returned data. 154 | 155 | 156 | Refreshing Access Tokens 157 | ```````````````````````` 158 | Some (not all) providers provide the ability to refresh a given access token, 159 | giving an application to users' data even if they're offline (Google is one 160 | of these providers). If your client previously received a refresh token 161 | with the initial code/token exchange, then you can use the ``request_token`` 162 | API to request a refreshed token:: 163 | 164 | c.request_token(grant_type='refresh_token', 165 | refresh_token=my_refresh_token) 166 | 167 | An example of this is shown in the Google login handler in the sample app. 168 | 169 | 170 | Resource Request 171 | ```````````````` 172 | 173 | If the user has granted access and your config settings are correct, you should 174 | then be able to access protected resources through the adapter's API:: 175 | 176 | c.request("/userinfo") 177 | 178 | If the provider has deviated from the OAuth2 spec and the response isn't JSON 179 | (i.e. Stack Exchange), you can pass a custom parser to ``request``:: 180 | 181 | c.request("/userinfo", parser=lambda c: dosomething(c)) 182 | 183 | 184 | API 185 | ``` 186 | 187 | .. automodule:: sanction 188 | :members: 189 | 190 | 191 | Notes 192 | ----- 193 | 194 | There are no implementations for individual OAuth2-exposed resources. This is not 195 | the intention of the library and will not be added. 196 | 197 | 198 | Thanks 199 | ------ 200 | 201 | * `Alec Taylor`_: Example code refactor. 202 | * `Jake Basile`_: Pointing out the oversight of the ``refresh_token``. 203 | * `Ricky Elrod`_: Python 2.6 string index fix. 204 | 205 | 206 | .. _`Jake Basile`: https://github.com/jakebasile 207 | .. _`Alec Taylor`: https://github.com/AlecTaylor 208 | .. _`Ricky Elrod`: https://github.com/CodeBlock 209 | 210 | 211 | Indices and tables 212 | ================== 213 | 214 | * :ref:`genindex` 215 | * :ref:`modindex` 216 | * :ref:`search` 217 | 218 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\sanction.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\sanction.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /example/example.ini: -------------------------------------------------------------------------------- 1 | [sanction] 2 | google.client_id = 478624508801.apps.googleusercontent.com 3 | google.client_secret = lHATmcZjBrTfXbVDtLcUMpA3 4 | google.redirect_uri = http://localhost/login/google 5 | google.scope = https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile http://www.blogger.com/feeds/ 6 | google.access_type = online 7 | 8 | facebook.client_id = 285809954824916 9 | facebook.client_secret = d985f6a3ecaffd11d61b3cd026b8753a 10 | facebook.redirect_uri = http://localhost/login/facebook 11 | facebook.scope = read_stream,email,publish_stream 12 | 13 | foursquare.client_id = RNFFUX2WE5LDDD1NE2MUKGD5CESXKX0U4DJXPGDN24O0KENY 14 | foursquare.client_secret = OC5CIEMC0EYCBMR3PT1BG0WVPMIZKT1QM2KNA5ARC2GGWZRE 15 | foursquare.redirect_uri = http://localhost/login/foursquare 16 | 17 | bitly.client_id = 914e312cad093e7ab29b6057d826e6232dd21b64 18 | bitly.client_secret = 84d36c2376b272f7e81c5fce7c413282060b1c43 19 | bitly.redirect_uri = http://localhost/login/bitly 20 | 21 | github.client_id = 99439d8f709b8420e690 22 | github.client_secret = 36908df9c0248710d364d2bf2b583e3430290ffa 23 | github.redirect_uri = http://localhost/login/github 24 | 25 | instagram.client_id = f1676673441b4a84a7c82f2ddea72a64 26 | instagram.client_secret = 19b7a1400b1f4fda8d5829e77585e9d0 27 | instagram.redirect_uri = http://localhost/login/instagram 28 | 29 | stackexchange.client_id = 454 30 | stackexchange.client_secret = BzBMVkgaukCaJwe5PCViBA(( 31 | stackexchange.redirect_uri = http://localhost/login/stackexchange 32 | stackexchange.key = IoYZPCrlYCgBXIuM8VixlA(( 33 | 34 | deviantart.client_id = 216 35 | deviantart.client_secret = aaae25721430bf4231cb47980f273115 36 | deviantart.redirect_uri = http://localhost/login/deviantart 37 | 38 | -------------------------------------------------------------------------------- /example/server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: set ts=4 sw=4 et: 3 | 4 | import logging 5 | import sys, os 6 | 7 | try: 8 | from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler 9 | from ConfigParser import ConfigParser 10 | from urlparse import urlparse, urlsplit, urlunsplit, parse_qsl 11 | from urllib import urlencode 12 | from urllib2 import Request 13 | from io import BytesIO 14 | except ImportError: 15 | from http.server import HTTPServer, BaseHTTPRequestHandler 16 | from configparser import ConfigParser 17 | from urllib.parse import (urlparse, parse_qsl, urlencode, 18 | urlunsplit, urlsplit) 19 | from io import BytesIO 20 | from urllib.request import Request 21 | 22 | from gzip import GzipFile 23 | from json import loads 24 | 25 | # so we can run without installing 26 | sys.path.append(os.path.abspath('../')) 27 | 28 | from sanction import Client, transport_headers 29 | 30 | ENCODING_UTF8 = 'utf-8' 31 | 32 | def get_config(): 33 | config = ConfigParser({}, dict) 34 | config.read('example.ini') 35 | 36 | c = config._sections['sanction'] 37 | if '__name__' in c: 38 | del c['__name__'] 39 | 40 | if 'http_debug' in c: 41 | c['http_debug'] = c['http_debug'] == 'true' 42 | 43 | return config._sections['sanction'] 44 | 45 | 46 | logging.basicConfig(format='%(message)s') 47 | l = logging.getLogger(__name__) 48 | config = get_config() 49 | 50 | 51 | class Handler(BaseHTTPRequestHandler): 52 | route_handlers = { 53 | '/': 'handle_root', 54 | '/login/google': 'handle_google_login', 55 | '/oauth2/google': 'handle_google', 56 | '/login/facebook': 'handle_facebook_login', 57 | '/oauth2/facebook': 'handle_facebook', 58 | '/login/foursquare': 'handle_foursquare_login', 59 | '/oauth2/foursquare': 'handle_foursquare', 60 | '/login/github': 'handle_github_login', 61 | '/oauth2/github': 'handle_github', 62 | '/login/instagram': 'handle_instagram_login', 63 | '/oauth2/instagram': 'handle_instagram', 64 | '/login/stackexchange': 'handle_stackexchange_login', 65 | '/oauth2/stackexchange': 'handle_stackexchange', 66 | '/login/deviantart': 'handle_deviantart_login', 67 | '/oauth2/deviantart': 'handle_deviantart', 68 | } 69 | 70 | def do_GET(self): 71 | url = urlparse(self.path) 72 | if url.path in self.route_handlers: 73 | getattr(self, self.route_handlers[url.path])( 74 | dict(parse_qsl(url.query))) 75 | else: 76 | self.send_response(404) 77 | 78 | def success(func): 79 | def wrapper(self, *args, **kwargs): 80 | self.send_response(200) 81 | self.send_header('Content-type', 'text/html') 82 | self.log_message(self.path) 83 | self.end_headers() 84 | return func(self, *args, **kwargs) 85 | return wrapper 86 | 87 | @success 88 | def handle_root(self, data): 89 | self.wfile.write(''' 90 | login with: Google, 91 | Facebook, 92 | GitHub, 93 | Stack Exchange, 94 | Instagram, 95 | Foursquare, 96 | Deviant Art, 97 | '''.encode(ENCODING_UTF8)) 98 | 99 | def handle_stackexchange(self, data): 100 | self.send_response(302) 101 | c = Client(auth_endpoint='https://stackexchange.com/oauth', 102 | client_id=config['stackexchange.client_id']) 103 | self.send_header('Location', c.auth_uri( 104 | redirect_uri='http://localhost/login/stackexchange')) 105 | self.end_headers() 106 | 107 | def _gunzip(self, data): 108 | s = BytesIO(data) 109 | gz = GzipFile(fileobj=s, mode='rb') 110 | return gz.read() 111 | 112 | @success 113 | def handle_stackexchange_login(self, data): 114 | c = Client(token_endpoint='https://stackexchange.com/oauth/access_token', 115 | resource_endpoint='https://api.stackexchange.com/2.0', 116 | client_id=config['stackexchange.client_id'], 117 | client_secret=config['stackexchange.client_secret']) 118 | 119 | c.request_token(code=data['code'], 120 | parser = lambda data: dict(parse_qsl(data)), 121 | redirect_uri='http://localhost/login/stackexchange') 122 | 123 | self.dump_client(c) 124 | data = c.request('/me?{}'.format(urlencode({ 125 | 'site': 'stackoverflow.com', 126 | 'key': config['stackexchange.key'] 127 | })), parser=lambda c: loads(self._gunzip(c).decode( 128 | 'utf-8')))['items'][0] 129 | 130 | self.dump_response(data) 131 | 132 | def dump_response(self, data): 133 | for k in data: 134 | self.wfile.write('{0}: {1}
'.format(k, 135 | data[k]).encode(ENCODING_UTF8)) 136 | 137 | def dump_client(self, c): 138 | for k in c.__dict__: 139 | self.wfile.write('{0}: {1}
'.format(k, 140 | c.__dict__[k]).encode(ENCODING_UTF8)) 141 | self.wfile.write('
'.encode(ENCODING_UTF8)) 142 | 143 | def handle_google(self, data): 144 | self.send_response(302) 145 | c = Client(auth_endpoint='https://accounts.google.com/o/oauth2/auth', 146 | client_id=config['google.client_id']) 147 | self.send_header('Location', c.auth_uri( 148 | scope=config['google.scope'], access_type='offline', 149 | redirect_uri='http://localhost/login/google')) 150 | self.end_headers() 151 | 152 | @success 153 | def handle_google_login(self, data): 154 | c = Client(token_endpoint='https://accounts.google.com/o/oauth2/token', 155 | resource_endpoint='https://www.googleapis.com/oauth2/v1', 156 | client_id=config['google.client_id'], 157 | client_secret=config['google.client_secret'], 158 | token_transport=transport_headers) 159 | c.request_token(code=data['code'], 160 | redirect_uri='http://localhost/login/google') 161 | 162 | self.dump_client(c) 163 | data = c.request('/userinfo') 164 | self.dump_response(data) 165 | 166 | if hasattr(c, 'refresh_token'): 167 | rc = Client(token_endpoint=c.token_endpoint, 168 | client_id=c.client_id, 169 | client_secret=c.client_secret, 170 | resource_endpoint=c.resource_endpoint, 171 | token_transport='headers') 172 | 173 | rc.request_token(grant_type='refresh_token', 174 | refresh_token=c.refresh_token) 175 | self.wfile.write('

post refresh token:

'.encode(ENCODING_UTF8)) 176 | self.dump_client(rc) 177 | 178 | def handle_facebook(self, data): 179 | self.send_response(302) 180 | c = Client(auth_endpoint='https://www.facebook.com/dialog/oauth', 181 | client_id=config['facebook.client_id']) 182 | self.send_header('Location', c.auth_uri( 183 | scope=config['facebook.scope'], 184 | redirect_uri='http://localhost/login/facebook')) 185 | 186 | self.end_headers() 187 | 188 | @success 189 | def handle_facebook_login(self, data): 190 | c = Client( 191 | token_endpoint='https://graph.facebook.com/oauth/access_token', 192 | resource_endpoint='https://graph.facebook.com', 193 | client_id=config['facebook.client_id'], 194 | client_secret=config['facebook.client_secret']) 195 | 196 | c.request_token(code=data['code'], 197 | redirect_uri='http://localhost/login/facebook') 198 | 199 | self.dump_client(c) 200 | d = c.request('/me') 201 | self.dump_response(d) 202 | 203 | try: 204 | d = c.request('/me/feed', data=bytes(urlencode({ 205 | 'message': 'test post from py-sanction' 206 | }), 'ascii')) 207 | self.wfile.write( 208 | 'I posted a message to your wall (in sandbox mode, nobody ' 209 | 'else will see it)'.encode(ENCODING_UTF8)) 210 | except: 211 | self.wfile.write( 212 | b'Unable to post to your wall') 213 | 214 | def handle_foursquare(self, data): 215 | self.send_response(302) 216 | c = Client(auth_endpoint='https://foursquare.com/oauth2/authenticate', 217 | client_id=config['foursquare.client_id']) 218 | self.send_header('Location', c.auth_uri( 219 | redirect_uri='http://localhost/login/foursquare')) 220 | self.end_headers() 221 | 222 | @success 223 | def handle_foursquare_login(self, data): 224 | def token_transport(url, access_token, data=None, method=None): 225 | parts = urlsplit(url) 226 | query = dict(parse_qsl(parts.query)) 227 | query.update({ 228 | 'oauth_token': access_token 229 | }) 230 | url = urlunsplit((parts.scheme, parts.netloc, parts.path, 231 | urlencode(query), parts.fragment)) 232 | try: 233 | req = Request(url, data=data, method=method) 234 | except TypeError: 235 | req = Request(url, data=data) 236 | req.get_method = lambda: method 237 | return req 238 | 239 | c = Client( 240 | token_endpoint='https://foursquare.com/oauth2/access_token', 241 | resource_endpoint='https://api.foursquare.com/v2', 242 | client_id=config['foursquare.client_id'], 243 | client_secret=config['foursquare.client_secret'], 244 | token_transport=token_transport 245 | ) 246 | c.request_token(code=data['code'], 247 | redirect_uri='http://localhost/login/foursquare') 248 | 249 | self.dump_client(c) 250 | d = c.request('/users/24700343') 251 | self.dump_response(d) 252 | 253 | 254 | def handle_github(self, data): 255 | self.send_response(302) 256 | c = Client(auth_endpoint='https://github.com/login/oauth/authorize', 257 | client_id=config['github.client_id']) 258 | self.send_header('Location', c.auth_uri( 259 | redirect_uri='http://localhost/login/github')) 260 | self.end_headers() 261 | 262 | 263 | @success 264 | def handle_github_login(self, data): 265 | c = Client(token_endpoint='https://github.com/login/oauth/access_token', 266 | resource_endpoint='https://api.github.com', 267 | client_id=config['github.client_id'], 268 | client_secret=config['github.client_secret']) 269 | c.request_token(code=data['code'], 270 | redirect_uri='http://localhost/login/github') 271 | 272 | self.dump_client(c) 273 | data = c.request('/user') 274 | self.dump_response(data) 275 | 276 | 277 | def handle_instagram(self, data): 278 | self.send_response(302) 279 | c = Client(auth_endpoint='https://api.instagram.com/oauth/authorize/', 280 | client_id=config['instagram.client_id']) 281 | self.send_header('Location', c.auth_uri( 282 | redirect_uri='http://localhost/login/instagram')) 283 | self.end_headers() 284 | 285 | 286 | @success 287 | def handle_instagram_login(self, data): 288 | c = Client(token_endpoint='https://api.instagram.com/oauth/access_token', 289 | resource_endpoint='https://api.instagram.com/v1', 290 | client_id=config['instagram.client_id'], 291 | client_secret=config['instagram.client_secret']) 292 | c.request_token(code=data['code'], 293 | redirect_uri='http://localhost/login/instagram') 294 | 295 | self.dump_client(c) 296 | data = c.request('/users/self')['data'] 297 | self.dump_response(data) 298 | 299 | 300 | def handle_deviantart(self, data): 301 | self.send_response(302) 302 | c = Client( 303 | auth_endpoint='https://www.deviantart.com/oauth2/draft15/authorize', 304 | client_id=config['deviantart.client_id']) 305 | self.send_header('Location', c.auth_uri( 306 | redirect_uri=config['deviantart.redirect_uri'])) 307 | self.end_headers() 308 | 309 | 310 | @success 311 | def handle_deviantart_login(self, data): 312 | c = Client( 313 | token_endpoint='https://www.deviantart.com/oauth2/draft15/token', 314 | resource_endpoint='https://www.deviantart.com/api/draft15', 315 | client_id=config['deviantart.client_id'], 316 | client_secret=config['deviantart.client_secret']) 317 | c.request_token(code=data['code'], 318 | redirect_uri=config['deviantart.redirect_uri']) 319 | 320 | self.dump_client(c) 321 | data = c.request('/user/whoami') 322 | self.dump_response(data) 323 | 324 | 325 | if __name__ == '__main__': 326 | l.setLevel(1) 327 | server_address = ('', 80) 328 | server = HTTPServer(server_address, Handler) 329 | l.info('Starting server on %sport %s \nPress +c to exit' % server_address) 330 | server.serve_forever() 331 | 332 | -------------------------------------------------------------------------------- /sanction/__init__.py: -------------------------------------------------------------------------------- 1 | # vim: set ts=4 sw=) 2 | 3 | from functools import wraps 4 | from json import loads 5 | from datetime import datetime, timedelta 6 | from time import mktime 7 | try: 8 | from urllib import urlencode 9 | from urllib2 import Request, urlopen 10 | from urlparse import urlsplit, urlunsplit, parse_qsl 11 | 12 | # monkeypatch httpmessage 13 | from httplib import HTTPMessage 14 | def get_charset(self): 15 | try: 16 | data = filter(lambda s: 'Content-Type' in s, self.headers)[0] 17 | if 'charset' in data: 18 | cs = data[data.index(';') + 1:-2].split('=')[1].lower() 19 | return cs 20 | except IndexError: 21 | pass 22 | 23 | return 'utf-8' 24 | HTTPMessage.get_content_charset = get_charset 25 | except ImportError: # pragma: no cover 26 | from urllib.parse import urlencode, urlsplit, urlunsplit, parse_qsl 27 | from urllib.request import Request, urlopen 28 | 29 | 30 | class Client(object): 31 | """ OAuth 2.0 client object 32 | """ 33 | 34 | def __init__(self, auth_endpoint=None, token_endpoint=None, 35 | resource_endpoint=None, client_id=None, client_secret=None, 36 | token_transport=None): 37 | """ Instantiates a `Client` to authorize and authenticate a user 38 | 39 | :param auth_endpoint: The authorization endpoint as issued by the 40 | provider. This is where the user should be 41 | redirect to provider authorization for your 42 | application. 43 | :param token_endpoint: The endpoint against which a `code` will be 44 | exchanged for an access token. 45 | :param resource_endpoint: The base url to use when accessing resources 46 | via `Client.request`. 47 | :param client_id: The client ID as issued by the provider. 48 | :param client_secret: The client secret as issued by the provider. This 49 | must not be shared. 50 | """ 51 | assert token_transport is None or hasattr(token_transport, '__call__') 52 | 53 | self.auth_endpoint = auth_endpoint 54 | self.token_endpoint = token_endpoint 55 | self.resource_endpoint = resource_endpoint 56 | self.client_id = client_id 57 | self.client_secret = client_secret 58 | self.access_token = None 59 | self.token_transport = token_transport or transport_query 60 | self.token_expires = -1 61 | self.refresh_token = None 62 | 63 | def auth_uri(self, redirect_uri=None, scope=None, scope_delim=None, 64 | state=None, **kwargs): 65 | 66 | """ Builds the auth URI for the authorization endpoint 67 | 68 | :param scope: (optional) The `scope` parameter to pass for 69 | authorization. The format should match that expected by 70 | the provider (i.e. Facebook expects comma-delimited, 71 | while Google expects space-delimited) 72 | :param state: (optional) The `state` parameter to pass for 73 | authorization. If the provider follows the OAuth 2.0 74 | spec, this will be returned to your `redirect_uri` after 75 | authorization. Generally used for CSRF protection. 76 | :param **kwargs: Any other querystring parameters to be passed to the 77 | provider. 78 | """ 79 | kwargs.update({ 80 | 'client_id': self.client_id, 81 | 'response_type': 'code', 82 | }) 83 | 84 | if scope is not None: 85 | kwargs['scope'] = scope 86 | 87 | if state is not None: 88 | kwargs['state'] = state 89 | 90 | if redirect_uri is not None: 91 | kwargs['redirect_uri'] = redirect_uri 92 | 93 | return '%s?%s' % (self.auth_endpoint, urlencode(kwargs)) 94 | 95 | def request_token(self, parser=None, redirect_uri=None, **kwargs): 96 | """ Request an access token from the token endpoint. 97 | This is largely a helper method and expects the client code to 98 | understand what the server expects. Anything that's passed into 99 | ``**kwargs`` will be sent (``urlencode``d) to the endpoint. Client 100 | secret and client ID are automatically included, so are not required 101 | as kwargs. For example:: 102 | 103 | # if requesting access token from auth flow: 104 | { 105 | 'code': rval_from_auth, 106 | } 107 | 108 | # if refreshing access token: 109 | { 110 | 'refresh_token': stored_refresh_token, 111 | 'grant_type': 'refresh_token', 112 | } 113 | 114 | :param parser: Callback to deal with returned data. Not all providers 115 | use JSON. 116 | """ 117 | kwargs = kwargs and kwargs or {} 118 | 119 | parser = parser or _default_parser 120 | kwargs.update({ 121 | 'client_id': self.client_id, 122 | 'client_secret': self.client_secret, 123 | 'grant_type': 'grant_type' in kwargs and kwargs['grant_type'] or \ 124 | 'authorization_code' 125 | }) 126 | if redirect_uri is not None: 127 | kwargs.update({'redirect_uri': redirect_uri}) 128 | 129 | # TODO: maybe raise an exception here if status code isn't 200? 130 | msg = urlopen(self.token_endpoint, urlencode(kwargs).encode( 131 | 'utf-8')) 132 | data = parser(msg.read().decode(msg.info().get_content_charset() or 133 | 'utf-8')) 134 | 135 | for key in data: 136 | setattr(self, key, data[key]) 137 | 138 | # expires_in is RFC-compliant. if anything else is used by the 139 | # provider, token_expires must be set manually 140 | if hasattr(self, 'expires_in'): 141 | try: 142 | # python3 dosn't support long 143 | seconds = long(self.expires_in) 144 | except: 145 | seconds = int(self.expires_in) 146 | self.token_expires = mktime((datetime.utcnow() + timedelta( 147 | seconds=seconds)).timetuple()) 148 | 149 | def refresh(self): 150 | self.request_token(refresh_token=self.refresh_token, 151 | grant_type='refresh_token') 152 | 153 | def request(self, url, method=None, data=None, headers=None, parser=None): 154 | """ Request user data from the resource endpoint 155 | :param url: The path to the resource and querystring if required 156 | :param method: HTTP method. Defaults to ``GET`` unless data is not None 157 | in which case it defaults to ``POST`` 158 | :param data: Data to be POSTed to the resource endpoint 159 | :param parser: Parser callback to deal with the returned data. Defaults 160 | to ``json.loads`.` 161 | """ 162 | assert self.access_token is not None 163 | parser = parser or loads 164 | 165 | if not method: 166 | method = 'GET' if not data else 'POST' 167 | 168 | req = self.token_transport('{0}{1}'.format(self.resource_endpoint, 169 | url), self.access_token, data=data, method=method, headers=headers) 170 | 171 | resp = urlopen(req) 172 | data = resp.read() 173 | try: 174 | return parser(data.decode(resp.info().get_content_charset() or 175 | 'utf-8')) 176 | # try to decode it first using either the content charset, falling 177 | # back to utf-8 178 | 179 | except UnicodeDecodeError: 180 | # if we've gotten a decoder error, the calling code better know how 181 | # to deal with it. some providers (i.e. stackexchange) like to gzip 182 | # their responses, so this allows the client code to handle it 183 | # directly. 184 | return parser(data) 185 | 186 | 187 | def transport_headers(url, access_token, data=None, method=None, headers=None): 188 | try: 189 | req = Request(url, data=data, method=method) 190 | except TypeError: 191 | req = Request(url, data=data) 192 | req.get_method = lambda: method 193 | 194 | add_headers = {'Authorization': 'Bearer {0}'.format(access_token)} 195 | if headers is not None: 196 | add_headers.update(headers) 197 | 198 | req.headers.update(add_headers) 199 | return req 200 | 201 | 202 | def transport_query(url, access_token, data=None, method=None, headers=None): 203 | parts = urlsplit(url) 204 | query = dict(parse_qsl(parts.query)) 205 | query.update({ 206 | 'access_token': access_token 207 | }) 208 | url = urlunsplit((parts.scheme, parts.netloc, parts.path, 209 | urlencode(query), parts.fragment)) 210 | try: 211 | req = Request(url, data=data, method=method) 212 | except TypeError: 213 | req = Request(url, data=data) 214 | req.get_method = lambda: method 215 | 216 | if headers is not None: 217 | req.headers.update(headers) 218 | 219 | return req 220 | 221 | 222 | def _default_parser(data): 223 | try: 224 | return loads(data) 225 | except ValueError: 226 | return dict(parse_qsl(data)) 227 | -------------------------------------------------------------------------------- /sanction/client.py: -------------------------------------------------------------------------------- 1 | from warnings import warn 2 | warn('sanction.client.Client is deprecated, please use sanction.Client') 3 | from sanction import Client 4 | -------------------------------------------------------------------------------- /sanction/test.py: -------------------------------------------------------------------------------- 1 | # vim: set ts=4 sw=4 et: 2 | from io import BytesIO 3 | from functools import wraps 4 | try: 5 | from urllib2 import addinfourl 6 | from httplib import HTTPMessage 7 | except ImportError: 8 | from urllib.response import addinfourl 9 | from http.client import HTTPMessage 10 | basestring = str 11 | 12 | from mock import patch 13 | 14 | 15 | def with_patched_client(data, code=200, headers=None): 16 | def wrapper(fn): 17 | @wraps(fn) 18 | def inner(*args, **kwargs): 19 | with patch('sanction.urlopen') as mock_urlopen: 20 | bdata = type(data) is basestring and data.encode() or data 21 | sheaders = '' 22 | if headers is not None: 23 | sheaders = '\r\n'.join(['{}: {}'.format(k, v) for k, v in 24 | headers.items()]) 25 | bheaders = (sheaders or '').encode() 26 | 27 | mock_urlopen.return_value = addinfourl(BytesIO(bdata), 28 | HTTPMessage(BytesIO(bheaders)), '', code=code) 29 | fn(*args, **kwargs) 30 | return inner 31 | return wrapper 32 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup 3 | 4 | here = os.path.abspath(os.path.dirname(__file__)) 5 | README = open(os.path.join(here, "README")).read() 6 | README = README + open(os.path.join(here, "CHANGES")).read() 7 | 8 | requires = [] 9 | 10 | setup(name="sanction", 11 | keywords="python,oauth2", 12 | version="0.4.1", 13 | description="A simple, lightweight OAuth2 client", 14 | author="Demian Brecht", 15 | author_email="demianbrecht@gmail.com", 16 | url="https://github.com/demianbrecht/sanction", 17 | classifiers=[ 18 | "Development Status :: 5 - Production/Stable", 19 | "Environment :: Web Environment", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: MIT License", 22 | "Natural Language :: English", 23 | "Programming Language :: Python", 24 | "Topic :: Internet :: WWW/HTTP", 25 | "Topic :: Internet :: WWW/HTTP :: Site Management", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | "Topic :: System :: Systems Administration :: Authentication/Directory" 28 | ], 29 | long_description=README, 30 | install_requires=requires, 31 | packages=["sanction",] 32 | ) 33 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | # vim: set ts=4 sw=4 et: 2 | import json 3 | import zlib 4 | 5 | from functools import wraps 6 | from unittest import TestCase 7 | from uuid import uuid4 8 | try: 9 | from urlparse import parse_qsl, urlparse 10 | except ImportError: 11 | from urllib.parse import parse_qsl, urlparse 12 | 13 | from mock import patch 14 | 15 | from sanction import Client, transport_headers 16 | from sanction.test import with_patched_client 17 | 18 | AUTH_ENDPOINT = "http://example.com" 19 | TOKEN_ENDPOINT = "http://example.com/token" 20 | RESOURCE_ENDPOINT = "http://example.com/resource" 21 | CLIENT_ID = "client_id" 22 | CLIENT_SECRET = "client_secret" 23 | REDIRECT_URI = "redirect_uri" 24 | SCOPE = 'foo,bar' 25 | STATE = str(uuid4()) 26 | ACCESS_TOKEN = 'access_token' 27 | 28 | 29 | 30 | class TestClient(TestCase): 31 | def setUp(self): 32 | self.client = Client(auth_endpoint=AUTH_ENDPOINT, 33 | token_endpoint=TOKEN_ENDPOINT, 34 | resource_endpoint=RESOURCE_ENDPOINT, 35 | client_id=CLIENT_ID) 36 | 37 | def test_init(self): 38 | map(lambda c: self.assertEqual(*c), 39 | ((self.client.auth_endpoint, AUTH_ENDPOINT), 40 | (self.client.token_endpoint, TOKEN_ENDPOINT), 41 | (self.client.resource_endpoint, RESOURCE_ENDPOINT), 42 | (self.client.client_id, CLIENT_ID),)) 43 | 44 | def test_auth_uri(self): 45 | parsed = urlparse(self.client.auth_uri(redirect_uri=REDIRECT_URI)) 46 | qs = dict(parse_qsl(parsed.query)) 47 | 48 | map(lambda c: self.assertEqual(*c), 49 | ((qs['redirect_uri'], REDIRECT_URI), 50 | (qs['response_type'], 'code'), 51 | (qs['client_id'], CLIENT_ID))) 52 | 53 | parsed = urlparse(self.client.auth_uri(scope=SCOPE)) 54 | qs = dict(parse_qsl(parsed.query)) 55 | 56 | self.assertEqual(qs['scope'], SCOPE) 57 | 58 | parsed = urlparse(self.client.auth_uri(state=STATE)) 59 | qs = dict(parse_qsl(parsed.query)) 60 | 61 | self.assertEqual(qs['state'], STATE) 62 | 63 | @with_patched_client(json.dumps({ 64 | 'access_token':'test_token', 65 | 'expires_in': 300, 66 | })) 67 | def test_request_token_json(self): 68 | self.client.request_token() 69 | self.assertEqual(self.client.access_token, 'test_token') 70 | 71 | self.client.request_token(redirect_uri=REDIRECT_URI) 72 | self.assertEqual(self.client.access_token, 'test_token') 73 | 74 | @with_patched_client('access_token=test_token') 75 | def test_request_token_url(self): 76 | self.client.request_token() 77 | self.assertEqual(self.client.access_token, 'test_token') 78 | 79 | @with_patched_client(json.dumps({ 80 | 'access_token': 'refreshed_token', 81 | })) 82 | def test_refresh_token(self): 83 | self.client.refresh_token = 'refresh_token' 84 | self.client.refresh() 85 | self.assertEqual(self.client.access_token, 'refreshed_token') 86 | 87 | @with_patched_client(json.dumps({ 88 | 'userid': 1234 89 | })) 90 | def test_request(self): 91 | self.client.access_token = 'foo' 92 | data = self.client.request('/foo') 93 | self.assertEqual(data['userid'], 1234) 94 | 95 | @with_patched_client(zlib.compress(json.dumps({ 96 | 'userid': 1234 97 | }).encode('utf8'))) 98 | def test_request_custom_parser(self): 99 | def _decompress(buf): 100 | return json.loads(zlib.decompress(buf).decode()) 101 | 102 | self.client.access_token = 'foo' 103 | data = self.client.request('/foo', parser=_decompress) 104 | self.assertEqual(data['userid'], 1234) 105 | 106 | @with_patched_client(json.dumps({ 107 | 'userid': 1234 108 | })) 109 | def test_request_transport_headers(self): 110 | self.client.token_transport = transport_headers 111 | self.client.access_token = 'foo' 112 | data = self.client.request('/foo') 113 | self.assertEqual(data['userid'], 1234) 114 | 115 | @with_patched_client(json.dumps({ 116 | 'userid': 1234 117 | }), headers={ 118 | 'Content-Type': 'text/html; charset=utf-8', 119 | }) 120 | def test_request_with_charset(self): 121 | self.client.access_token = 'foo' 122 | data = self.client.request('/foo') 123 | self.assertEqual(data['userid'], 1234) 124 | 125 | @with_patched_client(json.dumps({ 126 | 'userid': 1234 127 | })) 128 | def test_custom_transport(self): 129 | def _transport(url, access_token, data=None, method=None, 130 | headers=None): 131 | 132 | self.assertEqual(url, 'http://example.com/resource/foo') 133 | self.assertEqual(access_token, 'foo') 134 | 135 | self.client.access_token = 'foo' 136 | self.client.token_transport = _transport 137 | data = self.client.request('/foo', headers={ 138 | 'Content-Type': 'application/json'}) 139 | 140 | self.assertEqual(data['userid'], 1234) 141 | 142 | @with_patched_client(json.dumps({ 143 | 'userid': 1234 144 | })) 145 | def test_query_transport_with_headers(self): 146 | self.client.access_token = 'foo' 147 | data = self.client.request('/foo', headers={'Content-Type': 148 | 'application/json'}) 149 | 150 | self.assertEqual(data['userid'], 1234) 151 | 152 | @with_patched_client(json.dumps({ 153 | 'userid': 1234 154 | })) 155 | def test_header_transport_with_headers(self): 156 | self.client.access_token = 'foo' 157 | self.client.token_transport = transport_headers 158 | data = self.client.request('/foo', headers={'Content-Type': 159 | 'application/json'}) 160 | 161 | self.assertEqual(data['userid'], 1234) 162 | --------------------------------------------------------------------------------