diff --git a/.gitignore b/.gitignore index 2c6f875..c057419 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ src/ docs/_build/ .eggs/ .python-version +.pytest_cache/ diff --git a/.travis.yml b/.travis.yml index 6c39c62..443e5a0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,17 +5,15 @@ python: - "3.5" - "3.6" env: - - DJANGO=1.7 - DJANGO=1.8 - DJANGO=1.9 - DJANGO=1.10 - DJANGO=1.11 + - DJANGO=2.0 matrix: exclude: - - python: "3.5" - env: DJANGO=1.7 - - python: "3.6" - env: DJANGO=1.7 + - python: "2.7" + env: DJANGO=2.0 install: - pip install tox coveralls script: diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index f572a6d..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,290 +0,0 @@ -# CHANGELOG - -All notable changes to this project will be documented in this file. - -### [Unreleased] - -### [0.5.0] - 2017-05-18 - -##### Added -- Signals when user accept/decline the authorization page. -- `OIDC_AFTER_END_SESSION_HOOK` setting for additional business logic. -- Feature granttype password. -- require_consent and reuse_consent are added to Client model. - -##### Changed -- OIDC_SKIP_CONSENT_ALWAYS and OIDC_SKIP_CONSENT_ENABLE are removed from settings. - -##### Fixed -- Timestamps with unixtime (instead of django timezone). -- Field refresh_token cannot be primary key if null. -- `create_uri_exceptions` are now being logged at `Exception` level not `DEBUG`. - -### [0.4.4] - 2016-11-29 - -##### Fixed -- Bug in Session Management middleware when using Python 3. -- Translations handling. - -### [0.4.3] - 2016-11-02 - -##### Added -- Session Management 1.0 support. -- post_logout_redirect_uris into admin. - -##### Changed -- Package url names. -- Rename /logout/ url to /end-session/. - -##### Fixed -- Bug when trying authorize with response_type id_token without openid scope. - -### [0.4.2] - 2016-10-13 - -##### Added -- Support for client redirect URIs with query strings. - -##### Fixed -- Bug when generating secret_key value using admin. - -##### Changed -- Client is available to OIDC_EXTRA_SCOPE_CLAIMS implementations via `self.client`. -- The constructor signature for `ScopeClaims` has changed, it now is called with the `Token` as its single argument. - -### [0.4.1] - 2016-10-03 - -##### Changed -- Update pyjwkest to version 1.3.0. -- Use Cryptodome instead of Crypto lib. - -### [0.4.0] - 2016-09-12 - -##### Added -- Support for Hybrid Flow. -- New attributes for Clients: Website url, logo, contact email, terms url. -- Polish translations. -- Examples section in documentation. - -##### Fixed -- CORS in discovery and userinfo endpoint. -- Client type public bug when created using the admin. -- Missing OIDC_TOKEN_EXPIRE setting on implicit flow. - -### [0.3.7] - 2016-08-31 - -##### Added -- Support for Django 1.10. -- Initial translation files (ES, FR). -- Support for at_hash parameter. - -##### Fixed -- Empty address dict in userinfo response. - -### [0.3.6] - 2016-07-07 - -##### Changed -- OIDC_USERINFO setting. - -### [0.3.5] - 2016-06-21 - -##### Added -- Field date_given in UserConsent model. -- Verbose names to all model fields. -- Customize scopes names and descriptions on authorize template. - -##### Changed -- OIDC_EXTRA_SCOPE_CLAIMS setting. - -### [0.3.4] - 2016-06-10 - -##### Changed -- Make SITE_URL setting optional. - -##### Fixed -- Missing migration. - -### [0.3.3] - 2016-05-03 - -##### Fixed -- Important bug with PKCE and form submit in Auth Request. - -### [0.3.2] - 2016-04-26 - -##### Added -- Choose type of client on creation. -- Implement Proof Key for Code Exchange by OAuth Public Clients. -- Support for prompt parameter. -- Support for different client JWT tokens algorithm. - -##### Fixed -- Not auto-approve requests for non-confidential clients (publics). - -### [0.3.1] - 2016-03-09 - -##### Fixed -- response_type was not being validated (OpenID request). - -### [0.3.0] - 2016-02-23 - -##### Added -- Support OAuth2 requests. -- Decorator for protecting views with OAuth2. -- Setting OIDC_IDTOKEN_PROCESSING_HOOK. - -### [0.2.5] - 2016-02-03 - -##### Added -- Setting OIDC_SKIP_CONSENT_ALWAYS. - -##### Changed -- Removing OIDC_RSA_KEY_FOLDER setting. Moving RSA Keys to the database. -- Update pyjwkest to version 1.1.0. - -##### Fixed -- Nonce parameter missing on the decide form. -- Set Allow-Origin header to jwks endpoint. - -### [0.2.4] - 2016-01-20 - -##### Added -- Auto-generation of client ID and SECRET using the admin. -- Validate nonce parameter when using Implicit Flow. - -##### Fixed -- Fixed generating RSA key by ignoring value of OIDC_RSA_KEY_FOLDER. -- Make OIDC_AFTER_USERLOGIN_HOOK and OIDC_IDTOKEN_SUB_GENERATOR to be lazy imported by the location of the function. -- Problem with a function that generate urls for the /.well-known/openid-configuration/ endpoint. - -### [0.2.3] - 2016-01-06 - -##### Added -- Make user and client unique on UserConsent model. -- Support for URL's without end slash. - -##### Changed -- Upgrade pyjwkest to version 1.0.8. - -##### Fixed -- String format error in models. -- Redirect to non http urls fail (for Mobile Apps). - -### [0.2.1] - 2015-10-21 - -##### Added -- Refresh token flow. - -##### Changed -- Upgrade pyjwkest to version >= 1.0.6. - -##### Fixed -- Unicode error in Client model. -- Bug in creatersakey command (when using Python 3). -- Bug when updating pyjwkest version. - -### [0.2.0] - 2015-09-25 - -##### Changed -- UserInfo model was removed. Now you can add your own model using OIDC_USERINFO setting. - -##### Fixed -- ID token does NOT contain kid. - -### [0.1.2] - 2015-08-04 - -##### Added -- Add token_endpoint_auth_methods_supported to discovery. - -##### Fixed -- Missing commands folder in setup file. - -### [0.1.1] - 2015-07-31 - -##### Added -- Sending access_token as query string parameter in UserInfo Endpoint. -- Support HTTP Basic client authentication. - -##### Changed -- Use models setting instead of User. - -##### Fixed -- In python 2: "aud" and "nonce" parameters didn't appear in id_token. - -### [0.1.0] - 2015-07-17 - -##### Added -- Now id tokens are signed/encrypted with RS256. -- Command for easily generate random RSA key. -- Jwks uri to discovery endpoint. -- id_token_signing_alg_values_supported to discovery endpoint. - -##### Fixed -- Nonce support for both Code and Implicit flow. - -### [0.0.7] - 2015-07-06 - -##### Added -- Support for Python 3. -- Way of remember user consent and skipt it (OIDC_SKIP_CONSENT_ENABLE). -- Setting OIDC_SKIP_CONSENT_EXPIRE. - -##### Changed -- Now OIDC_EXTRA_SCOPE_CLAIMS must be a string, to be lazy imported. - -### [0.0.6] - 2015-06-16 - -##### Added -- Better naming for models in the admin. - -##### Changed -- Now tests run without the need of a project configured. - -##### Fixed -- Error when returning address_formatted claim. - -### [0.0.5] - 2015-05-09 - -##### Added -- Support for Django 1.8. - -##### Fixed -- Validation of scope in UserInfo endpoint. - -### [0.0.4] - 2015-04-22 - -##### Added -- Initial migrations. - -##### Fixed -- Important bug with id_token when using implicit flow. -- Validate Code expiration in Auth Code Flow. -- Validate Access Token expiration in UserInfo endpoint. - -### [0.0.3] - 2015-04-15 - -##### Added -- Normalize gender field in UserInfo. - -##### Changed -- Make address_formatted a property inside UserInfo. - -##### Fixed -- Important bug in claims response. - -### [0.0.2] - 2015-03-26 - -##### Added -- Setting OIDC_AFTER_USERLOGIN_HOOK. - -##### Fixed -- Tests failing because an incorrect tag in one template. - -### [0.0.1] - 2015-03-13 - -##### Added -- Provider Configuration Information endpoint. -- Setting OIDC_IDTOKEN_SUB_GENERATOR. - -##### Changed -- Now use setup in OIDC_EXTRA_SCOPE_CLAIMS setting. - -### [0.0.0] - 2015-02-26 diff --git a/README.md b/README.md index f126d84..074dc01 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# Django OIDC Provider +# Django OpenID Connect Provider [![Python Versions](https://img.shields.io/pypi/pyversions/django-oidc-provider.svg)](https://pypi.python.org/pypi/django-oidc-provider) [![PyPI Versions](https://img.shields.io/pypi/v/django-oidc-provider.svg)](https://pypi.python.org/pypi/django-oidc-provider) -[![Documentation Status](https://readthedocs.org/projects/django-oidc-provider/badge/?version=v0.4.x)](http://django-oidc-provider.readthedocs.io/en/v0.4.x/?badge=v0.4.x) -[![Travis](https://travis-ci.org/juanifioren/django-oidc-provider.svg?branch=v0.4.x)](https://travis-ci.org/juanifioren/django-oidc-provider) +[![Documentation Status](https://readthedocs.org/projects/django-oidc-provider/badge/?version=master)](http://django-oidc-provider.readthedocs.io/) +[![Travis](https://travis-ci.org/juanifioren/django-oidc-provider.svg?branch=master)](https://travis-ci.org/juanifioren/django-oidc-provider) ## About OpenID @@ -11,17 +11,10 @@ OpenID Connect is a simple identity layer on top of the OAuth 2.0 protocol, whic ## About the package -`django-oidc-provider` can help you providing out of the box all the endpoints, data and logic needed to add OpenID Connect capabilities to your Django projects. +`django-oidc-provider` can help you providing out of the box all the endpoints, data and logic needed to add OpenID Connect (and OAuth2) capabilities to your Django projects. Support for Python 3 and 2. Also latest versions of django. -[Read docs for more info](http://django-oidc-provider.readthedocs.org/). +[Read documentation for more info.](http://django-oidc-provider.readthedocs.org/) -## Contributing - -[Join us!](https://github.com/juanifioren/django-oidc-provider/graphs/contributors) we love contributions, so please feel free to fix bugs, improve things, provide documentation. You SHOULD follow this steps: - -* Fork the project. -* Make your feature addition or bug fix. -* Add tests for it inside `oidc_provider/tests`. Then run all and ensure everything is OK (read docs for how to test in all envs). -* Send pull request to the `develop` branch. +[Do you want to contribute? Please read this.](http://django-oidc-provider.readthedocs.io/en/latest/sections/contribute.html) diff --git a/docs/conf.py b/docs/conf.py index e3760f6..62ca4c1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,18 +12,18 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys -import os +# import sys +# import os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +# sys.path.insert(0, os.path.abspath('.')) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom @@ -38,7 +38,7 @@ templates_path = ['_templates'] # source_suffix = ['.rst'] # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' @@ -53,9 +53,9 @@ author = u'Juan Ignacio Fiorentino' # built documents. # # The short X.Y version. -version = u'0.3' +version = u'0.5' # The full version, including alpha/beta/rc tags. -release = u'0.3.x' +release = u'0.5.x' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -66,9 +66,9 @@ language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -76,27 +76,27 @@ exclude_patterns = ['_build'] # The reST default role (used for this markup: `text`) to use for all # documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False @@ -106,31 +106,31 @@ todo_include_todos = False # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'alabaster' +html_theme = 'sphinx_rtd_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -140,62 +140,62 @@ html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. -#html_extra_path = [] +# html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' -#html_search_language = 'en' +# html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # Now only 'ja' uses this config value -#html_search_options = {'type': 'default'} +# html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. -#html_search_scorer = 'scorer.js' +# html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. htmlhelp_basename = 'django-oidc-providerdoc' @@ -203,17 +203,17 @@ htmlhelp_basename = 'django-oidc-providerdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', + # The paper size ('letterpaper' or 'a4paper'). + # 'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', + # The font size ('10pt', '11pt' or '12pt'). + # 'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # Additional stuff for the LaTeX preamble. + # 'preamble': '', -# Latex figure (float) alignment -#'figure_align': 'htbp', + # Latex figure (float) alignment + # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples @@ -226,23 +226,23 @@ latex_documents = [ # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output --------------------------------------- @@ -255,7 +255,7 @@ man_pages = [ ] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------- @@ -270,16 +270,16 @@ texinfo_documents = [ ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False # -- Options for Epub output ---------------------------------------------- @@ -291,62 +291,62 @@ epub_publisher = author epub_copyright = copyright # The basename for the epub file. It defaults to the project name. -#epub_basename = project +# epub_basename = project # The HTML theme for the epub output. Since the default themes are not # optimized for small screen space, using the same theme for HTML and epub # output is usually not wise. This defaults to 'epub', a theme designed to save # visual space. -#epub_theme = 'epub' +# epub_theme = 'epub' # The language of the text. It defaults to the language option # or 'en' if the language is not set. -#epub_language = '' +# epub_language = '' # The scheme of the identifier. Typical schemes are ISBN or URL. -#epub_scheme = '' +# epub_scheme = '' # The unique identifier of the text. This can be a ISBN number # or the project homepage. -#epub_identifier = '' +# epub_identifier = '' # A unique identification for the text. -#epub_uid = '' +# epub_uid = '' # A tuple containing the cover image and cover page html template filenames. -#epub_cover = () +# epub_cover = () # A sequence of (type, uri, title) tuples for the guide element of content.opf. -#epub_guide = () +# epub_guide = () # HTML files that should be inserted before the pages created by sphinx. # The format is a list of tuples containing the path and title. -#epub_pre_files = [] +# epub_pre_files = [] # HTML files that should be inserted after the pages created by sphinx. # The format is a list of tuples containing the path and title. -#epub_post_files = [] +# epub_post_files = [] # A list of files that should not be packed into the epub file. epub_exclude_files = ['search.html'] # The depth of the table of contents in toc.ncx. -#epub_tocdepth = 3 +# epub_tocdepth = 3 # Allow duplicate toc entries. -#epub_tocdup = True +# epub_tocdup = True # Choose between 'default' and 'includehidden'. -#epub_tocscope = 'default' +# epub_tocscope = 'default' # Fix unsupported image types using the Pillow. -#epub_fix_images = False +# epub_fix_images = False # Scale large images. -#epub_max_image_width = 0 +# epub_max_image_width = 0 # How to display URL addresses: 'footnote', 'no', or 'inline'. -#epub_show_urls = 'inline' +# epub_show_urls = 'inline' # If false, no index is generated. -#epub_use_index = True +# epub_use_index = True diff --git a/docs/index.rst b/docs/index.rst index 1a15854..05edb50 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,7 +1,7 @@ Welcome to Django OIDC Provider Documentation! ============================================== -This tiny (but powerful!) package can help you providing out of the box all the endpoints, data and logic needed to add OpenID Connect capabilities to your Django projects. And as a side effect a fair implementation of OAuth2.0 too. Covers Authorization Code, Implicit and Hybrid flows. +This tiny (but powerful!) package can help you to provide out of the box all the endpoints, data and logic needed to add OpenID Connect capabilities to your Django projects. And as a side effect a fair implementation of OAuth2.0 too. Covers Authorization Code, Implicit and Hybrid flows. Also implements the following specifications: @@ -15,8 +15,8 @@ Also implements the following specifications: Before getting started there are some important things that you should know: -* Despite that implementation MUST support TLS. You can make request without using SSL. There is no control on that. -* Supports only for requesting Claims using Scope values. +* Despite that implementation MUST support TLS, you *can* make request without using SSL. There is no control on that. +* Supports only requesting Claims using Scope values, so you cannot request individual Claims. * If you enable the Resource Owner Password Credentials Grant, you MUST implement protection against brute force attacks on the token endpoint -------------------------------------------------------------------------------- @@ -39,6 +39,7 @@ Contents: sections/signals sections/examples sections/contribute + sections/changelog .. Indices and tables diff --git a/docs/sections/accesstokens.rst b/docs/sections/accesstokens.rst index 0e000aa..0472853 100644 --- a/docs/sections/accesstokens.rst +++ b/docs/sections/accesstokens.rst @@ -3,11 +3,11 @@ Access Tokens ############# -At the end of the login process, an access token is generated. This access token is the thing that's passed along with every API call (e.g. userinfo endpoint) as proof that the call was made by a specific person from a specific app. +At the end of the login process, an access token is generated. This access token is the thing that is passed along with every API call to the openid connect server (e.g. userinfo endpoint) as proof that the call was made by a specific person from a specific app. -Access tokens generally have a lifetime of only a couple of hours, you can use ``OIDC_TOKEN_EXPIRE`` to set custom expiration that suit your needs. +Access tokens generally have a lifetime of only a couple of hours. You can use ``OIDC_TOKEN_EXPIRE`` to set a custom expiration time that suits your needs. -Obtaining an Access token +Obtaining an Access Token ========================= Go to the admin site and create a confidential client with ``response_type = code`` and ``redirect_uri = http://example.org/``. @@ -20,7 +20,7 @@ In the redirected URL you should have a ``code`` parameter included as query str http://example.org/?code=b9cedb346ee04f15ab1d3ac13da92002&state=123123 -We use ``code`` value to obtain ``access_token`` and ``refresh_token``:: +We use the ``code`` value to obtain ``access_token`` and ``refresh_token``:: curl -X POST \ -H "Cache-Control: no-cache" \ @@ -42,7 +42,7 @@ Example response:: "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6..." } -Then you can grab the access token and ask user data by doing a GET request to the ``/userinfo`` endpoint:: +Then you can grab the access token and ask for user data by doing a GET request to the ``/userinfo`` endpoint:: curl -X GET \ -H "Cache-Control: no-cache" \ @@ -51,9 +51,9 @@ Then you can grab the access token and ask user data by doing a GET request to t Expiration and Refresh of Access Tokens ======================================= -If you receive a ``401 Unauthorized`` status when issuing access token probably means that has expired. +If you receive a ``401 Unauthorized`` status when using the access token, this probably means that your access token has expired. -The RP application obtains a new access token by sending a POST request to the ``/token`` endpoint with the following request parameters:: +The RP application can request a new access token by using the refresh token. Send a POST request to the ``/token`` endpoint with the following request parameters:: curl -X POST \ -H "Cache-Control: no-cache" \ diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst new file mode 100644 index 0000000..94a7840 --- /dev/null +++ b/docs/sections/changelog.rst @@ -0,0 +1,337 @@ +.. _changelog: + +Changelog +######### + +All notable changes to this project will be documented in this file. + +Unreleased +========== + +* Added: token instrospection endpoint support (RFC7662). +* Added: request in password grant authenticate call. +* Changed: Dropping support for Django versions before 1.8. + +0.6.0 +===== + +*2018-04-13* + +* Added: OAuth2 grant_type client_credentials support. +* Added: pep8 compliance and checker. +* Added: Setting OIDC_IDTOKEN_INCLUDE_CLAIMS supporting claims inside id_token. +* Changed: Test suit now uses pytest. +* Fixed: Infinite callback loop in the check-session iframe. + +0.5.3 +===== + +*2018-03-09* + +* Fixed: Update project to support Django 2.0 + +0.5.2 +===== + +*2017-08-22* + +* Fixed: infinite login loop if "prompt=login" (#198) +* Fixed: Django 2.0 deprecation warnings (#185) + +0.5.1 +===== + +*2017-07-11* + +* Changed: Documentation template changed to Read The Docs. +* Fixed: install_requires has not longer pinned versions. +* Fixed: Removed infinity loop during authorization stage when prompt=login has been send. +* Fixed: Changed prompt handling as set of options instead of regular string. +* Fixed: Redirect URI must match exactly with given in query parameter. +* Fixed: Stored user consent are useful for public clients too. +* Fixed: documentation for custom scopes handling. +* Fixed: Scopes during refresh and code exchange are being taken from authorization request and not from query parameters. + +0.5.0 +===== + +*2017-05-18* + +* Added: signals when user accept/decline the authorization page. +* Added: OIDC_AFTER_END_SESSION_HOOK setting for additional business logic. +* Added: feature granttype password. +* Added: require_consent and reuse_consent are added to Client model. +* Changed: OIDC_SKIP_CONSENT_ALWAYS and OIDC_SKIP_CONSENT_ENABLE are removed from settings. +* Fixed: timestamps with unixtime (instead of django timezone). +* Fixed: field refresh_token cannot be primary key if null. +* Fixed: create_uri_exceptions are now being logged at Exception level not DEBUG. + +0.4.4 +===== + +*2016-11-29* + +* Fixed: Bug in Session Management middleware when using Python 3. +* Fixed: Translations handling. + +0.4.3 +===== + +*2016-11-02* + +* Added: Session Management 1.0 support. +* Added: post_logout_redirect_uris into admin. +* Changed: Package url names. +* Changed: Rename /logout/ url to /end-session/. +* Fixed: bug when trying authorize with response_type id_token without openid scope. + +0.4.2 +===== + +*2016-10-13* + +* Added: support for client redirect URIs with query strings. +* Fixed: bug when generating secret_key value using admin. +* Changed: client is available to OIDC_EXTRA_SCOPE_CLAIMS implementations via self.client. +* Changed: the constructor signature for ScopeClaims has changed, it now is called with the Token as its single argument. + +0.4.1 +===== + +*2016-10-03* + +* Changed: update pyjwkest to version 1.3.0. +* Changed: use Cryptodome instead of Crypto lib. + +0.4.0 +===== + +*2016-09-12* + +* Added: support for Hybrid Flow. +* Added: new attributes for Clients: Website url, logo, contact email, terms url. +* Added: polish translations. +* Added: examples section in documentation. +* Fixed: CORS in discovery and userinfo endpoint. +* Fixed: client type public bug when created using the admin. +* Fixed: missing OIDC_TOKEN_EXPIRE setting on implicit flow. + +0.3.7 +===== + +*2016-08-31* + +* Added: support for Django 1.10. +* Added: initial translation files (ES, FR). +* Added: support for at_hash parameter. +* Fixed: empty address dict in userinfo response. + +0.3.6 +===== + +*2016-07-07* + +* Changed: OIDC_USERINFO setting. + +0.3.5 +===== + +*2016-06-21* + +* Added: field date_given in UserConsent model. +* Added: verbose names to all model fields. +* Added: customize scopes names and descriptions on authorize template. +* Changed: OIDC_EXTRA_SCOPE_CLAIMS setting. + +0.3.4 +===== + +*2016-06-10* + +* Changed: Make SITE_URL setting optional. +* Fixed: Missing migration. + +0.3.3 +===== + +*2016-05-03* + +* Fixed: Important bug with PKCE and form submit in Auth Request. + +0.3.2 +===== + +*2016-04-26* + +* Added: choose type of client on creation. +* Added: implement Proof Key for Code Exchange by OAuth Public Clients. +* Added: support for prompt parameter. +* Added: support for different client JWT tokens algorithm. +* Fixed: not auto-approve requests for non-confidential clients (publics). + +0.3.1 +===== + +*2016-03-09* + +* Fixed: response_type was not being validated (OpenID request). + +0.3.0 +===== + +*2016-02-23* + +* Added: support OAuth2 requests. +* Added: decorator for protecting views with OAuth2. +* Added: setting OIDC_IDTOKEN_PROCESSING_HOOK. + +0.2.5 +===== + +*2016-02-03* + +* Added: Setting OIDC_SKIP_CONSENT_ALWAYS. +* Changed: Removing OIDC_RSA_KEY_FOLDER setting. Moving RSA Keys to the database. +* Changed: Update pyjwkest to version 1.1.0. +* Fixed: Nonce parameter missing on the decide form. +* Fixed: Set Allow-Origin header to jwks endpoint. + +0.2.4 +===== + +*2016-01-20* + +* Added: Auto-generation of client ID and SECRET using the admin. +* Added: Validate nonce parameter when using Implicit Flow. +* Fixed: generating RSA key by ignoring value of OIDC_RSA_KEY_FOLDER. +* Fixed: make OIDC_AFTER_USERLOGIN_HOOK and OIDC_IDTOKEN_SUB_GENERATOR to be lazy imported by the location of the function. +* Fixed: problem with a function that generate urls for the /.well-known/openid-configuration/ endpoint. + +0.2.3 +===== + +*2016-01-06* + +* Added: Make user and client unique on UserConsent model. +* Added: Support for URL's without end slash. +* Changed: Upgrade pyjwkest to version 1.0.8. +* Fixed: String format error in models. +* Fixed: Redirect to non http urls fail (for Mobile Apps). + +0.2.1 +===== + +*2015-10-21* + +* Added: refresh token flow. +* Changed: upgrade pyjwkest to version >= 1.0.6. +* Fixed: Unicode error in Client model. +* Fixed: Bug in creatersakey command (when using Python 3). +* Fixed: Bug when updating pyjwkest version. + +0.2.0 +===== + +*2015-09-25* + +* Changed: UserInfo model was removed. Now you can add your own model using OIDC_USERINFO setting. +* Fixed: ID token does NOT contain kid. + +0.1.2 +===== + +*2015-08-04* + +* Added: add token_endpoint_auth_methods_supported to discovery. +* Fixed: missing commands folder in setup file. + +0.1.1 +===== + +*2015-07-31* + +* Added: sending access_token as query string parameter in UserInfo Endpoint. +* Added: support HTTP Basic client authentication. +* Changed: use models setting instead of User. +* Fixed: in python 2: "aud" and "nonce" parameters didn't appear in id_token. + +0.1.0 +===== + +*2015-07-17* + +* Added: now id tokens are signed/encrypted with RS256. +* Added: command for easily generate random RSA key. +* Added: jwks uri to discovery endpoint. +* Added: id_token_signing_alg_values_supported to discovery endpoint. +* Fixed: nonce support for both Code and Implicit flow. + +0.0.7 +===== + +*2015-07-06* + +**** + +* Added: support for Python 3. +* Added: way of remember user consent and skipt it (OIDC_SKIP_CONSENT_ENABLE). +* Added: setting OIDC_SKIP_CONSENT_EXPIRE. +* Changed: now OIDC_EXTRA_SCOPE_CLAIMS must be a string, to be lazy imported. + +0.0.6 +===== + +*2015-06-16* + +* Added: better naming for models in the admin. +* Changed: now tests run without the need of a project configured. +* Fixed: error when returning address_formatted claim. + +0.0.5 +===== + +*2015-05-09* + +* Added: support for Django 1.8. +* Fixed: validation of scope in UserInfo endpoint. + +0.0.4 +===== + +*2015-04-22* + +* Added: initial migrations. +* Fixed: important bug with id_token when using implicit flow. +* Fixed: validate Code expiration in Auth Code Flow. +* Fixed: validate Access Token expiration in UserInfo endpoint. + +0.0.3 +===== + +*2015-04-15* + +* Added: normalize gender field in UserInfo. +* Changed: make address_formatted a property inside UserInfo. +* Fixed: important bug in claims response. + +0.0.2 +===== + +*2015-03-26* + +* Added: setting OIDC_AFTER_USERLOGIN_HOOK. +* Fixed: tests failing because an incorrect tag in one template. + +0.0.1 +===== + +*2015-03-13* + +* Added: provider Configuration Information endpoint. +* Added: setting OIDC_IDTOKEN_SUB_GENERATOR. +* Changed: now use setup in OIDC_EXTRA_SCOPE_CLAIMS setting. + +0.0.0 +===== + +*2015-02-26* diff --git a/docs/sections/contribute.rst b/docs/sections/contribute.rst index 091d28a..e67769c 100644 --- a/docs/sections/contribute.rst +++ b/docs/sections/contribute.rst @@ -3,28 +3,31 @@ Contribute ########## -We love contributions, so please feel free to fix bugs, improve things, provide documentation. You SHOULD follow this steps: +We love contributions, so please feel free to fix bugs, improve things, provide documentation. These are the steps: -* Fork the project. +* Create an issue and explain your feature/bugfix. +* Wait collaborators comments. +* Fork the project and create new branch from `develop`. * Make your feature addition or bug fix. -* Add tests for it inside ``oidc_provider/tests``. Then run all and ensure everything is OK (read docs for how to test in all envs). -* Send pull request to the specific version branch. +* Add tests and documentation if needed. +* Create pull request for the issue to the `develop` branch. +* Wait collaborators reviews. Running Tests ============= -Use `tox `_ for running tests in each of the environments, also to run coverage among:: +Use `tox `_ for running tests in each of the environments, also to run coverage and flake8 among:: # Run all tests. $ tox - # Run with Python 2.7 and Django 1.9. - $ tox -e py27-django19 + # Run with Python 3.5 and Django 2.0. + $ tox -e py35-django20 - # Run single test file. - $ python runtests.py oidc_provider.tests.test_authorize_endpoint + # Run single test file on specific environment. + $ tox -e py35-django20 tests/cases/test_authorize_endpoint.py -Also tests run on every commit to the project, we use `travis `_ for this. +We also use `travis `_ to automatically test every commit to the project. Improve Documentation ===================== @@ -34,4 +37,4 @@ We use `Sphinx `_ for generate this documentation. I * Install Sphinx (``pip install sphinx``) and the auto-build tool (``pip install sphinx-autobuild``). * Move inside the docs folder. ``cd docs/`` * Generate and watch docs by running ``sphinx-autobuild . _build/``. -* Open ``http://127.0.0.1:8000`` on a browser. +* Open ``http://127.0.0.1:8000`` in a browser. diff --git a/docs/sections/examples.rst b/docs/sections/examples.rst index d1e8254..a8f5fe6 100644 --- a/docs/sections/examples.rst +++ b/docs/sections/examples.rst @@ -15,7 +15,7 @@ You can use the example project code to run your OIDC Provider at ``localhost:80 Go to the admin site and create a public client with a response_type ``id_token token`` and a redirect_uri ``http://localhost:3000``. .. note:: - Remember to create at least one **RSA Key** for the server. ``python manage.py creatersakey`` + Remember to create at least one **RSA Key** for the server with ``python manage.py creatersakey`` **02. Create the client** diff --git a/docs/sections/installation.rst b/docs/sections/installation.rst index 53db3ed..a910193 100644 --- a/docs/sections/installation.rst +++ b/docs/sections/installation.rst @@ -6,19 +6,19 @@ Installation Requirements ============ -* Python: ``2.7`` ``3.4`` ``3.5`` -* Django: ``1.7`` ``1.8`` ``1.9`` ``1.10`` +* Python: ``2.7`` ``3.4`` ``3.5`` ``3.6`` +* Django: ``1.8`` ``1.9`` ``1.10`` ``1.11`` ``2.0`` Quick Installation ================== -If you want to get started fast see our ``/example_project`` folder. +If you want to get started fast see our ``/example_project`` folder in your local installation. Or look at it `on github `_. Install the package using pip:: $ pip install django-oidc-provider -Add it to your apps:: +Add it to your apps in your project's django settings:: INSTALLED_APPS = ( 'django.contrib.admin', @@ -31,7 +31,7 @@ Add it to your apps:: # ... ) -Add the provider urls:: +Include our urls to your project's ``urls.py``:: urlpatterns = patterns('', # ... @@ -39,11 +39,11 @@ Add the provider urls:: # ... ) -Generate server RSA key and run migrations (if you don't):: +Run the migrations and generate a server RSA key:: $ python manage.py migrate $ python manage.py creatersakey -Add required variables to your project settings:: +Add this required variable to your project's django settings:: LOGIN_URL = '/accounts/login/' diff --git a/docs/sections/oauth2.rst b/docs/sections/oauth2.rst index bd8f545..6dde5cc 100644 --- a/docs/sections/oauth2.rst +++ b/docs/sections/oauth2.rst @@ -3,12 +3,12 @@ OAuth2 Server ############# -Because OIDC is a layer on top of the OAuth 2.0 protocol, this package gives you a simple but effective OAuth2 server that you can use not only for logging in your users on multiple platforms, also to protect some resources you want to expose. +Because OIDC is a layer on top of the OAuth 2.0 protocol, this package also gives you a simple but effective OAuth2 server that you can use not only for logging in your users on multiple platforms, but also to protect other resources you want to expose. Protecting Views ================ -Here we are going to protect a view with a scope called ``testscope``:: +Here we are going to protect a view with a scope called ``read_books``:: from django.http import JsonResponse from django.views.decorators.http import require_http_methods @@ -17,7 +17,7 @@ Here we are going to protect a view with a scope called ``testscope``:: @require_http_methods(['GET']) - @protected_resource_view(['testscope']) + @protected_resource_view(['read_books']) def protected_api(request, *args, **kwargs): dic = { @@ -25,3 +25,37 @@ Here we are going to protect a view with a scope called ``testscope``:: } return JsonResponse(dic, status=200) + +Client Credentials Grant +======================== + +The client can request an access token using only its client credentials (ID and SECRET) when the client is requesting access to the protected resources under its control, that have been previously arranged with the authorization server using the ``client.scope`` field. + +.. note:: + You can use Django admin to manually set the client scope or programmatically:: + + client.scope = ['read_books', 'add_books'] + client.save() + +This is how the request should look like:: + + POST /token HTTP/1.1 + Host: localhost:8000 + Authorization: Basic eWZ3a3c0cWxtaHY0cToyVWE0QjVzRlhmZ3pNeXR5d1FqT01jNUsxYmpWeXhXeXRySVdsTmpQbld3\ + Content-Type: application/x-www-form-urlencoded + + grant_type=client_credentials + +A successful access token response will like this:: + + HTTP/1.1 200 OK + Content-Type: application/json + Cache-Control: no-store + Pragma: no-cache + + { + "token_type" : "Bearer", + "access_token" : "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJzY3AiOlsib3BlbmlkIiw...", + "expires_in" : 3600, + "scope" : "read_books add_books" + } diff --git a/docs/sections/relyingparties.rst b/docs/sections/relyingparties.rst index d4477e4..d99497a 100644 --- a/docs/sections/relyingparties.rst +++ b/docs/sections/relyingparties.rst @@ -3,8 +3,9 @@ Relying Parties ############### -Relying Parties (RP) creation it's up to you. This is because is out of the scope in the core implementation of OIDC. -So, there are different ways to create your Clients (RP). By displaying a HTML form or maybe if you have internal thrusted Clients you can create them programatically. +Relying Parties (RP) creation is up to you. This is because is out of the scope in the core implementation of OIDC. +So, there are different ways to create your Clients (RP). By displaying a HTML form or maybe if you have internal trusted Clients you can create them programatically. +Out of the box, django-oidc-provider enables you to create them by hand in the django admin. OAuth defines two client types, based on their ability to maintain the confidentiality of their client credentials: @@ -61,4 +62,4 @@ You can create a Client programmatically with Django shell ``python manage.py sh >>> c = Client(name='Some Client', client_id='123', client_secret='456', response_type='code', redirect_uris=['http://example.com/']) >>> c.save() -`Read more about client creation from OAuth2 spec `_ +`Read more about client creation in the OAuth2 spec `_ diff --git a/docs/sections/scopesclaims.rst b/docs/sections/scopesclaims.rst index 58481f3..e0a283e 100644 --- a/docs/sections/scopesclaims.rst +++ b/docs/sections/scopesclaims.rst @@ -78,7 +78,7 @@ Let's say that you want add your custom ``foo`` scope for your OAuth2/OpenID pro Somewhere in your Django ``settings.py``:: - OIDC_USERINFO = 'yourproject.oidc_provider_settings.CustomScopeClaims' + OIDC_EXTRA_SCOPE_CLAIMS = 'yourproject.oidc_provider_settings.CustomScopeClaims' Inside your oidc_provider_settings.py file add the following class:: diff --git a/docs/sections/settings.rst b/docs/sections/settings.rst index 82d1344..f6f3131 100644 --- a/docs/sections/settings.rst +++ b/docs/sections/settings.rst @@ -3,12 +3,12 @@ Settings ######## -Customize your provider so fit your project needs. +Customize django-oidc-provider so that it fits your project's needs. OIDC_LOGIN_URL ============== -OPTIONAL. ``str``. Used to log the user in. By default Django's ``LOGIN_URL`` will be used. `Read more in Django docs `_ +OPTIONAL. ``str``. Used to log the user in. By default Django's ``LOGIN_URL`` will be used. `Read more in the Django docs `_ ``str``. Default is ``/accounts/login/`` (Django's ``LOGIN_URL``). @@ -17,7 +17,7 @@ SITE_URL OPTIONAL. ``str``. The OP server url. -If not specified will be automatically generated using ``request.scheme`` and ``request.get_host()``. +If not specified, it will be automatically generated using ``request.scheme`` and ``request.get_host()``. For example ``http://localhost:8000``. @@ -34,7 +34,7 @@ Default is:: Return ``None`` if you want to continue with the flow. The typical situation will be checking some state of the user or maybe redirect him somewhere. -With request you have access to all OIDC parameters. Remember that if you redirect the user to another place then you need to take him back to the authorize endpoint (use ``request.get_full_path()`` as the value for a "next" parameter). +With ``request`` you have access to all OIDC parameters. Remember that if you redirect the user to another place then you need to take him back to the authorize endpoint (use ``request.get_full_path()`` as the value for a "next" parameter). OIDC_AFTER_END_SESSION_HOOK =========================== @@ -90,6 +90,33 @@ Default is:: return id_token +OIDC_INTROSPECTION_PROCESSING_HOOK +================================== + +OPTIONAL. ``str`` or ``(list, tuple)``. + +A string with the location of your function hook or ``list`` or ``tuple`` with hook functions. +Here you can add extra dictionary values specific to your valid response value for token introspection. + +The function receives an ``introspection_response`` dictionary, a ``client`` instance and an ``id_token`` dictionary. + +Default is:: + + def default_introspection_processing_hook(introspection_response, client, id_token): + + return introspection_response + + +OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE +========================================== + +OPTIONAL ``bool`` + +A flag which toggles whether the audience is matched against the client resource scope when calling the introspection endpoint. + +Default is ``True``. + + OIDC_IDTOKEN_SUB_GENERATOR ========================== @@ -103,38 +130,45 @@ Default is:: return str(user.id) +OIDC_IDTOKEN_INCLUDE_CLAIMS +============================== + +OPTIONAL. ``bool``. If enabled, id_token will include standard claims of the user (email, first name, etc.). + +Default is ``False``. + OIDC_SESSION_MANAGEMENT_ENABLE ============================== -OPTIONAL. ``bool``. Enables OpenID Connect Session Management 1.0 in your provider. Read :ref:`sessionmanagement` section. +OPTIONAL. ``bool``. Enables OpenID Connect Session Management 1.0 in your provider. See the :ref:`sessionmanagement` section. Default is ``False``. OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY =========================================== -OPTIONAL. Supply a fixed string to use as browser-state key for unauthenticated clients. Read :ref:`sessionmanagement` section. +OPTIONAL. Supply a fixed string to use as browser-state key for unauthenticated clients. See the :ref:`sessionmanagement` section. Default is a string generated at startup. OIDC_SKIP_CONSENT_EXPIRE ======================== -OPTIONAL. ``int``. User consent expiration after been granted. +OPTIONAL. ``int``. How soon User Consent expires after being granted. Expressed in days. Default is ``30*3``. OIDC_TOKEN_EXPIRE ================= -OPTIONAL. ``int``. Token object (access token) expiration after been created. +OPTIONAL. ``int``. Token object (access token) expiration after being created. Expressed in seconds. Default is ``60*60``. OIDC_USERINFO ============= -OPTIONAL. ``str``. A string with the location of your function. Read :ref:`scopesclaims` section. +OPTIONAL. ``str``. A string with the location of your function. See the :ref:`scopesclaims` section. The function receives a ``claims`` dictionary with all the standard claims and ``user`` instance. Must returns the ``claims`` dict again. @@ -155,7 +189,7 @@ Example usage:: OIDC_GRANT_TYPE_PASSWORD_ENABLE =============================== -OPTIONAL. A boolean to set whether to allow the Resource Owner Password +OPTIONAL. A boolean whether to allow the Resource Owner Password Credentials Grant. https://tools.ietf.org/html/rfc6749#section-4.3 .. important:: @@ -179,21 +213,6 @@ Default is:: 'error': 'oidc_provider/error.html' } -The following contexts will be passed to the ``authorize`` and ``error`` templates respectively:: +See the :ref:`templates` section. - # For authorize template - { - 'client': 'an instance of Client for the auth request', - 'hidden_inputs': 'a rendered html with all the hidden inputs needed for AuthorizeEndpoint', - 'params': 'a dict containing the params in the auth request', - 'scopes': 'a list of scopes' - } - - # For error template - { - 'error': 'string stating the error', - 'description': 'string stating description of the error' - } - -.. note:: - The templates that are not specified here will use the default ones. +The templates that are not specified here will use the default ones. diff --git a/docs/sections/templates.rst b/docs/sections/templates.rst index bd9cef5..bb1f5fa 100644 --- a/docs/sections/templates.rst +++ b/docs/sections/templates.rst @@ -4,7 +4,7 @@ Templates ######### Add your own templates files inside a folder named ``templates/oidc_provider/``. -You can copy the sample html here and edit them with your own styles. +You can copy the sample html files here and customize them with your own style. **authorize.html**:: @@ -19,7 +19,7 @@ You can copy the sample html here and edit them with your own styles. {{ hidden_inputs }}
    - {% for scope in params.scope %} + {% for scope in scopes %}
  • {{ scope.name }}
    {{ scope.description }}
  • {% endfor %}
@@ -36,3 +36,18 @@ You can copy the sample html here and edit them with your own styles. You can also customize paths to your custom templates by putting them in ``OIDC_TEMPLATES`` in the settings. +The following contexts will be passed to the ``authorize`` and ``error`` templates respectively:: + + # For authorize template + { + 'client': 'an instance of Client for the auth request', + 'hidden_inputs': 'a rendered html with all the hidden inputs needed for AuthorizeEndpoint', + 'params': 'a dict containing the params in the auth request', + 'scopes': 'a list of scopes' + } + + # For error template + { + 'error': 'string stating the error', + 'description': 'string stating description of the error' + } diff --git a/docs/sections/userconsent.rst b/docs/sections/userconsent.rst index bc74025..2033055 100644 --- a/docs/sections/userconsent.rst +++ b/docs/sections/userconsent.rst @@ -9,6 +9,9 @@ The package store some information after the user grant access to some client. F >>> UserConsent.objects.filter(user__email='some@email.com') [] +Note: the ``UserConsent`` model is not included in the admin. + + Properties ========== diff --git a/example_project/.gitignore b/example/.gitignore similarity index 100% rename from example_project/.gitignore rename to example/.gitignore diff --git a/example_project/Dockerfile b/example/Dockerfile similarity index 87% rename from example_project/Dockerfile rename to example/Dockerfile index a636e93..abe0b7e 100644 --- a/example_project/Dockerfile +++ b/example/Dockerfile @@ -1,4 +1,4 @@ -FROM python:2-onbuild +FROM python:3-onbuild RUN [ "python", "manage.py", "migrate" ] RUN [ "python", "manage.py", "creatersakey" ] diff --git a/example_project/README.md b/example/README.md similarity index 75% rename from example_project/README.md rename to example/README.md index 0a8dfef..c3f78c7 100644 --- a/example_project/README.md +++ b/example/README.md @@ -2,7 +2,7 @@ ![Example Project](https://s17.postimg.org/4jjj8lavj/Screen_Shot_2016_09_07_at_15_58_43.png) -Run your own OIDC provider in a second. This is a Django app with all the necessary things to work with `django-oidc-provider` package. +On this example you'll be running your own OIDC provider in a second. This is a Django app with all the necessary things to work with `django-oidc-provider` package. ## Setup & Running @@ -14,15 +14,12 @@ Run your own OIDC provider in a second. This is a Django app with all the necess Setup project environment with [virtualenv](https://virtualenv.pypa.io) and [pip](https://pip.pypa.io). ```bash -# For Python 2.7. -$ virtualenv project_env -# Or Python 3. -$ virtualenv -p /usr/bin/python3.4 project_env +$ virtualenv -p /usr/bin/python3 project_env $ source project_env/bin/activate $ git clone https://github.com/juanifioren/django-oidc-provider.git -$ cd django-oidc-provider/example_project +$ cd django-oidc-provider/example $ pip install -r requirements.txt ``` @@ -53,7 +50,7 @@ After you run `pip install -r requirements.txt`. # Remove pypi package. $ pip uninstall django-oidc-provider -# Go back and add the package again. +# Go back to django-oidc-provider/ folder and add the package on editable mode. $ cd .. $ pip install -e . ``` diff --git a/example_project/myapp/__init__.py b/example/app/__init__.py similarity index 100% rename from example_project/myapp/__init__.py rename to example/app/__init__.py diff --git a/example_project/myapp/settings.py b/example/app/settings.py similarity index 92% rename from example_project/myapp/settings.py rename to example/app/settings.py index e97ad1d..1c3e972 100644 --- a/example_project/myapp/settings.py +++ b/example/app/settings.py @@ -20,7 +20,7 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - 'myapp', + 'app', 'oidc_provider', ] @@ -29,11 +29,11 @@ MIDDLEWARE_CLASSES = [ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'oidc_provider.middleware.SessionManagementMiddleware', ] +MIDDLEWARE = MIDDLEWARE_CLASSES TEMPLATES = [ { @@ -51,9 +51,9 @@ TEMPLATES = [ }, ] -ROOT_URLCONF = 'myapp.urls' +ROOT_URLCONF = 'app.urls' -WSGI_APPLICATION = 'myapp.wsgi.application' +WSGI_APPLICATION = 'app.wsgi.application' # Database diff --git a/example_project/myapp/templates/base.html b/example/app/templates/base.html similarity index 100% rename from example_project/myapp/templates/base.html rename to example/app/templates/base.html diff --git a/example_project/myapp/templates/home.html b/example/app/templates/home.html similarity index 95% rename from example_project/myapp/templates/home.html rename to example/app/templates/home.html index c2f2518..32e01ff 100644 --- a/example_project/myapp/templates/home.html +++ b/example/app/templates/home.html @@ -3,7 +3,7 @@ {% block content %} -
+

{% trans 'Welcome' %}{% if user.is_authenticated %} {{ user.username }}{% endif %}!

{% trans 'This is an example of an OpenID Connect 1.0 Provider. Built with the Django Framework and django-oidc-provider package.' %}

diff --git a/example_project/myapp/templates/login.html b/example/app/templates/login.html similarity index 100% rename from example_project/myapp/templates/login.html rename to example/app/templates/login.html diff --git a/example_project/myapp/templates/oidc_provider/authorize.html b/example/app/templates/oidc_provider/authorize.html similarity index 100% rename from example_project/myapp/templates/oidc_provider/authorize.html rename to example/app/templates/oidc_provider/authorize.html diff --git a/example_project/myapp/templates/oidc_provider/error.html b/example/app/templates/oidc_provider/error.html similarity index 100% rename from example_project/myapp/templates/oidc_provider/error.html rename to example/app/templates/oidc_provider/error.html diff --git a/example/app/urls.py b/example/app/urls.py new file mode 100644 index 0000000..2b0a624 --- /dev/null +++ b/example/app/urls.py @@ -0,0 +1,16 @@ +from django.contrib.auth import views as auth_views +try: + from django.urls import include, url +except ImportError: + from django.conf.urls import include, url +from django.contrib import admin +from django.views.generic import TemplateView + + +urlpatterns = [ + url(r'^$', TemplateView.as_view(template_name='home.html'), name='home'), + url(r'^accounts/login/$', auth_views.login, {'template_name': 'login.html'}, name='login'), + url(r'^accounts/logout/$', auth_views.logout, {'next_page': '/'}, name='logout'), + url(r'^', include('oidc_provider.urls', namespace='oidc_provider')), + url(r'^admin/', admin.site.urls), +] diff --git a/example_project/myapp/wsgi.py b/example/app/wsgi.py similarity index 59% rename from example_project/myapp/wsgi.py rename to example/app/wsgi.py index 91caa07..7c75d28 100644 --- a/example_project/myapp/wsgi.py +++ b/example/app/wsgi.py @@ -1,5 +1,8 @@ import os -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myapp.settings') from django.core.wsgi import get_wsgi_application + + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') + application = get_wsgi_application() diff --git a/example_project/manage.py b/example/manage.py similarity index 71% rename from example_project/manage.py rename to example/manage.py index 7bf6f3d..7adfe49 100755 --- a/example_project/manage.py +++ b/example/manage.py @@ -3,7 +3,7 @@ import os import sys if __name__ == '__main__': - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myapp.settings') + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') from django.core.management import execute_from_command_line diff --git a/example/requirements.txt b/example/requirements.txt new file mode 100644 index 0000000..4f95392 --- /dev/null +++ b/example/requirements.txt @@ -0,0 +1,2 @@ +django +https://github.com/juanifioren/django-oidc-provider/archive/master.zip diff --git a/example_project/myapp/urls.py b/example_project/myapp/urls.py deleted file mode 100644 index 91d31fa..0000000 --- a/example_project/myapp/urls.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.contrib.auth import views as auth_views -from django.conf.urls import include, url -from django.contrib import admin -from django.views.generic import TemplateView - - -urlpatterns = [ - url(r'^$', TemplateView.as_view(template_name='home.html'), name='home'), - url(r'^accounts/login/$', auth_views.login, { 'template_name': 'login.html' }, name='login'), - url(r'^accounts/logout/$', auth_views.logout, { 'next_page': '/' }, name='logout'), - - url(r'^', include('oidc_provider.urls', namespace='oidc_provider')), - - url(r'^admin/', include(admin.site.urls)), -] diff --git a/example_project/requirements.txt b/example_project/requirements.txt deleted file mode 100644 index 412a8f5..0000000 --- a/example_project/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -django==1.10 -https://github.com/juanifioren/django-oidc-provider/archive/v0.4.x.zip diff --git a/oidc_provider/admin.py b/oidc_provider/admin.py index 5ff343b..7718897 100644 --- a/oidc_provider/admin.py +++ b/oidc_provider/admin.py @@ -51,10 +51,12 @@ class ClientAdmin(admin.ModelAdmin): fieldsets = [ [_(u''), { - 'fields': ('name', 'client_type', 'response_type','_redirect_uris', 'jwt_alg', 'require_consent', 'reuse_consent'), + 'fields': ( + 'name', 'owner', 'client_type', 'response_type', '_redirect_uris', 'jwt_alg', + 'require_consent', 'reuse_consent'), }], [_(u'Credentials'), { - 'fields': ('client_id', 'client_secret'), + 'fields': ('client_id', 'client_secret', '_scope'), }], [_(u'Information'), { 'fields': ('contact_email', 'website_url', 'terms_url', 'logo', 'date_created'), @@ -67,6 +69,7 @@ class ClientAdmin(admin.ModelAdmin): list_display = ['name', 'client_id', 'response_type', 'date_created'] readonly_fields = ['date_created'] search_fields = ['name'] + raw_id_fields = ['owner'] @admin.register(Code) diff --git a/oidc_provider/compat.py b/oidc_provider/compat.py new file mode 100644 index 0000000..13091df --- /dev/null +++ b/oidc_provider/compat.py @@ -0,0 +1,5 @@ +def get_attr_or_callable(obj, name): + target = getattr(obj, name) + if callable(target): + return target() + return target diff --git a/oidc_provider/lib/claims.py b/oidc_provider/lib/claims.py index fe2e716..28c4602 100644 --- a/oidc_provider/lib/claims.py +++ b/oidc_provider/lib/claims.py @@ -6,11 +6,32 @@ from oidc_provider import settings STANDARD_CLAIMS = { - 'name': '', 'given_name': '', 'family_name': '', 'middle_name': '', 'nickname': '', - 'preferred_username': '', 'profile': '', 'picture': '', 'website': '', 'gender': '', - 'birthdate': '', 'zoneinfo': '', 'locale': '', 'updated_at': '', 'email': '', 'email_verified': '', - 'phone_number': '', 'phone_number_verified': '', 'address': { 'formatted': '', - 'street_address': '', 'locality': '', 'region': '', 'postal_code': '', 'country': '', }, + 'name': '', + 'given_name': '', + 'family_name': '', + 'middle_name': '', + 'nickname': '', + 'preferred_username': '', + 'profile': '', + 'picture': '', + 'website': '', + 'gender': '', + 'birthdate': '', + 'zoneinfo': '', + 'locale': '', + 'updated_at': '', + 'email': '', + 'email_verified': '', + 'phone_number': '', + 'phone_number_verified': '', + 'address': { + 'formatted': '', + 'street_address': '', + 'locality': '', + 'region': '', + 'postal_code': '', + 'country': '', + }, } @@ -72,7 +93,9 @@ class ScopeClaims(object): return aux_dic @classmethod - def get_scopes_info(cls, scopes=[]): + def get_scopes_info(cls, scopes=None): + if scopes is None: + scopes = [] scopes_info = [] for name in cls.__dict__: @@ -97,13 +120,17 @@ class StandardScopeClaims(ScopeClaims): info_profile = ( _(u'Basic profile'), - _(u'Access to your basic information. Includes names, gender, birthdate and other information.'), + _(u'Access to your basic information. Includes names, gender, birthdate' + 'and other information.'), ) + def scope_profile(self): dic = { 'name': self.userinfo.get('name'), - 'given_name': self.userinfo.get('given_name') or getattr(self.user, 'first_name', None), - 'family_name': self.userinfo.get('family_name') or getattr(self.user, 'last_name', None), + 'given_name': (self.userinfo.get('given_name') or + getattr(self.user, 'first_name', None)), + 'family_name': (self.userinfo.get('family_name') or + getattr(self.user, 'last_name', None)), 'middle_name': self.userinfo.get('middle_name'), 'nickname': self.userinfo.get('nickname') or getattr(self.user, 'username', None), 'preferred_username': self.userinfo.get('preferred_username'), @@ -123,6 +150,7 @@ class StandardScopeClaims(ScopeClaims): _(u'Email'), _(u'Access to your email address.'), ) + def scope_email(self): dic = { 'email': self.userinfo.get('email') or getattr(self.user, 'email', None), @@ -135,6 +163,7 @@ class StandardScopeClaims(ScopeClaims): _(u'Phone number'), _(u'Access to your phone number.'), ) + def scope_phone(self): dic = { 'phone_number': self.userinfo.get('phone_number'), @@ -147,6 +176,7 @@ class StandardScopeClaims(ScopeClaims): _(u'Address information'), _(u'Access to your address. Includes country, locality, street and other information.'), ) + def scope_address(self): dic = { 'address': { diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 36b4b2d..569d1f2 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -30,12 +30,13 @@ from oidc_provider.models import ( UserConsent, ) from oidc_provider import settings -from oidc_provider.lib.utils.common import cleanup_url_from_query_string, get_browser_state_or_default +from oidc_provider.lib.utils.common import get_browser_state_or_default logger = logging.getLogger(__name__) class AuthorizeEndpoint(object): + _allowed_prompt_params = {'none', 'login', 'consent', 'select_account'} def __init__(self, request): self.request = request @@ -48,7 +49,8 @@ class AuthorizeEndpoint(object): self.grant_type = 'authorization_code' elif self.params['response_type'] in ['id_token', 'id_token token', 'token']: self.grant_type = 'implicit' - elif self.params['response_type'] in ['code token', 'code id_token', 'code id_token token']: + elif self.params['response_type'] in [ + 'code token', 'code id_token', 'code id_token token']: self.grant_type = 'hybrid' else: self.grant_type = None @@ -74,7 +76,10 @@ class AuthorizeEndpoint(object): self.params['scope'] = query_dict.get('scope', '').split() self.params['state'] = query_dict.get('state', '') self.params['nonce'] = query_dict.get('nonce', '') - self.params['prompt'] = query_dict.get('prompt', '') + + self.params['prompt'] = self._allowed_prompt_params.intersection( + set(query_dict.get('prompt', '').split())) + self.params['code_challenge'] = query_dict.get('code_challenge', '') self.params['code_challenge_method'] = query_dict.get('code_challenge_method', '') @@ -90,18 +95,18 @@ class AuthorizeEndpoint(object): if self.is_authentication and not self.params['redirect_uri']: logger.debug('[Authorize] Missing redirect uri.') raise RedirectUriError() - clean_redirect_uri = cleanup_url_from_query_string(self.params['redirect_uri']) - if not (clean_redirect_uri in self.client.redirect_uris): + if not (self.params['redirect_uri'] in self.client.redirect_uris): logger.debug('[Authorize] Invalid redirect uri: %s', self.params['redirect_uri']) raise RedirectUriError() # Grant type validation. if not self.grant_type: logger.debug('[Authorize] Invalid response type: %s', self.params['response_type']) - raise AuthorizeError(self.params['redirect_uri'], 'unsupported_response_type', self.grant_type) + raise AuthorizeError( + self.params['redirect_uri'], 'unsupported_response_type', self.grant_type) - if not self.is_authentication and \ - (self.grant_type == 'hybrid' or self.params['response_type'] in ['id_token', 'id_token token']): + if (not self.is_authentication and (self.grant_type == 'hybrid' or + self.params['response_type'] in ['id_token', 'id_token token'])): logger.debug('[Authorize] Missing openid scope.') raise AuthorizeError(self.params['redirect_uri'], 'invalid_scope', self.grant_type) @@ -116,7 +121,8 @@ class AuthorizeEndpoint(object): # PKCE validation of the transformation method. if self.params['code_challenge']: if not (self.params['code_challenge_method'] in ['plain', 'S256']): - raise AuthorizeError(self.params['redirect_uri'], 'invalid_request', self.grant_type) + raise AuthorizeError( + self.params['redirect_uri'], 'invalid_request', self.grant_type) def create_response_uri(self): uri = urlsplit(self.params['redirect_uri']) @@ -145,12 +151,14 @@ class AuthorizeEndpoint(object): scope=self.params['scope']) # Check if response_type must include access_token in the response. - if self.params['response_type'] in ['id_token token', 'token', 'code token', 'code id_token token']: + if (self.params['response_type'] in + ['id_token token', 'token', 'code token', 'code id_token token']): query_fragment['access_token'] = token.access_token # We don't need id_token if it's an OAuth2 request. if self.is_authentication: kwargs = { + 'token': token, 'user': self.request.user, 'aud': self.client.client_id, 'nonce': self.params['nonce'], @@ -163,7 +171,8 @@ class AuthorizeEndpoint(object): id_token_dic = create_id_token(**kwargs) # Check if response_type must include id_token in the response. - if self.params['response_type'] in ['id_token', 'id_token token', 'code id_token', 'code id_token token']: + if self.params['response_type'] in [ + 'id_token', 'id_token token', 'code id_token', 'code id_token token']: query_fragment['id_token'] = encode_id_token(id_token_dic, self.client) else: id_token_dic = {} @@ -185,7 +194,8 @@ class AuthorizeEndpoint(object): if settings.get('OIDC_SESSION_MANAGEMENT_ENABLE'): # Generate client origin URI from the redirect_uri param. redirect_uri_parsed = urlsplit(self.params['redirect_uri']) - client_origin = '{0}://{1}'.format(redirect_uri_parsed.scheme, redirect_uri_parsed.netloc) + client_origin = '{0}://{1}'.format( + redirect_uri_parsed.scheme, redirect_uri_parsed.netloc) # Create random salt. salt = md5(uuid4().hex.encode()).hexdigest() @@ -209,7 +219,9 @@ class AuthorizeEndpoint(object): logger.exception('[Authorize] Error when trying to create response uri: %s', error) raise AuthorizeError(self.params['redirect_uri'], 'server_error', self.grant_type) - uri = uri._replace(query=urlencode(query_params, doseq=True), fragment=uri.fragment + urlencode(query_fragment, doseq=True)) + uri = uri._replace( + query=urlencode(query_params, doseq=True), + fragment=uri.fragment + urlencode(query_fragment, doseq=True)) return urlunsplit(uri) @@ -262,7 +274,8 @@ class AuthorizeEndpoint(object): """ scopes = StandardScopeClaims.get_scopes_info(self.params['scope']) if settings.get('OIDC_EXTRA_SCOPE_CLAIMS'): - scopes_extra = settings.get('OIDC_EXTRA_SCOPE_CLAIMS', import_str=True).get_scopes_info(self.params['scope']) + scopes_extra = settings.get( + 'OIDC_EXTRA_SCOPE_CLAIMS', import_str=True).get_scopes_info(self.params['scope']) for index_extra, scope_extra in enumerate(scopes_extra): for index, scope in enumerate(scopes[:]): if scope_extra['scope'] == scope['scope']: diff --git a/oidc_provider/lib/endpoints/introspection.py b/oidc_provider/lib/endpoints/introspection.py new file mode 100644 index 0000000..91b0a5a --- /dev/null +++ b/oidc_provider/lib/endpoints/introspection.py @@ -0,0 +1,98 @@ +import logging + +from django.http import JsonResponse + +from oidc_provider.lib.errors import TokenIntrospectionError +from oidc_provider.lib.utils.common import run_processing_hook +from oidc_provider.lib.utils.oauth2 import extract_client_auth +from oidc_provider.models import Token, Client +from oidc_provider import settings + +logger = logging.getLogger(__name__) + +INTROSPECTION_SCOPE = 'token_introspection' + + +class TokenIntrospectionEndpoint(object): + + def __init__(self, request): + self.request = request + self.params = {} + self.id_token = None + self.client = None + self._extract_params() + + def _extract_params(self): + # Introspection only supports POST requests + self.params['token'] = self.request.POST.get('token') + client_id, client_secret = extract_client_auth(self.request) + self.params['client_id'] = client_id + self.params['client_secret'] = client_secret + + def validate_params(self): + if not (self.params['client_id'] and self.params['client_secret']): + logger.debug('[Introspection] No client credentials provided') + raise TokenIntrospectionError() + if not self.params['token']: + logger.debug('[Introspection] No token provided') + raise TokenIntrospectionError() + try: + token = Token.objects.get(access_token=self.params['token']) + except Token.DoesNotExist: + logger.debug('[Introspection] Token does not exist: %s', self.params['token']) + raise TokenIntrospectionError() + if token.has_expired(): + logger.debug('[Introspection] Token is not valid: %s', self.params['token']) + raise TokenIntrospectionError() + if not token.id_token: + logger.debug('[Introspection] Token not an authentication token: %s', + self.params['token']) + raise TokenIntrospectionError() + + self.id_token = token.id_token + audience = self.id_token.get('aud') + if not audience: + logger.debug('[Introspection] No audience found for token: %s', self.params['token']) + raise TokenIntrospectionError() + + try: + self.client = Client.objects.get( + client_id=self.params['client_id'], + client_secret=self.params['client_secret']) + except Client.DoesNotExist: + logger.debug('[Introspection] No valid client for id: %s', + self.params['client_id']) + raise TokenIntrospectionError() + if INTROSPECTION_SCOPE not in self.client.scope: + logger.debug('[Introspection] Client %s does not have introspection scope', + self.params['client_id']) + raise TokenIntrospectionError() + if settings.get('OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE') \ + and audience not in self.client.scope: + logger.debug('[Introspection] Client %s does not audience scope %s', + self.params['client_id'], audience) + raise TokenIntrospectionError() + + def create_response_dic(self): + response_dic = dict((k, self.id_token[k]) for k in ('sub', 'exp', 'iat', 'iss')) + response_dic['active'] = True + response_dic['client_id'] = self.id_token.get('aud') + response_dic['aud'] = self.client.client_id + + response_dic = run_processing_hook(response_dic, + 'OIDC_INTROSPECTION_PROCESSING_HOOK', + client=self.client, + id_token=self.id_token) + + return response_dic + + @classmethod + def response(cls, dic, status=200): + """ + Create and return a response object. + """ + response = JsonResponse(dic, status=status) + response['Cache-Control'] = 'no-store' + response['Pragma'] = 'no-cache' + + return response diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index 88734b7..8c32046 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -1,14 +1,8 @@ -from base64 import b64decode, urlsafe_b64encode +import inspect +from base64 import urlsafe_b64encode import hashlib import logging -import re from django.contrib.auth import authenticate -from oidc_provider.lib.utils.common import cleanup_url_from_query_string - -try: - from urllib.parse import unquote -except ImportError: - from urllib import unquote from django.http import JsonResponse @@ -16,6 +10,7 @@ from oidc_provider.lib.errors import ( TokenError, UserAuthError, ) +from oidc_provider.lib.utils.oauth2 import extract_client_auth from oidc_provider.lib.utils.token import ( create_id_token, create_token, @@ -32,6 +27,7 @@ logger = logging.getLogger(__name__) class TokenEndpoint(object): + def __init__(self, request): self.request = request self.params = {} @@ -39,12 +35,11 @@ class TokenEndpoint(object): self._extract_params() def _extract_params(self): - client_id, client_secret = self._extract_client_auth() + client_id, client_secret = extract_client_auth(self.request) self.params['client_id'] = client_id self.params['client_secret'] = client_secret - self.params['redirect_uri'] = unquote( - self.request.POST.get('redirect_uri', '').split('?', 1)[0]) + self.params['redirect_uri'] = self.request.POST.get('redirect_uri', '') self.params['grant_type'] = self.request.POST.get('grant_type', '') self.params['code'] = self.request.POST.get('code', '') self.params['state'] = self.request.POST.get('state', '') @@ -56,29 +51,6 @@ class TokenEndpoint(object): self.params['username'] = self.request.POST.get('username', '') self.params['password'] = self.request.POST.get('password', '') - def _extract_client_auth(self): - """ - Get client credentials using HTTP Basic Authentication method. - Or try getting parameters via POST. - See: http://tools.ietf.org/html/rfc6750#section-2.1 - - Return a string. - """ - auth_header = self.request.META.get('HTTP_AUTHORIZATION', '') - - if re.compile('^Basic\s{1}.+$').match(auth_header): - b64_user_pass = auth_header.split()[1] - try: - user_pass = b64decode(b64_user_pass).decode('utf-8').split(':') - client_id, client_secret = tuple(user_pass) - except: - client_id = client_secret = '' - else: - client_id = self.request.POST.get('client_id', '') - client_secret = self.request.POST.get('client_secret', '') - - return (client_id, client_secret) - def validate_params(self): try: self.client = Client.objects.get(client_id=self.params['client_id']) @@ -93,8 +65,7 @@ class TokenEndpoint(object): raise TokenError('invalid_client') if self.params['grant_type'] == 'authorization_code': - clean_redirect_uri = cleanup_url_from_query_string(self.params['redirect_uri']) - if not (clean_redirect_uri in self.client.redirect_uris): + if not (self.params['redirect_uri'] in self.client.redirect_uris): logger.debug('[Token] Invalid redirect uri: %s', self.params['redirect_uri']) raise TokenError('invalid_client') @@ -126,7 +97,14 @@ class TokenEndpoint(object): if not settings.get('OIDC_GRANT_TYPE_PASSWORD_ENABLE'): raise TokenError('unsupported_grant_type') + auth_args = (self.request,) + try: + inspect.getcallargs(authenticate, *auth_args) + except TypeError: + auth_args = () + user = authenticate( + *auth_args, username=self.params['username'], password=self.params['password'] ) @@ -146,9 +124,13 @@ class TokenEndpoint(object): client=self.client) except Token.DoesNotExist: - logger.debug('[Token] Refresh token does not exist: %s', self.params['refresh_token']) + logger.debug( + '[Token] Refresh token does not exist: %s', self.params['refresh_token']) raise TokenError('invalid_grant') - + elif self.params['grant_type'] == 'client_credentials': + if not self.client._scope: + logger.debug('[Token] Client using client credentials with empty scope') + raise TokenError('invalid_scope') else: logger.debug('[Token] Invalid grant type: %s', self.params['grant_type']) raise TokenError('unsupported_grant_type') @@ -160,34 +142,8 @@ class TokenEndpoint(object): return self.create_refresh_response_dic() elif self.params['grant_type'] == 'password': return self.create_access_token_response_dic() - - def create_access_token_response_dic(self): - # See https://tools.ietf.org/html/rfc6749#section-4.3 - - token = create_token( - self.user, - self.client, - self.params['scope'].split(' ')) - - id_token_dic = create_id_token( - user=self.user, - aud=self.client.client_id, - nonce='self.code.nonce', - at_hash=token.at_hash, - request=self.request, - scope=token.scope, - ) - - token.id_token = id_token_dic - token.save() - - return { - 'access_token': token.access_token, - 'refresh_token': token.refresh_token, - 'expires_in': settings.get('OIDC_TOKEN_EXPIRE'), - 'token_type': 'bearer', - 'id_token': encode_id_token(id_token_dic, token.client), - } + elif self.params['grant_type'] == 'client_credentials': + return self.create_client_credentials_response_dic() def create_code_response_dic(self): # See https://tools.ietf.org/html/rfc6749#section-4.1 @@ -201,6 +157,7 @@ class TokenEndpoint(object): id_token_dic = create_id_token( user=self.code.user, aud=self.client.client_id, + token=token, nonce=self.code.nonce, at_hash=token.at_hash, request=self.request, @@ -245,6 +202,7 @@ class TokenEndpoint(object): id_token_dic = create_id_token( user=self.token.user, aud=self.client.client_id, + token=token, nonce=None, at_hash=token.at_hash, request=self.request, @@ -270,6 +228,52 @@ class TokenEndpoint(object): return dic + def create_access_token_response_dic(self): + # See https://tools.ietf.org/html/rfc6749#section-4.3 + + token = create_token( + self.user, + self.client, + self.params['scope'].split(' ')) + + id_token_dic = create_id_token( + token=token, + user=self.user, + aud=self.client.client_id, + nonce='self.code.nonce', + at_hash=token.at_hash, + request=self.request, + scope=token.scope, + ) + + token.id_token = id_token_dic + token.save() + + return { + 'access_token': token.access_token, + 'refresh_token': token.refresh_token, + 'expires_in': settings.get('OIDC_TOKEN_EXPIRE'), + 'token_type': 'bearer', + 'id_token': encode_id_token(id_token_dic, token.client), + } + + def create_client_credentials_response_dic(self): + # See https://tools.ietf.org/html/rfc6749#section-4.4.3 + + token = create_token( + user=None, + client=self.client, + scope=self.client.scope) + + token.save() + + return { + 'access_token': token.access_token, + 'expires_in': settings.get('OIDC_TOKEN_EXPIRE'), + 'token_type': 'bearer', + 'scope': self.client._scope, + } + @classmethod def response(cls, dic, status=200): """ diff --git a/oidc_provider/lib/errors.py b/oidc_provider/lib/errors.py index 47f4b10..318fb96 100644 --- a/oidc_provider/lib/errors.py +++ b/oidc_provider/lib/errors.py @@ -7,7 +7,8 @@ except ImportError: class RedirectUriError(Exception): error = 'Redirect URI Error' - description = 'The request fails due to a missing, invalid, or mismatching redirection URI (redirect_uri).' + description = 'The request fails due to a missing, invalid, or mismatching' \ + ' redirection URI (redirect_uri).' class ClientIdError(Exception): @@ -22,8 +23,7 @@ class UserAuthError(Exception): the Resource Owners credentials are not valid. """ error = 'access_denied' - description = 'The resource owner or authorization server denied ' \ - 'the request' + description = 'The resource owner or authorization server denied the request.' def create_dict(self): return { @@ -31,6 +31,16 @@ class UserAuthError(Exception): 'error_description': self.description, } + +class TokenIntrospectionError(Exception): + """ + Specific to the introspection endpoint. This error will be converted + to an "active: false" response, as per the spec. + See https://tools.ietf.org/html/rfc7662 + """ + pass + + class AuthorizeError(Exception): _errors = { diff --git a/oidc_provider/lib/utils/common.py b/oidc_provider/lib/utils/common.py index 9ef225d..c1d913f 100644 --- a/oidc_provider/lib/utils/common.py +++ b/oidc_provider/lib/utils/common.py @@ -1,27 +1,15 @@ from hashlib import sha224 -from django.core.urlresolvers import reverse +import django from django.http import HttpResponse from oidc_provider import settings -try: - from urlparse import urlsplit, urlunsplit -except ImportError: - from urllib.parse import urlsplit, urlunsplit - -def cleanup_url_from_query_string(uri): - """ - Function used to clean up the uri from any query string, used i.e. by endpoints to validate redirect_uri - - :param uri: URI to clean from query string - :type uri: str - :return: cleaned URI without query string - """ - clean_uri = urlsplit(uri) - clean_uri = urlunsplit(clean_uri._replace(query='')) - return clean_uri +if django.VERSION >= (1, 11): + from django.urls import reverse +else: + from django.core.urlresolvers import reverse def redirect(uri): @@ -88,23 +76,28 @@ def default_after_userlogin_hook(request, user, client): return None -def default_after_end_session_hook(request, id_token=None, post_logout_redirect_uri=None, state=None, client=None, next_page=None): +def default_after_end_session_hook( + request, id_token=None, post_logout_redirect_uri=None, + state=None, client=None, next_page=None): """ Default function for setting OIDC_AFTER_END_SESSION_HOOK. :param request: Django request object :type request: django.http.HttpRequest - :param id_token: token passed by `id_token_hint` url query param - do NOT trust this param or validate token + :param id_token: token passed by `id_token_hint` url query param. + Do NOT trust this param or validate token :type id_token: str - :param post_logout_redirect_uri: redirect url from url query param - do NOT trust this param + :param post_logout_redirect_uri: redirect url from url query param. + Do NOT trust this param :type post_logout_redirect_uri: str :param state: state param from url query params :type state: str - :param client: If id_token has `aud` param and associated Client exists, this is an instance of it - do NOT trust this param + :param client: If id_token has `aud` param and associated Client exists, + this is an instance of it - do NOT trust this param :type client: oidc_provider.models.Client :param next_page: calculated next_page redirection target @@ -133,9 +126,33 @@ def default_idtoken_processing_hook(id_token, user, scope=None): return id_token +def default_introspection_processing_hook(introspection_response, client, id_token): + """ + Hook to customise the returned data from the token introspection endpoint + :param introspection_response: + :param client: + :param id_token: + :return: + """ + return introspection_response + + def get_browser_state_or_default(request): """ Determine value to use as session state. """ - key = request.session.session_key or settings.get('OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY') + key = (request.session.session_key or + settings.get('OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY')) return sha224(key.encode('utf-8')).hexdigest() + + +def run_processing_hook(subject, hook_settings_name, **kwargs): + processing_hooks = settings.get(hook_settings_name) + if not isinstance(processing_hooks, (list, tuple)): + processing_hooks = [processing_hooks] + + for hook_string in processing_hooks: + hook = settings.import_from_str(hook_string) + subject = hook(subject, **kwargs) + + return subject diff --git a/oidc_provider/lib/utils/oauth2.py b/oidc_provider/lib/utils/oauth2.py index eba482c..452325f 100644 --- a/oidc_provider/lib/utils/oauth2.py +++ b/oidc_provider/lib/utils/oauth2.py @@ -1,3 +1,4 @@ +from base64 import b64decode import logging import re @@ -28,12 +29,39 @@ def extract_access_token(request): return access_token -def protected_resource_view(scopes=[]): +def extract_client_auth(request): + """ + Get client credentials using HTTP Basic Authentication method. + Or try getting parameters via POST. + See: http://tools.ietf.org/html/rfc6750#section-2.1 + + Return a tuple `(client_id, client_secret)`. + """ + auth_header = request.META.get('HTTP_AUTHORIZATION', '') + + if re.compile('^Basic\s{1}.+$').match(auth_header): + b64_user_pass = auth_header.split()[1] + try: + user_pass = b64decode(b64_user_pass).decode('utf-8').split(':') + client_id, client_secret = tuple(user_pass) + except Exception: + client_id = client_secret = '' + else: + client_id = request.POST.get('client_id', '') + client_secret = request.POST.get('client_secret', '') + + return (client_id, client_secret) + + +def protected_resource_view(scopes=None): """ View decorator. The client accesses protected resources by presenting the access token to the resource server. https://tools.ietf.org/html/rfc6749#section-7 """ + if scopes is None: + scopes = [] + def wrapper(view): def view_wrapper(request, *args, **kwargs): access_token = extract_access_token(request) @@ -52,9 +80,10 @@ def protected_resource_view(scopes=[]): if not set(scopes).issubset(set(kwargs['token'].scope)): logger.debug('[UserInfo] Missing openid scope.') raise BearerTokenError('insufficient_scope') - except (BearerTokenError) as error: + except BearerTokenError as error: response = HttpResponse(status=error.status) - response['WWW-Authenticate'] = 'error="{0}", error_description="{1}"'.format(error.code, error.description) + response['WWW-Authenticate'] = 'error="{0}", error_description="{1}"'.format( + error.code, error.description) return response return view(request, *args, **kwargs) diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index 91bf459..264c268 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -9,7 +9,8 @@ from jwkest.jwk import SYMKey from jwkest.jws import JWS from jwkest.jwt import JWT -from oidc_provider.lib.utils.common import get_issuer +from oidc_provider.lib.utils.common import get_issuer, run_processing_hook +from oidc_provider.lib.claims import StandardScopeClaims from oidc_provider.models import ( Code, RSAKey, @@ -18,12 +19,14 @@ from oidc_provider.models import ( from oidc_provider import settings -def create_id_token(user, aud, nonce='', at_hash='', request=None, scope=[]): +def create_id_token(token, user, aud, nonce='', at_hash='', request=None, scope=None): """ Creates the id_token dictionary. See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken Return a dic. """ + if scope is None: + scope = [] sub = settings.get('OIDC_IDTOKEN_SUB_GENERATOR', import_str=True)(user=user) expires_in = settings.get('OIDC_IDTOKEN_EXPIRE') @@ -50,20 +53,22 @@ def create_id_token(user, aud, nonce='', at_hash='', request=None, scope=[]): if at_hash: dic['at_hash'] = at_hash - if ('email' in scope) and getattr(user, 'email', None): - dic['email'] = user.email + # Inlude (or not) user standard claims in the id_token. + if settings.get('OIDC_IDTOKEN_INCLUDE_CLAIMS'): + if settings.get('OIDC_EXTRA_SCOPE_CLAIMS'): + custom_claims = settings.get('OIDC_EXTRA_SCOPE_CLAIMS', import_str=True)(token) + claims = custom_claims.create_response_dic() + else: + claims = StandardScopeClaims(token).create_response_dic() + dic.update(claims) - processing_hooks = settings.get('OIDC_IDTOKEN_PROCESSING_HOOK') - - if not isinstance(processing_hooks, (list, tuple)): - processing_hooks = [processing_hooks] - - for hook_string in processing_hooks: - hook = settings.import_from_str(hook_string) - dic = hook(dic, user=user, scope=scope) + dic = run_processing_hook( + dic, 'OIDC_IDTOKEN_PROCESSING_HOOK', + user=user, scope=scope) return dic + def encode_id_token(payload, client): """ Represent the ID Token as a JSON Web Token (JWT). @@ -73,6 +78,7 @@ def encode_id_token(payload, client): _jws = JWS(payload, alg=client.jwt_alg) return _jws.sign_compact(keys) + def decode_id_token(token, client): """ Represent the ID Token as a JSON Web Token (JWT). @@ -81,13 +87,20 @@ def decode_id_token(token, client): keys = get_client_alg_keys(client) return JWS().verify_compact(token, keys=keys) + def client_id_from_id_token(id_token): """ Extracts the client id from a JSON Web Token (JWT). Returns a string or None. """ payload = JWT().unpack(id_token).payload() - return payload.get('aud', None) + aud = payload.get('aud', None) + if aud is None: + return None + if isinstance(aud, list): + return aud[0] + return aud + def create_token(user, client, scope, id_token_dic=None): """ @@ -109,6 +122,7 @@ def create_token(user, client, scope, id_token_dic=None): return token + def create_code(user, client, scope, nonce, is_authentication, code_challenge=None, code_challenge_method=None): """ @@ -133,6 +147,7 @@ def create_code(user, client, scope, nonce, is_authentication, return code + def get_client_alg_keys(client): """ Takes a client and returns the set of keys associated with it. diff --git a/oidc_provider/management/commands/creatersakey.py b/oidc_provider/management/commands/creatersakey.py index 1dc1a2c..d5d423f 100644 --- a/oidc_provider/management/commands/creatersakey.py +++ b/oidc_provider/management/commands/creatersakey.py @@ -1,9 +1,6 @@ -import os - from Cryptodome.PublicKey import RSA from django.core.management.base import BaseCommand -from oidc_provider import settings from oidc_provider.models import RSAKey diff --git a/oidc_provider/migrations/0001_initial.py b/oidc_provider/migrations/0001_initial.py index ca32b7e..2af079a 100644 --- a/oidc_provider/migrations/0001_initial.py +++ b/oidc_provider/migrations/0001_initial.py @@ -20,7 +20,9 @@ class Migration(migrations.Migration): ('name', models.CharField(default=b'', max_length=100)), ('client_id', models.CharField(unique=True, max_length=255)), ('client_secret', models.CharField(unique=True, max_length=255)), - ('response_type', models.CharField(max_length=30, choices=[(b'code', b'code (Authorization Code Flow)'), (b'id_token', b'id_token (Implicit Flow)'), (b'id_token token', b'id_token token (Implicit Flow)')])), + ('response_type', models.CharField(max_length=30, choices=[ + (b'code', b'code (Authorization Code Flow)'), (b'id_token', b'id_token (Implicit Flow)'), + (b'id_token token', b'id_token token (Implicit Flow)')])), ('_redirect_uris', models.TextField(default=b'')), ], options={ @@ -34,7 +36,7 @@ class Migration(migrations.Migration): ('expires_at', models.DateTimeField()), ('_scope', models.TextField(default=b'')), ('code', models.CharField(unique=True, max_length=255)), - ('client', models.ForeignKey(to='oidc_provider.Client')), + ('client', models.ForeignKey(to='oidc_provider.Client', on_delete=models.CASCADE)), ], options={ 'abstract': False, @@ -49,7 +51,7 @@ class Migration(migrations.Migration): ('_scope', models.TextField(default=b'')), ('access_token', models.CharField(unique=True, max_length=255)), ('_id_token', models.TextField()), - ('client', models.ForeignKey(to='oidc_provider.Client')), + ('client', models.ForeignKey(to='oidc_provider.Client', on_delete=models.CASCADE)), ], options={ 'abstract': False, @@ -59,7 +61,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name='UserInfo', fields=[ - ('user', models.OneToOneField(primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ('user', models.OneToOneField(primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ('given_name', models.CharField(max_length=255, null=True, blank=True)), ('family_name', models.CharField(max_length=255, null=True, blank=True)), ('middle_name', models.CharField(max_length=255, null=True, blank=True)), @@ -89,13 +91,13 @@ class Migration(migrations.Migration): migrations.AddField( model_name='token', name='user', - field=models.ForeignKey(to=settings.AUTH_USER_MODEL), + field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), preserve_default=True, ), migrations.AddField( model_name='code', name='user', - field=models.ForeignKey(to=settings.AUTH_USER_MODEL), + field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), preserve_default=True, ), ] diff --git a/oidc_provider/migrations/0002_userconsent.py b/oidc_provider/migrations/0002_userconsent.py index 4cdf6e3..d2a0f12 100644 --- a/oidc_provider/migrations/0002_userconsent.py +++ b/oidc_provider/migrations/0002_userconsent.py @@ -19,8 +19,8 @@ class Migration(migrations.Migration): ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('expires_at', models.DateTimeField()), ('_scope', models.TextField(default=b'')), - ('client', models.ForeignKey(to='oidc_provider.Client')), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ('client', models.ForeignKey(to='oidc_provider.Client', on_delete=models.CASCADE)), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], options={ 'abstract': False, diff --git a/oidc_provider/migrations/0004_remove_userinfo.py b/oidc_provider/migrations/0004_remove_userinfo.py index 33df109..d4208e0 100644 --- a/oidc_provider/migrations/0004_remove_userinfo.py +++ b/oidc_provider/migrations/0004_remove_userinfo.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import models, migrations +from django.db import migrations class Migration(migrations.Migration): diff --git a/oidc_provider/migrations/0007_auto_20160111_1844.py b/oidc_provider/migrations/0007_auto_20160111_1844.py index a160cc5..263c4c5 100644 --- a/oidc_provider/migrations/0007_auto_20160111_1844.py +++ b/oidc_provider/migrations/0007_auto_20160111_1844.py @@ -29,7 +29,8 @@ class Migration(migrations.Migration): migrations.AddField( model_name='client', name='date_created', - field=models.DateField(auto_now_add=True, default=datetime.datetime(2016, 1, 11, 18, 44, 32, 192477, tzinfo=utc)), + field=models.DateField( + auto_now_add=True, default=datetime.datetime(2016, 1, 11, 18, 44, 32, 192477, tzinfo=utc)), preserve_default=False, ), migrations.AlterField( diff --git a/oidc_provider/migrations/0011_client_client_type.py b/oidc_provider/migrations/0011_client_client_type.py index 26e9fc3..563096f 100644 --- a/oidc_provider/migrations/0011_client_client_type.py +++ b/oidc_provider/migrations/0011_client_client_type.py @@ -15,6 +15,11 @@ class Migration(migrations.Migration): migrations.AddField( model_name='client', name='client_type', - field=models.CharField(choices=[(b'confidential', b'Confidential'), (b'public', b'Public')], default=b'confidential', help_text='Confidential clients are capable of maintaining the confidentiality of their credentials. Public clients are incapable.', max_length=30), + field=models.CharField( + choices=[(b'confidential', b'Confidential'), (b'public', b'Public')], + default=b'confidential', + help_text='Confidential clients are capable of maintaining the confidentiality of their ' + 'credentials. Public clients are incapable.', + max_length=30), ), ] diff --git a/oidc_provider/migrations/0014_client_jwt_alg.py b/oidc_provider/migrations/0014_client_jwt_alg.py index d2b096c..18a34c2 100644 --- a/oidc_provider/migrations/0014_client_jwt_alg.py +++ b/oidc_provider/migrations/0014_client_jwt_alg.py @@ -15,6 +15,10 @@ class Migration(migrations.Migration): migrations.AddField( model_name='client', name='jwt_alg', - field=models.CharField(choices=[(b'HS256', b'HS256'), (b'RS256', b'RS256')], default=b'RS256', max_length=10, verbose_name='JWT Algorithm'), + field=models.CharField( + choices=[(b'HS256', b'HS256'), (b'RS256', b'RS256')], + default=b'RS256', + max_length=10, + verbose_name='JWT Algorithm'), ), ] diff --git a/oidc_provider/migrations/0015_change_client_code.py b/oidc_provider/migrations/0015_change_client_code.py index bfffd57..a4f67e1 100644 --- a/oidc_provider/migrations/0015_change_client_code.py +++ b/oidc_provider/migrations/0015_change_client_code.py @@ -25,12 +25,21 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='client', name='client_type', - field=models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], default='confidential', help_text='Confidential clients are capable of maintaining the confidentiality of their credentials. Public clients are incapable.', max_length=30), + field=models.CharField( + choices=[('confidential', 'Confidential'), ('public', 'Public')], + default='confidential', + help_text='Confidential clients are capable of maintaining the confidentiality of their' + ' credentials. Public clients are incapable.', + max_length=30), ), migrations.AlterField( model_name='client', name='jwt_alg', - field=models.CharField(choices=[('HS256', 'HS256'), ('RS256', 'RS256')], default='RS256', max_length=10, verbose_name='JWT Algorithm'), + field=models.CharField( + choices=[('HS256', 'HS256'), ('RS256', 'RS256')], + default='RS256', + max_length=10, + verbose_name='JWT Algorithm'), ), migrations.AlterField( model_name='client', @@ -40,7 +49,11 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='client', name='response_type', - field=models.CharField(choices=[('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), ('id_token token', 'id_token token (Implicit Flow)')], max_length=30), + field=models.CharField( + choices=[ + ('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), + ('id_token token', 'id_token token (Implicit Flow)')], + max_length=30), ), migrations.AlterField( model_name='code', diff --git a/oidc_provider/migrations/0016_userconsent_and_verbosenames.py b/oidc_provider/migrations/0016_userconsent_and_verbosenames.py index afd043e..a698362 100644 --- a/oidc_provider/migrations/0016_userconsent_and_verbosenames.py +++ b/oidc_provider/migrations/0016_userconsent_and_verbosenames.py @@ -19,13 +19,15 @@ class Migration(migrations.Migration): migrations.AddField( model_name='userconsent', name='date_given', - field=models.DateTimeField(default=datetime.datetime(2016, 6, 10, 17, 53, 48, 889808, tzinfo=utc), verbose_name='Date Given'), + field=models.DateTimeField( + default=datetime.datetime(2016, 6, 10, 17, 53, 48, 889808, tzinfo=utc), verbose_name='Date Given'), preserve_default=False, ), migrations.AlterField( model_name='client', name='_redirect_uris', - field=models.TextField(default=b'', help_text='Enter each URI on a new line.', verbose_name='Redirect URIs'), + field=models.TextField( + default=b'', help_text='Enter each URI on a new line.', verbose_name='Redirect URIs'), ), migrations.AlterField( model_name='client', @@ -40,7 +42,13 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='client', name='client_type', - field=models.CharField(choices=[(b'confidential', b'Confidential'), (b'public', b'Public')], default=b'confidential', help_text='Confidential clients are capable of maintaining the confidentiality of their credentials. Public clients are incapable.', max_length=30, verbose_name='Client Type'), + field=models.CharField( + choices=[(b'confidential', b'Confidential'), (b'public', b'Public')], + default=b'confidential', + help_text='Confidential clients are capable of maintaining the confidentiality of their ' + 'credentials. Public clients are incapable.', + max_length=30, + verbose_name='Client Type'), ), migrations.AlterField( model_name='client', @@ -55,7 +63,12 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='client', name='response_type', - field=models.CharField(choices=[(b'code', b'code (Authorization Code Flow)'), (b'id_token', b'id_token (Implicit Flow)'), (b'id_token token', b'id_token token (Implicit Flow)')], max_length=30, verbose_name='Response Type'), + field=models.CharField( + choices=[ + (b'code', b'code (Authorization Code Flow)'), (b'id_token', b'id_token (Implicit Flow)'), + (b'id_token token', b'id_token token (Implicit Flow)')], + max_length=30, + verbose_name='Response Type'), ), migrations.AlterField( model_name='code', @@ -65,7 +78,8 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='code', name='client', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), ), migrations.AlterField( model_name='code', @@ -100,7 +114,8 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='code', name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), ), migrations.AlterField( model_name='rsakey', @@ -125,7 +140,8 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='token', name='client', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), ), migrations.AlterField( model_name='token', @@ -140,7 +156,8 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='token', name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), ), migrations.AlterField( model_name='userconsent', @@ -150,7 +167,8 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='userconsent', name='client', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), ), migrations.AlterField( model_name='userconsent', @@ -160,6 +178,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='userconsent', name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), ), ] diff --git a/oidc_provider/migrations/0017_auto_20160811_1954.py b/oidc_provider/migrations/0017_auto_20160811_1954.py index de7350f..2d564e3 100644 --- a/oidc_provider/migrations/0017_auto_20160811_1954.py +++ b/oidc_provider/migrations/0017_auto_20160811_1954.py @@ -25,7 +25,13 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='client', name='client_type', - field=models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], default='confidential', help_text='Confidential clients are capable of maintaining the confidentiality of their credentials. Public clients are incapable.', max_length=30, verbose_name='Client Type'), + field=models.CharField( + choices=[('confidential', 'Confidential'), ('public', 'Public')], + default='confidential', + help_text='Confidential clients are capable of maintaining the confidentiality of their ' + 'credentials. Public clients are incapable.', + max_length=30, + verbose_name='Client Type'), ), migrations.AlterField( model_name='client', @@ -35,7 +41,12 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='client', name='response_type', - field=models.CharField(choices=[('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), ('id_token token', 'id_token token (Implicit Flow)')], max_length=30, verbose_name='Response Type'), + field=models.CharField( + choices=[ + ('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), + ('id_token token', 'id_token token (Implicit Flow)')], + max_length=30, + verbose_name='Response Type'), ), migrations.AlterField( model_name='code', diff --git a/oidc_provider/migrations/0018_hybridflow_and_clientattrs.py b/oidc_provider/migrations/0018_hybridflow_and_clientattrs.py index c915cf8..06328dd 100644 --- a/oidc_provider/migrations/0018_hybridflow_and_clientattrs.py +++ b/oidc_provider/migrations/0018_hybridflow_and_clientattrs.py @@ -20,12 +20,18 @@ class Migration(migrations.Migration): migrations.AddField( model_name='client', name='logo', - field=models.FileField(blank=True, default='', upload_to='oidc_provider/clients', verbose_name='Logo Image'), + field=models.FileField( + blank=True, default='', upload_to='oidc_provider/clients', verbose_name='Logo Image'), ), migrations.AddField( model_name='client', name='terms_url', - field=models.CharField(blank=True, default='', help_text='External reference to the privacy policy of the client.', max_length=255, verbose_name='Terms URL'), + field=models.CharField( + blank=True, + default='', + help_text='External reference to the privacy policy of the client.', + max_length=255, + verbose_name='Terms URL'), ), migrations.AddField( model_name='client', @@ -35,11 +41,23 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='client', name='jwt_alg', - field=models.CharField(choices=[('HS256', 'HS256'), ('RS256', 'RS256')], default='RS256', help_text='Algorithm used to encode ID Tokens.', max_length=10, verbose_name='JWT Algorithm'), + field=models.CharField( + choices=[('HS256', 'HS256'), ('RS256', 'RS256')], + default='RS256', + help_text='Algorithm used to encode ID Tokens.', + max_length=10, + verbose_name='JWT Algorithm'), ), migrations.AlterField( model_name='client', name='response_type', - field=models.CharField(choices=[('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), ('id_token token', 'id_token token (Implicit Flow)'), ('code token', 'code token (Hybrid Flow)'), ('code id_token', 'code id_token (Hybrid Flow)'), ('code id_token token', 'code id_token token (Hybrid Flow)')], max_length=30, verbose_name='Response Type'), + field=models.CharField( + choices=[ + ('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), + ('id_token token', 'id_token token (Implicit Flow)'), ('code token', 'code token (Hybrid Flow)'), + ('code id_token', 'code id_token (Hybrid Flow)'), + ('code id_token token', 'code id_token token (Hybrid Flow)')], + max_length=30, + verbose_name='Response Type'), ), ] diff --git a/oidc_provider/migrations/0020_client__post_logout_redirect_uris.py b/oidc_provider/migrations/0020_client__post_logout_redirect_uris.py index db8e6d6..158da24 100644 --- a/oidc_provider/migrations/0020_client__post_logout_redirect_uris.py +++ b/oidc_provider/migrations/0020_client__post_logout_redirect_uris.py @@ -15,6 +15,10 @@ class Migration(migrations.Migration): migrations.AddField( model_name='client', name='_post_logout_redirect_uris', - field=models.TextField(blank=True, default='', help_text='Enter each URI on a new line.', verbose_name='Post Logout Redirect URIs'), + field=models.TextField( + blank=True, + default='', + help_text='Enter each URI on a new line.', + verbose_name='Post Logout Redirect URIs'), ), ] diff --git a/oidc_provider/migrations/0022_auto_20170331_1626.py b/oidc_provider/migrations/0022_auto_20170331_1626.py index bad8c93..78b7026 100644 --- a/oidc_provider/migrations/0022_auto_20170331_1626.py +++ b/oidc_provider/migrations/0022_auto_20170331_1626.py @@ -15,11 +15,18 @@ class Migration(migrations.Migration): migrations.AddField( model_name='client', name='require_consent', - field=models.BooleanField(default=True, help_text='If disabled, the Server will NEVER ask the user for consent.', verbose_name='Require Consent?'), + field=models.BooleanField( + default=True, + help_text='If disabled, the Server will NEVER ask the user for consent.', + verbose_name='Require Consent?'), ), migrations.AddField( model_name='client', name='reuse_consent', - field=models.BooleanField(default=True, help_text="If enabled, the Server will save the user consent given to a specific client, so that user won't be prompted for the same authorization multiple times.", verbose_name='Reuse Consent?'), + field=models.BooleanField( + default=True, + help_text="If enabled, the Server will save the user consent given to a specific client," + " so that user won't be prompted for the same authorization multiple times.", + verbose_name='Reuse Consent?'), ), ] diff --git a/oidc_provider/migrations/0023_client_owner.py b/oidc_provider/migrations/0023_client_owner.py new file mode 100644 index 0000000..b6d214d --- /dev/null +++ b/oidc_provider/migrations/0023_client_owner.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11 on 2017-11-08 21:43 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('oidc_provider', '0022_auto_20170331_1626'), + ] + + operations = [ + migrations.AddField( + model_name='client', + name='owner', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='oidc_clients_set', to=settings.AUTH_USER_MODEL, verbose_name='Owner'), + ), + ] diff --git a/oidc_provider/migrations/0024_auto_20180327_1959.py b/oidc_provider/migrations/0024_auto_20180327_1959.py new file mode 100644 index 0000000..7171661 --- /dev/null +++ b/oidc_provider/migrations/0024_auto_20180327_1959.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.3 on 2018-03-27 19:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oidc_provider', '0023_client_owner'), + ] + + operations = [ + migrations.AlterField( + model_name='client', + name='reuse_consent', + field=models.BooleanField(default=True, help_text="If enabled, server will save the user consent given to a specific client, so that user won't be prompted for the same authorization multiple times.", verbose_name='Reuse Consent?'), + ), + ] diff --git a/oidc_provider/migrations/0025_user_field_codetoken.py b/oidc_provider/migrations/0025_user_field_codetoken.py new file mode 100644 index 0000000..d757fb0 --- /dev/null +++ b/oidc_provider/migrations/0025_user_field_codetoken.py @@ -0,0 +1,25 @@ +# Generated by Django 2.0.3 on 2018-04-13 19:34 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('oidc_provider', '0024_auto_20180327_1959'), + ] + + operations = [ + migrations.AddField( + model_name='client', + name='_scope', + field=models.TextField(blank=True, default='', help_text='Specifies the authorized scope values for the client app.', verbose_name='Scopes'), + ), + migrations.AlterField( + model_name='token', + name='user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), + ), + ] diff --git a/oidc_provider/models.py b/oidc_provider/models.py index a196239..411633c 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -33,36 +33,61 @@ JWT_ALGS = [ class Client(models.Model): name = models.CharField(max_length=100, default='', verbose_name=_(u'Name')) - client_type = models.CharField(max_length=30, choices=CLIENT_TYPE_CHOICES, default='confidential', verbose_name=_(u'Client Type'), help_text=_(u'Confidential clients are capable of maintaining the confidentiality of their credentials. Public clients are incapable.')) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, verbose_name=_(u'Owner'), blank=True, + null=True, default=None, on_delete=models.SET_NULL, related_name='oidc_clients_set') + client_type = models.CharField( + max_length=30, + choices=CLIENT_TYPE_CHOICES, + default='confidential', + verbose_name=_(u'Client Type'), + help_text=_(u'Confidential clients are capable of maintaining the confidentiality' + u' of their credentials. Public clients are incapable.')) client_id = models.CharField(max_length=255, unique=True, verbose_name=_(u'Client ID')) client_secret = models.CharField(max_length=255, blank=True, verbose_name=_(u'Client SECRET')) - response_type = models.CharField(max_length=30, choices=RESPONSE_TYPE_CHOICES, verbose_name=_(u'Response Type')) - jwt_alg = models.CharField(max_length=10, choices=JWT_ALGS, default='RS256', verbose_name=_(u'JWT Algorithm'), help_text=_(u'Algorithm used to encode ID Tokens.')) + response_type = models.CharField( + max_length=30, choices=RESPONSE_TYPE_CHOICES, verbose_name=_(u'Response Type')) + jwt_alg = models.CharField( + max_length=10, + choices=JWT_ALGS, + default='RS256', + verbose_name=_(u'JWT Algorithm'), + help_text=_(u'Algorithm used to encode ID Tokens.')) date_created = models.DateField(auto_now_add=True, verbose_name=_(u'Date Created')) - website_url = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Website URL')) - terms_url = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Terms URL'), help_text=_(u'External reference to the privacy policy of the client.')) - contact_email = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Contact Email')) - logo = models.FileField(blank=True, default='', upload_to='oidc_provider/clients', verbose_name=_(u'Logo Image')) - reuse_consent = models.BooleanField(default=True, verbose_name=_('Reuse Consent?'), help_text=_('If enabled, the Server will save the user consent given to a specific client, so that user won\'t be prompted for the same authorization multiple times.')) - require_consent = models.BooleanField(default=True, verbose_name=_('Require Consent?'), help_text=_('If disabled, the Server will NEVER ask the user for consent.')) - - _redirect_uris = models.TextField(default='', verbose_name=_(u'Redirect URIs'), help_text=_(u'Enter each URI on a new line.')) - def redirect_uris(): - def fget(self): - return self._redirect_uris.splitlines() - def fset(self, value): - self._redirect_uris = '\n'.join(value) - return locals() - redirect_uris = property(**redirect_uris()) - - _post_logout_redirect_uris = models.TextField(blank=True, default='', verbose_name=_(u'Post Logout Redirect URIs'), help_text=_(u'Enter each URI on a new line.')) - def post_logout_redirect_uris(): - def fget(self): - return self._post_logout_redirect_uris.splitlines() - def fset(self, value): - self._post_logout_redirect_uris = '\n'.join(value) - return locals() - post_logout_redirect_uris = property(**post_logout_redirect_uris()) + website_url = models.CharField( + max_length=255, blank=True, default='', verbose_name=_(u'Website URL')) + terms_url = models.CharField( + max_length=255, + blank=True, + default='', + verbose_name=_(u'Terms URL'), + help_text=_(u'External reference to the privacy policy of the client.')) + contact_email = models.CharField( + max_length=255, blank=True, default='', verbose_name=_(u'Contact Email')) + logo = models.FileField( + blank=True, default='', upload_to='oidc_provider/clients', verbose_name=_(u'Logo Image')) + reuse_consent = models.BooleanField( + default=True, + verbose_name=_('Reuse Consent?'), + help_text=_('If enabled, server will save the user consent given to a specific client, ' + 'so that user won\'t be prompted for the same authorization multiple times.')) + require_consent = models.BooleanField( + default=True, + verbose_name=_('Require Consent?'), + help_text=_('If disabled, the Server will NEVER ask the user for consent.')) + _redirect_uris = models.TextField( + default='', verbose_name=_(u'Redirect URIs'), + help_text=_(u'Enter each URI on a new line.')) + _post_logout_redirect_uris = models.TextField( + blank=True, + default='', + verbose_name=_(u'Post Logout Redirect URIs'), + help_text=_(u'Enter each URI on a new line.')) + _scope = models.TextField( + blank=True, + default='', + verbose_name=_(u'Scopes'), + help_text=_('Specifies the authorized scope values for the client app.')) class Meta: verbose_name = _(u'Client') @@ -74,7 +99,29 @@ class Client(models.Model): def __unicode__(self): return self.__str__() + @property + def redirect_uris(self): + return self._redirect_uris.splitlines() + @redirect_uris.setter + def redirect_uris(self, value): + self._redirect_uris = '\n'.join(value) + + @property + def post_logout_redirect_uris(self): + return self._post_logout_redirect_uris.splitlines() + + @post_logout_redirect_uris.setter + def post_logout_redirect_uris(self, value): + self._post_logout_redirect_uris = '\n'.join(value) + + @property + def scope(self): + return self._scope.split() + + @scope.setter + def scope(self, value): + self._scope = ' '.join(value) @property def default_redirect_uri(self): @@ -83,20 +130,20 @@ class Client(models.Model): class BaseCodeTokenModel(models.Model): - user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_(u'User')) - client = models.ForeignKey(Client, verbose_name=_(u'Client')) + client = models.ForeignKey(Client, verbose_name=_(u'Client'), on_delete=models.CASCADE) expires_at = models.DateTimeField(verbose_name=_(u'Expiration Date')) _scope = models.TextField(default='', verbose_name=_(u'Scopes')) - def scope(): - def fget(self): - return self._scope.split() + class Meta: + abstract = True - def fset(self, value): - self._scope = ' '.join(value) + @property + def scope(self): + return self._scope.split() - return locals() - scope = property(**scope()) + @scope.setter + def scope(self, value): + self._scope = ' '.join(value) def has_expired(self): return timezone.now() >= self.expires_at @@ -107,17 +154,17 @@ class BaseCodeTokenModel(models.Model): def __unicode__(self): return self.__str__() - class Meta: - abstract = True - class Code(BaseCodeTokenModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, verbose_name=_(u'User'), on_delete=models.CASCADE) code = models.CharField(max_length=255, unique=True, verbose_name=_(u'Code')) nonce = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Nonce')) is_authentication = models.BooleanField(default=False, verbose_name=_(u'Is Authentication?')) code_challenge = models.CharField(max_length=255, null=True, verbose_name=_(u'Code Challenge')) - code_challenge_method = models.CharField(max_length=255, null=True, verbose_name=_(u'Code Challenge Method')) + code_challenge_method = models.CharField( + max_length=255, null=True, verbose_name=_(u'Code Challenge Method')) class Meta: verbose_name = _(u'Authorization Code') @@ -126,20 +173,19 @@ class Code(BaseCodeTokenModel): class Token(BaseCodeTokenModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, verbose_name=_(u'User'), on_delete=models.CASCADE) access_token = models.CharField(max_length=255, unique=True, verbose_name=_(u'Access Token')) refresh_token = models.CharField(max_length=255, unique=True, verbose_name=_(u'Refresh Token')) _id_token = models.TextField(verbose_name=_(u'ID Token')) - def id_token(): + @property + def id_token(self): + return json.loads(self._id_token) - def fget(self): - return json.loads(self._id_token) - - def fset(self, value): - self._id_token = json.dumps(value) - - return locals() - id_token = property(**id_token()) + @id_token.setter + def id_token(self, value): + self._id_token = json.dumps(value) class Meta: verbose_name = _(u'Token') @@ -160,6 +206,8 @@ class Token(BaseCodeTokenModel): class UserConsent(BaseCodeTokenModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, verbose_name=_(u'User'), on_delete=models.CASCADE) date_given = models.DateTimeField(verbose_name=_(u'Date Given')) class Meta: @@ -168,7 +216,8 @@ class UserConsent(BaseCodeTokenModel): class RSAKey(models.Model): - key = models.TextField(verbose_name=_(u'Key'), help_text=_(u'Paste your private RSA Key here.')) + key = models.TextField( + verbose_name=_(u'Key'), help_text=_(u'Paste your private RSA Key here.')) class Meta: verbose_name = _(u'RSA Key') diff --git a/oidc_provider/settings.py b/oidc_provider/settings.py index fc2b4c9..1fddbfa 100644 --- a/oidc_provider/settings.py +++ b/oidc_provider/settings.py @@ -72,6 +72,13 @@ class DefaultSettings(object): """ return 'oidc_provider.lib.utils.common.default_sub_generator' + @property + def OIDC_IDTOKEN_INCLUDE_CLAIMS(self): + """ + OPTIONAL. If enabled, id_token will include standard claims of the user. + """ + return False + @property def OIDC_SESSION_MANAGEMENT_ENABLE(self): """ @@ -122,6 +129,22 @@ class DefaultSettings(object): """ return 'oidc_provider.lib.utils.common.default_idtoken_processing_hook' + @property + def OIDC_INTROSPECTION_PROCESSING_HOOK(self): + """ + OPTIONAL. A string with the location of your function. + Used to update the response for a valid introspection token request. + """ + return 'oidc_provider.lib.utils.common.default_introspection_processing_hook' + + @property + def OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE(self): + """ + OPTIONAL: A boolean to specify whether or not to verify that the introspection + resource has the requesting client id as one of its scopes. + """ + return True + @property def OIDC_GRANT_TYPE_PASSWORD_ENABLE(self): """ @@ -145,6 +168,7 @@ class DefaultSettings(object): 'error': 'oidc_provider/error.html' } + default_settings = DefaultSettings() diff --git a/oidc_provider/templates/oidc_provider/check_session_iframe.html b/oidc_provider/templates/oidc_provider/check_session_iframe.html index 445fda2..e04d5ce 100644 --- a/oidc_provider/templates/oidc_provider/check_session_iframe.html +++ b/oidc_provider/templates/oidc_provider/check_session_iframe.html @@ -9,6 +9,10 @@ window.addEventListener("message", receiveMessage, false); function receiveMessage(e) { + if (!e.data || typeof e.data != 'string' || e.data == 'error') { + return; + } + var status; try { var clientId = e.data.split(' ')[0]; diff --git a/oidc_provider/tests/app/urls.py b/oidc_provider/tests/app/urls.py index 8c513fd..e50bdfe 100644 --- a/oidc_provider/tests/app/urls.py +++ b/oidc_provider/tests/app/urls.py @@ -1,15 +1,18 @@ from django.contrib.auth import views as auth_views -from django.conf.urls import include, url +try: + from django.urls import include, url +except ImportError: + from django.conf.urls import include, url from django.contrib import admin from django.views.generic import TemplateView urlpatterns = [ url(r'^$', TemplateView.as_view(template_name='home.html'), name='home'), - url(r'^accounts/login/$', auth_views.login, {'template_name': 'accounts/login.html'}, name='login'), - url(r'^accounts/logout/$', auth_views.logout, {'template_name': 'accounts/logout.html'}, name='logout'), - + url(r'^accounts/login/$', + auth_views.login, {'template_name': 'accounts/login.html'}, name='login'), + url(r'^accounts/logout/$', + auth_views.logout, {'template_name': 'accounts/logout.html'}, name='logout'), url(r'^openid/', include('oidc_provider.urls', namespace='oidc_provider')), - - url(r'^admin/', include(admin.site.urls)), + url(r'^admin/', admin.site.urls), ] diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index 31a9aca..6ab07f2 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -1,5 +1,9 @@ import random import string + +import django +from django.contrib.auth.backends import ModelBackend + try: from urlparse import parse_qs, urlsplit except ImportError: @@ -15,7 +19,8 @@ from oidc_provider.models import ( FAKE_NONCE = 'cb584e44c43ed6bd0bc2d9c7e242837d' -FAKE_RANDOM_STRING = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(32)) +FAKE_RANDOM_STRING = ''.join( + random.choice(string.ascii_uppercase + string.digits) for _ in range(32)) FAKE_CODE_CHALLENGE = 'YlYXEqXuRm-Xgi2BOUiK50JW1KsGTX6F1TDnZSC8VTg' FAKE_CODE_VERIFIER = 'SmxGa0XueyNh5bDgTcSrqzAh2_FmXEqU8kDT6CuXicw' @@ -82,7 +87,7 @@ def is_code_valid(url, user, client): code = params['code'][0] code = Code.objects.get(code=code) is_code_ok = (code.client == client) and (code.user == user) - except: + except Exception: is_code_ok = False return is_code_ok @@ -96,6 +101,7 @@ def userinfo(claims, user): claims['family_name'] = 'Doe' claims['name'] = '{0} {1}'.format(claims['given_name'], claims['family_name']) claims['email'] = user.email + claims['email_verified'] = True claims['address']['country'] = 'Argentina' return claims @@ -118,7 +124,8 @@ def fake_idtoken_processing_hook(id_token, user, scope=None): def fake_idtoken_processing_hook2(id_token, user, scope=None): """ - Fake function for inserting some keys into token. Testing OIDC_IDTOKEN_PROCESSING_HOOK - tuple or list as param + Fake function for inserting some keys into token. + Testing OIDC_IDTOKEN_PROCESSING_HOOK - tuple or list as param """ id_token['test_idtoken_processing_hook2'] = FAKE_RANDOM_STRING id_token['test_idtoken_processing_hook_user_email2'] = user.email @@ -131,3 +138,15 @@ def fake_idtoken_processing_hook3(id_token, user, scope=None): """ id_token['scope_passed_to_processing_hook'] = scope return id_token + + +def fake_introspection_processing_hook(response_dict, client, id_token): + response_dict['test_introspection_processing_hook'] = FAKE_RANDOM_STRING + return response_dict + + +class TestAuthBackend: + def authenticate(self, *args, **kwargs): + if django.VERSION[0] >= 2 or (django.VERSION[0] == 1 and django.VERSION[1] >= 11): + assert len(args) > 0 and args[0] + return ModelBackend().authenticate(*args, **kwargs) diff --git a/oidc_provider/tests/test_authorize_endpoint.py b/oidc_provider/tests/cases/test_authorize_endpoint.py similarity index 68% rename from oidc_provider/tests/test_authorize_endpoint.py rename to oidc_provider/tests/cases/test_authorize_endpoint.py index 6cb83f7..d589274 100644 --- a/oidc_provider/tests/test_authorize_endpoint.py +++ b/oidc_provider/tests/cases/test_authorize_endpoint.py @@ -1,7 +1,9 @@ +from oidc_provider.lib.errors import RedirectUriError + try: - from urllib.parse import urlencode + from urllib.parse import urlencode, quote except ImportError: - from urllib import urlencode + from urllib import urlencode, quote try: from urllib.parse import parse_qs, urlsplit except ImportError: @@ -11,7 +13,10 @@ from mock import patch, mock from django.contrib.auth.models import AnonymousUser from django.core.management import call_command -from django.core.urlresolvers import reverse +try: + from django.urls import reverse +except ImportError: + from django.core.urlresolvers import reverse from django.test import ( RequestFactory, override_settings, @@ -32,7 +37,9 @@ from oidc_provider.lib.endpoints.authorize import AuthorizeEndpoint class AuthorizeEndpointMixin(object): - def _auth_request(self, method, data={}, is_user_authenticated=False): + def _auth_request(self, method, data=None, is_user_authenticated=False): + if data is None: + data = {} url = reverse('oidc_provider:authorize') if method.lower() == 'get': @@ -63,9 +70,11 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): self.factory = RequestFactory() self.user = create_fake_user() self.client = create_fake_client(response_type='code') - self.client_with_no_consent = create_fake_client(response_type='code', require_consent=False) + self.client_with_no_consent = create_fake_client( + response_type='code', require_consent=False) self.client_public = create_fake_client(response_type='code', is_public=True) - self.client_public_with_no_consent = create_fake_client(response_type='code', is_public=True, require_consent=False) + self.client_public_with_no_consent = create_fake_client( + response_type='code', is_public=True, require_consent=False) self.state = uuid.uuid4().hex self.nonce = uuid.uuid4().hex @@ -161,8 +170,7 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): for key, value in iter(to_check.items()): is_input_ok = input_html.format(key, value) in response.content.decode('utf-8') - self.assertEqual(is_input_ok, True, - msg='Hidden input for "' + key + '" fails.') + self.assertEqual(is_input_ok, True, msg='Hidden input for "' + key + '" fails.') def test_user_consent_response(self): """ @@ -192,7 +200,8 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): # Because user doesn't allow app, SHOULD exists an error parameter # in the query. self.assertIn('error=', response['Location'], msg='error param is missing in query.') - self.assertIn('access_denied', response['Location'], msg='"access_denied" code is missing in query.') + self.assertIn( + 'access_denied', response['Location'], msg='"access_denied" code is missing in query.') # Simulate user authorization. data['allow'] = 'Accept' # Will be the value of the button. @@ -202,8 +211,7 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): is_code_ok = is_code_valid(url=response['Location'], user=self.user, client=self.client) - self.assertEqual(is_code_ok, True, - msg='Code returned is invalid.') + self.assertEqual(is_code_ok, True, msg='Code returned is invalid.') # Check if the state is returned. state = (response['Location'].split('state='))[1].split('&')[0] @@ -249,9 +257,13 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): self.assertEqual(is_code_ok, True, msg='Code returned is invalid or missing.') def test_response_uri_is_properly_constructed(self): + """ + Check that the redirect_uri matches the one configured for the client. + Only 'state' and 'code' should be appended. + """ data = { 'client_id': self.client.client_id, - 'redirect_uri': self.client.default_redirect_uri + "?redirect_state=xyz", + 'redirect_uri': self.client.default_redirect_uri, 'response_type': 'code', 'scope': 'openid email', 'state': self.state, @@ -260,11 +272,62 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): response = self._auth_request('post', data, is_user_authenticated=True) - # TODO + parsed = urlsplit(response['Location']) + params = parse_qs(parsed.query or parsed.fragment) + state = params['state'][0] + self.assertEquals(self.state, state, msg="State returned is invalid or missing") + + is_code_ok = is_code_valid(url=response['Location'], + user=self.user, + client=self.client) + self.assertTrue(is_code_ok, msg='Code returned is invalid or missing') + + self.assertEquals( + set(params.keys()), {'state', 'code'}, + msg='More than state or code appended as query params') + + self.assertTrue( + response['Location'].startswith(self.client.default_redirect_uri), + msg='Different redirect_uri returned') + + def test_unknown_redirect_uris_are_rejected(self): + """ + If a redirect_uri is not registered with the client the request must be rejected. + See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest. + """ + data = { + 'client_id': self.client.client_id, + 'response_type': 'code', + 'redirect_uri': 'http://neverseenthis.com', + 'scope': 'openid email', + 'state': self.state, + } + + response = self._auth_request('get', data) + self.assertIn( + RedirectUriError.error, response.content.decode('utf-8'), msg='No redirect_uri error') + + def test_manipulated_redirect_uris_are_rejected(self): + """ + If a redirect_uri does not exactly match the registered uri it must be rejected. + See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest. + """ + data = { + 'client_id': self.client.client_id, + 'response_type': 'code', + 'redirect_uri': self.client.default_redirect_uri + "?some=query", + 'scope': 'openid email', + 'state': self.state, + } + + response = self._auth_request('get', data) + self.assertIn( + RedirectUriError.error, response.content.decode('utf-8'), msg='No redirect_uri error') def test_public_client_auto_approval(self): """ - It's recommended not auto-approving requests for non-confidential clients. + It's recommended not auto-approving requests for non-confidential + clients using Authorization Code. """ data = { 'client_id': self.client_public_with_no_consent.client_id, @@ -278,9 +341,10 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): self.assertIn('Request for Permission', response.content.decode('utf-8')) - def test_prompt_parameter(self): + def test_prompt_none_parameter(self): """ - Specifies whether the Authorization Server prompts the End-User for reauthentication and consent. + Specifies whether the Authorization Server prompts the End-User for + reauthentication and consent. See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { @@ -289,10 +353,9 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): 'redirect_uri': self.client.default_redirect_uri, 'scope': 'openid email', 'state': self.state, + 'prompt': 'none' } - data['prompt'] = 'none' - response = self._auth_request('get', data) # An error is returned if an End-User is not already authenticated. @@ -300,8 +363,110 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): response = self._auth_request('get', data, is_user_authenticated=True) - # An error is returned if the Client does not have pre-configured consent for the requested Claims. - self.assertIn('interaction_required', response['Location']) + # An error is returned if the Client does not have pre-configured + # consent for the requested Claims. + self.assertIn('consent_required', response['Location']) + + @patch('oidc_provider.views.django_user_logout') + def test_prompt_login_parameter(self, logout_function): + """ + Specifies whether the Authorization Server prompts the End-User for + reauthentication and consent. + See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + """ + data = { + 'client_id': self.client.client_id, + 'response_type': self.client.response_type, + 'redirect_uri': self.client.default_redirect_uri, + 'scope': 'openid email', + 'state': self.state, + 'prompt': 'login' + } + + response = self._auth_request('get', data) + self.assertIn(settings.get('OIDC_LOGIN_URL'), response['Location']) + self.assertNotIn( + quote('prompt=login'), + response['Location'], + "Found prompt=login, this leads to infinite login loop. See " + "https://github.com/juanifioren/django-oidc-provider/issues/197." + ) + + response = self._auth_request('get', data, is_user_authenticated=True) + self.assertIn(settings.get('OIDC_LOGIN_URL'), response['Location']) + self.assertTrue(logout_function.called_once()) + self.assertNotIn( + quote('prompt=login'), + response['Location'], + "Found prompt=login, this leads to infinite login loop. See " + "https://github.com/juanifioren/django-oidc-provider/issues/197." + ) + + def test_prompt_login_none_parameter(self): + """ + Specifies whether the Authorization Server prompts the End-User for + reauthentication and consent. + See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + """ + data = { + 'client_id': self.client.client_id, + 'response_type': self.client.response_type, + 'redirect_uri': self.client.default_redirect_uri, + 'scope': 'openid email', + 'state': self.state, + 'prompt': 'login none' + } + + response = self._auth_request('get', data) + self.assertIn('login_required', response['Location']) + + response = self._auth_request('get', data, is_user_authenticated=True) + self.assertIn('login_required', response['Location']) + + @patch('oidc_provider.views.render') + def test_prompt_consent_parameter(self, render_patched): + """ + Specifies whether the Authorization Server prompts the End-User for + reauthentication and consent. + See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + """ + data = { + 'client_id': self.client.client_id, + 'response_type': self.client.response_type, + 'redirect_uri': self.client.default_redirect_uri, + 'scope': 'openid email', + 'state': self.state, + 'prompt': 'consent' + } + + response = self._auth_request('get', data) + self.assertIn(settings.get('OIDC_LOGIN_URL'), response['Location']) + + response = self._auth_request('get', data, is_user_authenticated=True) + render_patched.assert_called_once() + self.assertTrue( + render_patched.call_args[0][1], settings.get('OIDC_TEMPLATES')['authorize']) + + def test_prompt_consent_none_parameter(self): + """ + Specifies whether the Authorization Server prompts the End-User for + reauthentication and consent. + See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + """ + data = { + 'client_id': self.client.client_id, + 'response_type': self.client.response_type, + 'redirect_uri': self.client.default_redirect_uri, + 'scope': 'openid email', + 'state': self.state, + 'prompt': 'consent none' + } + + response = self._auth_request('get', data) + self.assertIn('login_required', response['Location']) + + response = self._auth_request('get', data, is_user_authenticated=True) + self.assertIn('consent_required', response['Location']) class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin): @@ -315,6 +480,9 @@ class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin): self.user = create_fake_user() self.client = create_fake_client(response_type='id_token token') self.client_public = create_fake_client(response_type='id_token token', is_public=True) + self.client_public_no_consent = create_fake_client( + response_type='id_token token', is_public=True, + require_consent=False) self.client_no_access = create_fake_client(response_type='id_token') self.client_public_no_access = create_fake_client(response_type='id_token', is_public=True) self.state = uuid.uuid4().hex @@ -448,6 +616,28 @@ class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin): self.assertNotIn('at_hash', id_token) + def test_public_client_implicit_auto_approval(self): + """ + Public clients using Implicit Flow should be able to reuse consent. + """ + data = { + 'client_id': self.client_public_no_consent.client_id, + 'response_type': self.client_public_no_consent.response_type, + 'redirect_uri': self.client_public_no_consent.default_redirect_uri, + 'scope': 'openid email', + 'state': self.state, + 'nonce': self.nonce, + } + + response = self._auth_request('get', data, is_user_authenticated=True) + response_text = response.content.decode('utf-8') + self.assertEquals(response_text, '') + components = urlsplit(response['Location']) + fragment = parse_qs(components[4]) + self.assertIn('access_token', fragment) + self.assertIn('id_token', fragment) + self.assertIn('expires_in', fragment) + class AuthorizationHybridFlowTestCase(TestCase, AuthorizeEndpointMixin): """ @@ -458,7 +648,8 @@ class AuthorizationHybridFlowTestCase(TestCase, AuthorizeEndpointMixin): call_command('creatersakey') self.factory = RequestFactory() self.user = create_fake_user() - self.client_code_idtoken_token = create_fake_client(response_type='code id_token token', is_public=True) + self.client_code_idtoken_token = create_fake_client( + response_type='code id_token token', is_public=True) self.state = uuid.uuid4().hex self.nonce = uuid.uuid4().hex @@ -523,8 +714,9 @@ class TestCreateResponseURI(TestCase): @patch('oidc_provider.lib.endpoints.authorize.logger.exception') def test_create_response_uri_logs_to_error(self, log_exception, create_code): """ - A lot can go wrong when creating a response uri and this is caught with a general Exception error. The - information contained within this error should show up in the error log so production servers have something + A lot can go wrong when creating a response uri and this is caught + with a general Exception error. The information contained within this + error should show up in the error log so production servers have something to work with when things don't work as expected. """ exception = Exception("Something went wrong!") @@ -536,7 +728,8 @@ class TestCreateResponseURI(TestCase): with self.assertRaises(Exception): authorization_endpoint.create_response_uri() - log_exception.assert_called_once_with('[Authorize] Error when trying to create response uri: %s', exception) + log_exception.assert_called_once_with( + '[Authorize] Error when trying to create response uri: %s', exception) @override_settings(OIDC_SESSION_MANAGEMENT_ENABLE=True) def test_create_response_uri_generates_session_state_if_session_management_enabled(self): diff --git a/oidc_provider/tests/test_claims.py b/oidc_provider/tests/cases/test_claims.py similarity index 100% rename from oidc_provider/tests/test_claims.py rename to oidc_provider/tests/cases/test_claims.py diff --git a/oidc_provider/tests/test_commands.py b/oidc_provider/tests/cases/test_commands.py similarity index 100% rename from oidc_provider/tests/test_commands.py rename to oidc_provider/tests/cases/test_commands.py diff --git a/oidc_provider/tests/test_end_session_endpoint.py b/oidc_provider/tests/cases/test_end_session_endpoint.py similarity index 50% rename from oidc_provider/tests/test_end_session_endpoint.py rename to oidc_provider/tests/cases/test_end_session_endpoint.py index b416762..fb36f8e 100644 --- a/oidc_provider/tests/test_end_session_endpoint.py +++ b/oidc_provider/tests/cases/test_end_session_endpoint.py @@ -1,8 +1,12 @@ from django.core.management import call_command -from django.core.urlresolvers import reverse +try: + from django.urls import reverse +except ImportError: + from django.core.urlresolvers import reverse from django.test import TestCase from oidc_provider.lib.utils.token import ( + create_token, create_id_token, encode_id_token, ) @@ -30,25 +34,47 @@ class EndSessionTestCase(TestCase): self.url = reverse('oidc_provider:end-session') - def test_redirects(self): + def test_redirects_when_aud_is_str(self): query_params = { 'post_logout_redirect_uri': self.LOGOUT_URL, } response = self.client.get(self.url, query_params) - # With no id_token the OP MUST NOT redirect to the requested redirect_uri. - self.assertRedirects(response, settings.get('OIDC_LOGIN_URL'), fetch_redirect_response=False) + # With no id_token the OP MUST NOT redirect to the requested + # redirect_uri. + self.assertRedirects( + response, settings.get('OIDC_LOGIN_URL'), + fetch_redirect_response=False) - id_token_dic = create_id_token(user=self.user, aud=self.oidc_client.client_id) + token = create_token(self.user, self.oidc_client, []) + id_token_dic = create_id_token( + token=token, user=self.user, aud=self.oidc_client.client_id) id_token = encode_id_token(id_token_dic, self.oidc_client) query_params['id_token_hint'] = id_token response = self.client.get(self.url, query_params) - self.assertRedirects(response, self.LOGOUT_URL, fetch_redirect_response=False) + self.assertRedirects( + response, self.LOGOUT_URL, fetch_redirect_response=False) + + def test_redirects_when_aud_is_list(self): + """Check with 'aud' containing a list of str.""" + query_params = { + 'post_logout_redirect_uri': self.LOGOUT_URL, + } + token = create_token(self.user, self.oidc_client, []) + id_token_dic = create_id_token( + token=token, user=self.user, aud=self.oidc_client.client_id) + id_token_dic['aud'] = [id_token_dic['aud']] + id_token = encode_id_token(id_token_dic, self.oidc_client) + query_params['id_token_hint'] = id_token + response = self.client.get(self.url, query_params) + self.assertRedirects( + response, self.LOGOUT_URL, fetch_redirect_response=False) @mock.patch(settings.get('OIDC_AFTER_END_SESSION_HOOK')) def test_call_post_end_session_hook(self, hook_function): self.client.get(self.url) self.assertTrue(hook_function.called, 'OIDC_AFTER_END_SESSION_HOOK should be called') - self.assertTrue(hook_function.call_count == 1, 'OIDC_AFTER_END_SESSION_HOOK should be called once but was {}'.format(hook_function.call_count)) - + self.assertTrue( + hook_function.call_count == 1, + 'OIDC_AFTER_END_SESSION_HOOK should be called once') diff --git a/oidc_provider/tests/cases/test_introspection_endpoint.py b/oidc_provider/tests/cases/test_introspection_endpoint.py new file mode 100644 index 0000000..99eab9a --- /dev/null +++ b/oidc_provider/tests/cases/test_introspection_endpoint.py @@ -0,0 +1,116 @@ +import time +import random + +from mock import patch +try: + from urllib.parse import urlencode +except ImportError: + from urllib import urlencode +from django.utils.encoding import force_text +from django.core.management import call_command +from django.test import TestCase, RequestFactory, override_settings +from django.utils import timezone +try: + from django.urls import reverse +except ImportError: + from django.core.urlresolvers import reverse + +from oidc_provider.tests.app.utils import ( + create_fake_user, + create_fake_client, + create_fake_token, + FAKE_RANDOM_STRING) +from oidc_provider.lib.utils.token import create_id_token +from oidc_provider.views import TokenIntrospectionView + + +class IntrospectionTestCase(TestCase): + + def setUp(self): + call_command('creatersakey') + self.factory = RequestFactory() + self.user = create_fake_user() + self.client = create_fake_client(response_type='id_token token') + self.resource = create_fake_client(response_type='id_token token') + self.resource.scope = ['token_introspection', self.client.client_id] + self.resource.save() + self.token = create_fake_token(self.user, self.client.scope, self.client) + self.token.access_token = str(random.randint(1, 999999)).zfill(6) + self.now = time.time() + with patch('oidc_provider.lib.utils.token.time.time') as time_func: + time_func.return_value = self.now + self.token.id_token = create_id_token(self.token, self.user, self.client.client_id) + self.token.save() + + def _assert_inactive(self, response): + self.assertEqual(response.status_code, 200) + self.assertJSONEqual(force_text(response.content), {'active': False}) + + def _make_request(self, **kwargs): + url = reverse('oidc_provider:token-introspection') + data = { + 'client_id': kwargs.get('client_id', self.resource.client_id), + 'client_secret': kwargs.get('client_secret', self.resource.client_secret), + 'token': kwargs.get('access_token', self.token.access_token), + } + + request = self.factory.post(url, data=urlencode(data), + content_type='application/x-www-form-urlencoded') + + return TokenIntrospectionView.as_view()(request) + + def test_no_client_params_returns_inactive(self): + response = self._make_request(client_id='') + self._assert_inactive(response) + + def test_no_client_secret_returns_inactive(self): + response = self._make_request(client_secret='') + self._assert_inactive(response) + + def test_invalid_client_returns_inactive(self): + response = self._make_request(client_id='invalid') + self._assert_inactive(response) + + def test_token_not_found_returns_inactive(self): + response = self._make_request(access_token='invalid') + self._assert_inactive(response) + + def test_scope_no_audience_returns_inactive(self): + self.resource.scope = ['token_introspection'] + self.resource.save() + response = self._make_request() + self._assert_inactive(response) + + def test_token_expired_returns_inactive(self): + self.token.expires_at = timezone.now() - timezone.timedelta(seconds=60) + self.token.save() + response = self._make_request() + self._assert_inactive(response) + + def test_valid_request_returns_default_properties(self): + response = self._make_request() + self.assertEqual(response.status_code, 200) + self.assertJSONEqual(force_text(response.content), { + 'active': True, + 'aud': self.resource.client_id, + 'client_id': self.client.client_id, + 'sub': str(self.user.pk), + 'iat': int(self.now), + 'exp': int(self.now + 600), + 'iss': 'http://localhost:8000/openid', + }) + + @override_settings(OIDC_INTROSPECTION_PROCESSING_HOOK='oidc_provider.tests.app.utils.fake_introspection_processing_hook') # NOQA + def test_custom_introspection_hook_called_on_valid_request(self): + response = self._make_request() + self.assertEqual(response.status_code, 200) + self.assertJSONEqual(force_text(response.content), { + 'active': True, + 'aud': self.resource.client_id, + 'client_id': self.client.client_id, + 'sub': str(self.user.pk), + 'iat': int(self.now), + 'exp': int(self.now + 600), + 'iss': 'http://localhost:8000/openid', + 'test_introspection_processing_hook': FAKE_RANDOM_STRING + }) diff --git a/oidc_provider/tests/test_middleware.py b/oidc_provider/tests/cases/test_middleware.py similarity index 93% rename from oidc_provider/tests/test_middleware.py rename to oidc_provider/tests/cases/test_middleware.py index c2a02df..4c93b0c 100644 --- a/oidc_provider/tests/test_middleware.py +++ b/oidc_provider/tests/cases/test_middleware.py @@ -1,4 +1,7 @@ -from django.conf.urls import url +try: + from django.urls import url +except ImportError: + from django.conf.urls import url from django.test import TestCase, override_settings from django.views.generic import View from mock import mock @@ -10,6 +13,7 @@ class StubbedViews: urlpatterns = [url('^test/', SampleView.as_view())] + MW_CLASSES = ('django.contrib.sessions.middleware.SessionMiddleware', 'oidc_provider.middleware.SessionManagementMiddleware') diff --git a/oidc_provider/tests/test_provider_info_endpoint.py b/oidc_provider/tests/cases/test_provider_info_endpoint.py similarity index 87% rename from oidc_provider/tests/test_provider_info_endpoint.py rename to oidc_provider/tests/cases/test_provider_info_endpoint.py index 6e8da9d..2265ef6 100644 --- a/oidc_provider/tests/test_provider_info_endpoint.py +++ b/oidc_provider/tests/cases/test_provider_info_endpoint.py @@ -1,4 +1,7 @@ -from django.core.urlresolvers import reverse +try: + from django.urls import reverse +except ImportError: + from django.core.urlresolvers import reverse from django.test import RequestFactory from django.test import TestCase diff --git a/oidc_provider/tests/test_settings.py b/oidc_provider/tests/cases/test_settings.py similarity index 100% rename from oidc_provider/tests/test_settings.py rename to oidc_provider/tests/cases/test_settings.py diff --git a/oidc_provider/tests/test_token_endpoint.py b/oidc_provider/tests/cases/test_token_endpoint.py similarity index 83% rename from oidc_provider/tests/test_token_endpoint.py rename to oidc_provider/tests/cases/test_token_endpoint.py index 3fedd83..d0e3703 100644 --- a/oidc_provider/tests/test_token_endpoint.py +++ b/oidc_provider/tests/cases/test_token_endpoint.py @@ -3,23 +3,30 @@ import time import uuid from base64 import b64encode + try: from urllib.parse import urlencode except ImportError: from urllib import urlencode from django.core.management import call_command -from django.core.urlresolvers import reverse +from django.http import JsonResponse +try: + from django.urls import reverse +except ImportError: + from django.core.urlresolvers import reverse from django.test import ( RequestFactory, override_settings, ) from django.test import TestCase +from django.views.decorators.http import require_http_methods from jwkest.jwk import KEYS from jwkest.jws import JWS from jwkest.jwt import JWT -from mock import patch, Mock +from mock import patch +from oidc_provider.lib.utils.oauth2 import protected_resource_view from oidc_provider.lib.utils.token import create_code from oidc_provider.models import Token from oidc_provider.tests.app.utils import ( @@ -101,7 +108,8 @@ class TokenTestCase(TestCase): """ url = reverse('oidc_provider:token') - request = self.factory.post(url, + request = self.factory.post( + url, data=urlencode(post_data), content_type='application/x-www-form-urlencoded', **extras) @@ -148,28 +156,6 @@ class TokenTestCase(TestCase): auth_header = {'HTTP_AUTHORIZATION': auth.decode('utf-8')} return auth_header - # Resource Owner Password Credentials Grant - # requirements to satisfy in all test_password_grant methods - # https://tools.ietf.org/html/rfc6749#section-4.3.2 - # - # grant_type - # REQUIRED. Value MUST be set to "password". - # username - # REQUIRED. The resource owner username. - # password - # REQUIRED. The resource owner password. - # scope - # OPTIONAL. The scope of the access request as described by - # Section 3.3. - # - # The authorization server MUST: - # o require client authentication for confidential clients or for any - # client that was issued client credentials (or with other - # authentication requirements), - # o authenticate the client if client authentication is included, and - # o validate the resource owner password credentials using its - # existing password validation algorithm. - def test_default_setting_does_not_allow_grant_type_password(self): post_data = self._password_grant_post_data() @@ -250,7 +236,8 @@ class TokenTestCase(TestCase): ) response_dict = json.loads(response.content.decode('utf-8')) - id_token = JWS().verify_compact(response_dict['id_token'].encode('utf-8'), self._get_keys()) + id_token = JWS().verify_compact( + response_dict['id_token'].encode('utf-8'), self._get_keys()) token = Token.objects.get(user=self.user) self.assertEqual(response_dict['access_token'], token.access_token) @@ -270,6 +257,17 @@ class TokenTestCase(TestCase): else: self.assertNotIn(claim, userinfo) + @override_settings(OIDC_GRANT_TYPE_PASSWORD_ENABLE=True, + AUTHENTICATION_BACKENDS=("oidc_provider.tests.app.utils.TestAuthBackend",)) + def test_password_grant_passes_request_to_backend(self): + response = self._post_request( + post_data=self._password_grant_post_data(), + extras=self._password_grant_auth_header() + ) + + response_dict = json.loads(response.content.decode('utf-8')) + self.assertIn('access_token', response_dict) + @override_settings(OIDC_TOKEN_EXPIRE=720) def test_authorization_code(self): """ @@ -295,17 +293,19 @@ class TokenTestCase(TestCase): self.assertEqual(id_token['sub'], str(self.user.id)) self.assertEqual(id_token['aud'], self.client.client_id) - @override_settings(OIDC_TOKEN_EXPIRE=720) + @override_settings(OIDC_TOKEN_EXPIRE=720, + OIDC_IDTOKEN_INCLUDE_CLAIMS=True) def test_scope_is_ignored_for_auth_code(self): """ Scope is ignored for token respones to auth code grant type. + This comes down to that the scopes requested in authorize are returned. """ SIGKEYS = self._get_keys() - for code_scope in [['openid'], ['openid', 'email']]: + for code_scope in [['openid'], ['openid', 'email'], ['openid', 'profile']]: code = self._create_code(code_scope) post_data = self._auth_code_post_data( - code=code.code, scope=['openid', 'profile']) + code=code.code, scope=code_scope) response = self._post_request(post_data) response_dic = json.loads(response.content.decode('utf-8')) @@ -316,9 +316,15 @@ class TokenTestCase(TestCase): if 'email' in code_scope: self.assertIn('email', id_token) + self.assertIn('email_verified', id_token) else: self.assertNotIn('email', id_token) + if 'profile' in code_scope: + self.assertIn('given_name', id_token) + else: + self.assertNotIn('given_name', id_token) + def test_refresh_token(self): """ A request to the Token Endpoint can also use a Refresh Token @@ -347,6 +353,7 @@ class TokenTestCase(TestCase): """ self.do_refresh_token_check(scope=['openid']) + @override_settings(OIDC_IDTOKEN_INCLUDE_CLAIMS=True) def do_refresh_token_check(self, scope=None): SIGKEYS = self._get_keys() @@ -371,7 +378,7 @@ class TokenTestCase(TestCase): response_dic2 = json.loads(response.content.decode('utf-8')) - if scope and set(scope) - set(code.scope): # too broad scope + if scope and set(scope) - set(code.scope): # too broad scope self.assertEqual(response.status_code, 400) # Bad Request self.assertIn('error', response_dic2) self.assertEqual(response_dic2['error'], 'invalid_scope') @@ -420,14 +427,13 @@ class TokenTestCase(TestCase): response = self._post_request(post_data) self.assertIn('invalid_grant', response.content.decode('utf-8')) - def test_client_redirect_url(self): + def test_client_redirect_uri(self): """ - Validate that client redirect URIs with query strings match registered - URIs, and that unregistered URIs are rejected. - - source: https://github.com/jerrykan/django-oidc-provider/blob/2f54e537666c689dd8448f8bbc6a3a0244b01a97/oidc_provider/tests/test_token_endpoint.py + Validate that client redirect URIs exactly match registered + URIs, and that unregistered URIs or URIs with query parameters are rejected. + See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest and + http://openid.net/specs/openid-connect-core-1_0.html#HybridTokenRequest. """ - SIGKEYS = self._get_keys() code = self._create_code() post_data = self._auth_code_post_data(code=code.code) @@ -435,15 +441,19 @@ class TokenTestCase(TestCase): post_data['redirect_uri'] = 'http://invalid.example.org' response = self._post_request(post_data) + self.assertIn('invalid_client', response.content.decode('utf-8')) - self.assertIn('invalid_client', response.content.decode('utf-8')), - - # Registered URI contained a query string - post_data['redirect_uri'] = 'http://example.com/?client=OidcClient' + # Registered URI, but with query string appended + post_data['redirect_uri'] = self.client.default_redirect_uri + '?foo=bar' response = self._post_request(post_data) + self.assertIn('invalid_client', response.content.decode('utf-8')) - self.assertNotIn('invalid_client', response.content.decode('utf-8')), + # Registered URI + post_data['redirect_uri'] = self.client.default_redirect_uri + + response = self._post_request(post_data) + self.assertNotIn('invalid_client', response.content.decode('utf-8')) def test_request_methods(self): """ @@ -461,15 +471,17 @@ class TokenTestCase(TestCase): for request in requests: response = TokenView.as_view()(request) - self.assertEqual(response.status_code == 405, True, + self.assertEqual( + response.status_code, 405, msg=request.method + ' request does not return a 405 status.') request = self.factory.post(url) response = TokenView.as_view()(request) - self.assertEqual(response.status_code == 400, True, - msg=request.method + ' request does not return a 400 status.') + self.assertEqual( + response.status_code, 400, + msg=request.method + ' request does not return a 400 status.') def test_client_authentication(self): """ @@ -486,9 +498,10 @@ class TokenTestCase(TestCase): response = self._post_request(post_data) - self.assertEqual('invalid_client' in response.content.decode('utf-8'), - False, - msg='Client authentication fails using request-body credentials.') + self.assertNotIn( + 'invalid_client', + response.content.decode('utf-8'), + msg='Client authentication fails using request-body credentials.') # Now, test with an invalid client_id. invalid_data = post_data.copy() @@ -500,9 +513,10 @@ class TokenTestCase(TestCase): response = self._post_request(invalid_data) - self.assertEqual('invalid_client' in response.content.decode('utf-8'), - True, - msg='Client authentication success with an invalid "client_id".') + self.assertIn( + 'invalid_client', + response.content.decode('utf-8'), + msg='Client authentication success with an invalid "client_id".') # Now, test using HTTP Basic Authentication method. basicauth_data = post_data.copy() @@ -517,32 +531,10 @@ class TokenTestCase(TestCase): response = self._post_request(basicauth_data, self._password_grant_auth_header()) response.content.decode('utf-8') - self.assertEqual('invalid_client' in response.content.decode('utf-8'), - False, - msg='Client authentication fails using HTTP Basic Auth.') - - def test_client_redirect_url(self): - """ - Validate that client redirect URIs with query strings match registered - URIs, and that unregistered URIs are rejected. - """ - SIGKEYS = self._get_keys() - code = self._create_code() - post_data = self._auth_code_post_data(code=code.code) - - # Unregistered URI - post_data['redirect_uri'] = 'http://invalid.example.org' - - response = self._post_request(post_data) - - self.assertIn('invalid_client', response.content.decode('utf-8')), - - # Registered URI contained a query string - post_data['redirect_uri'] = 'http://example.com/?client=OidcClient' - - response = self._post_request(post_data) - - self.assertNotIn('invalid_client', response.content.decode('utf-8')), + self.assertNotIn( + 'invalid_client', + response.content.decode('utf-8'), + msg='Client authentication fails using HTTP Basic Auth.') def test_access_token_contains_nonce(self): """ @@ -607,9 +599,10 @@ class TokenTestCase(TestCase): response = self._post_request(post_data) response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWS().verify_compact(response_dic['id_token'].encode('utf-8'), RSAKEYS) + JWS().verify_compact(response_dic['id_token'].encode('utf-8'), RSAKEYS) - @override_settings(OIDC_IDTOKEN_SUB_GENERATOR='oidc_provider.tests.app.utils.fake_sub_generator') + @override_settings( + OIDC_IDTOKEN_SUB_GENERATOR='oidc_provider.tests.app.utils.fake_sub_generator') def test_custom_sub_generator(self): """ Test custom function for setting OIDC_IDTOKEN_SUB_GENERATOR. @@ -625,7 +618,8 @@ class TokenTestCase(TestCase): self.assertEqual(id_token.get('sub'), self.user.email) - @override_settings(OIDC_IDTOKEN_PROCESSING_HOOK='oidc_provider.tests.app.utils.fake_idtoken_processing_hook') + @override_settings( + OIDC_IDTOKEN_PROCESSING_HOOK='oidc_provider.tests.app.utils.fake_idtoken_processing_hook') def test_additional_idtoken_processing_hook(self): """ Test custom function for setting OIDC_IDTOKEN_PROCESSING_HOOK. @@ -771,4 +765,53 @@ class TokenTestCase(TestCase): response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) + json.loads(response.content.decode('utf-8')) + + def test_client_credentials_grant_type(self): + fake_scopes_list = ['scopeone', 'scopetwo'] + + # Add scope for this client. + self.client.scope = fake_scopes_list + self.client.save() + + post_data = { + 'client_id': self.client.client_id, + 'client_secret': self.client.client_secret, + 'grant_type': 'client_credentials', + } + response = self._post_request(post_data) + response_dict = json.loads(response.content.decode('utf-8')) + + # Ensure access token exists in the response, also check if scopes are + # the ones we registered previously. + self.assertTrue('access_token' in response_dict) + self.assertEqual(' '.join(fake_scopes_list), response_dict['scope']) + + # Create a protected resource and test the access_token. + + @require_http_methods(['GET']) + @protected_resource_view(fake_scopes_list) + def protected_api(request, *args, **kwargs): + return JsonResponse({'protected': 'information'}, status=200) + + # Deploy view on some url. So, base url could be anything. + request = self.factory.get( + '/api/protected/?access_token={0}'.format(response_dict['access_token'])) + response = protected_api(request) + response_dict = json.loads(response.content.decode('utf-8')) + + self.assertEqual(response.status_code, 200) + self.assertTrue('protected' in response_dict) + + # Protected resource test ends here. + + # Clean scopes for this client. + self.client.scope = '' + self.client.save() + + response = self._post_request(post_data) + response_dict = json.loads(response.content.decode('utf-8')) + + # It should fail when client does not have any scope added. + self.assertEqual(400, response.status_code) + self.assertEqual('invalid_scope', response_dict['error']) diff --git a/oidc_provider/tests/test_userinfo_endpoint.py b/oidc_provider/tests/cases/test_userinfo_endpoint.py similarity index 83% rename from oidc_provider/tests/test_userinfo_endpoint.py rename to oidc_provider/tests/cases/test_userinfo_endpoint.py index de95cd8..dc26371 100644 --- a/oidc_provider/tests/test_userinfo_endpoint.py +++ b/oidc_provider/tests/cases/test_userinfo_endpoint.py @@ -6,7 +6,10 @@ try: except ImportError: from urllib import urlencode -from django.core.urlresolvers import reverse +try: + from django.urls import reverse +except ImportError: + from django.core.urlresolvers import reverse from django.test import RequestFactory from django.test import TestCase from django.utils import timezone @@ -30,24 +33,28 @@ class UserInfoTestCase(TestCase): self.user = create_fake_user() self.client = create_fake_client(response_type='code') - def _create_token(self, extra_scope=[]): + def _create_token(self, extra_scope=None): """ Generate a valid token. """ + if extra_scope is None: + extra_scope = [] scope = ['openid', 'email'] + extra_scope + token = create_token( + user=self.user, + client=self.client, + scope=scope) + id_token_dic = create_id_token( + token=token, user=self.user, aud=self.client.client_id, nonce=FAKE_NONCE, scope=scope, ) - token = create_token( - user=self.user, - client=self.client, - id_token_dic=id_token_dic, - scope=scope) + token.id_token = id_token_dic token.save() return token @@ -60,9 +67,7 @@ class UserInfoTestCase(TestCase): """ url = reverse('oidc_provider:userinfo') - request = self.factory.post(url, - data={}, - content_type='multipart/form-data') + request = self.factory.post(url, data={}, content_type='multipart/form-data') request.META['HTTP_AUTHORIZATION'] = 'Bearer ' + access_token @@ -136,17 +141,14 @@ class UserInfoTestCase(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(bool(response.content), True) - self.assertEqual('given_name' in response_dic, True, - msg='"given_name" claim should be in response.') - self.assertEqual('profile' in response_dic, False, - msg='"profile" claim should not be in response.') + self.assertIn('given_name', response_dic, msg='"given_name" claim should be in response.') + self.assertNotIn('profile', response_dic, msg='"profile" claim should not be in response.') # Now adding `address` scope. token = self._create_token(extra_scope=['profile', 'address']) response = self._post_request(token.access_token) response_dic = json.loads(response.content.decode('utf-8')) - self.assertEqual('address' in response_dic, True, - msg='"address" claim should be in response.') - self.assertEqual('country' in response_dic['address'], True, - msg='"country" claim should be in response.') + self.assertIn('address', response_dic, msg='"address" claim should be in response.') + self.assertIn( + 'country', response_dic['address'], msg='"country" claim should be in response.') diff --git a/oidc_provider/tests/test_utils.py b/oidc_provider/tests/cases/test_utils.py similarity index 90% rename from oidc_provider/tests/test_utils.py rename to oidc_provider/tests/cases/test_utils.py index b09ff46..787a3f5 100644 --- a/oidc_provider/tests/test_utils.py +++ b/oidc_provider/tests/cases/test_utils.py @@ -8,8 +8,8 @@ from django.utils import timezone from mock import mock from oidc_provider.lib.utils.common import get_issuer, get_browser_state_or_default -from oidc_provider.lib.utils.token import create_id_token -from oidc_provider.tests.app.utils import create_fake_user +from oidc_provider.lib.utils.token import create_token, create_id_token +from oidc_provider.tests.app.utils import create_fake_user, create_fake_client class Request(object): @@ -67,7 +67,9 @@ class TokenTest(TestCase): start_time = int(time.time()) login_timestamp = start_time - 1234 self.user.last_login = timestamp_to_datetime(login_timestamp) - id_token_data = create_id_token(self.user, aud='test-aud') + client = create_fake_client("code") + token = create_token(self.user, client, []) + id_token_data = create_id_token(token=token, user=self.user, aud='test-aud') iat = id_token_data['iat'] self.assertEqual(type(iat), int) self.assertGreaterEqual(iat, start_time) diff --git a/oidc_provider/tests/settings.py b/oidc_provider/tests/settings.py new file mode 100644 index 0000000..ea61262 --- /dev/null +++ b/oidc_provider/tests/settings.py @@ -0,0 +1,79 @@ +DEBUG = False + +SECRET_KEY = 'this-should-be-top-secret' + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + } +} + +SITE_ID = 1 + +MIDDLEWARE_CLASSES = [ + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', +] + +MIDDLEWARE = [ + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', +] + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +INSTALLED_APPS = [ + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + 'django.contrib.admin', + 'oidc_provider', +] + +ROOT_URLCONF = 'oidc_provider.tests.app.urls' + +TEMPLATE_DIRS = [ + 'oidc_provider/tests/templates', +] + +USE_TZ = True + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'loggers': { + 'oidc_provider': { + 'handlers': ['console'], + 'level': 'DEBUG', + }, + }, +} + +# OIDC Provider settings. + +SITE_URL = 'http://localhost:8000' +OIDC_USERINFO = 'oidc_provider.tests.app.utils.userinfo' diff --git a/oidc_provider/urls.py b/oidc_provider/urls.py index 6b62883..44cc914 100644 --- a/oidc_provider/urls.py +++ b/oidc_provider/urls.py @@ -1,4 +1,7 @@ -from django.conf.urls import url +try: + from django.urls import url +except ImportError: + from django.conf.urls import url from django.views.decorators.csrf import csrf_exempt from oidc_provider import ( @@ -6,18 +9,20 @@ from oidc_provider import ( views, ) - +app_name = 'oidc_provider' urlpatterns = [ url(r'^authorize/?$', views.AuthorizeView.as_view(), name='authorize'), url(r'^token/?$', csrf_exempt(views.TokenView.as_view()), name='token'), url(r'^userinfo/?$', csrf_exempt(views.userinfo), name='userinfo'), url(r'^end-session/?$', views.EndSessionView.as_view(), name='end-session'), - - url(r'^\.well-known/openid-configuration/?$', views.ProviderInfoView.as_view(), name='provider-info'), + url(r'^\.well-known/openid-configuration/?$', views.ProviderInfoView.as_view(), + name='provider-info'), + url(r'^introspect/?$', views.TokenIntrospectionView.as_view(), name='token-introspection'), url(r'^jwks/?$', views.JwksView.as_view(), name='jwks'), ] if settings.get('OIDC_SESSION_MANAGEMENT_ENABLE'): urlpatterns += [ - url(r'^check-session-iframe/?$', views.CheckSessionIframeView.as_view(), name='check-session-iframe'), + url(r'^check-session-iframe/?$', views.CheckSessionIframeView.as_view(), + name='check-session-iframe'), ] diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 7fdbd11..e6f3ddc 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -1,4 +1,8 @@ import logging + +from django.views.decorators.csrf import csrf_exempt + +from oidc_provider.lib.endpoints.introspection import TokenIntrospectionEndpoint try: from urllib import urlencode from urlparse import urlsplit, parse_qs, urlunsplit @@ -10,7 +14,11 @@ from django.contrib.auth.views import ( redirect_to_login, logout, ) -from django.core.urlresolvers import reverse +try: + from django.urls import reverse +except ImportError: + from django.core.urlresolvers import reverse +from django.contrib.auth import logout as django_user_logout from django.http import JsonResponse from django.shortcuts import render from django.template.loader import render_to_string @@ -20,6 +28,7 @@ from django.views.decorators.http import require_http_methods from django.views.generic import View from jwkest import long_to_base64 +from oidc_provider.compat import get_attr_or_callable from oidc_provider.lib.claims import StandardScopeClaims from oidc_provider.lib.endpoints.authorize import AuthorizeEndpoint from oidc_provider.lib.endpoints.token import TokenEndpoint @@ -28,7 +37,8 @@ from oidc_provider.lib.errors import ( ClientIdError, RedirectUriError, TokenError, - UserAuthError) + UserAuthError, + TokenIntrospectionError) from oidc_provider.lib.utils.common import ( redirect, get_site_url, @@ -51,7 +61,6 @@ OIDC_TEMPLATES = settings.get('OIDC_TEMPLATES') class AuthorizeView(View): - def get(self, request, *args, **kwargs): authorize = AuthorizeEndpoint(request) @@ -59,7 +68,7 @@ class AuthorizeView(View): try: authorize.validate_params() - if request.user.is_authenticated(): + if get_attr_or_callable(request.user, 'is_authenticated'): # Check if there's a hook setted. hook_resp = settings.get('OIDC_AFTER_USERLOGIN_HOOK', import_str=True)( request=request, user=request.user, @@ -67,25 +76,51 @@ class AuthorizeView(View): if hook_resp: return hook_resp - if not authorize.client.require_consent and not (authorize.client.client_type == 'public') \ - and not (authorize.params['prompt'] == 'consent'): + if 'login' in authorize.params['prompt']: + if 'none' in authorize.params['prompt']: + raise AuthorizeError( + authorize.params['redirect_uri'], 'login_required', + authorize.grant_type) + else: + django_user_logout(request) + next_page = self.strip_prompt_login(request.get_full_path()) + return redirect_to_login(next_page, settings.get('OIDC_LOGIN_URL')) + + if 'select_account' in authorize.params['prompt']: + # TODO: see how we can support multiple accounts for the end-user. + if 'none' in authorize.params['prompt']: + raise AuthorizeError( + authorize.params['redirect_uri'], 'account_selection_required', + authorize.grant_type) + else: + django_user_logout(request) + return redirect_to_login( + request.get_full_path(), settings.get('OIDC_LOGIN_URL')) + + if {'none', 'consent'}.issubset(authorize.params['prompt']): + raise AuthorizeError( + authorize.params['redirect_uri'], 'consent_required', authorize.grant_type) + + implicit_flow_resp_types = {'id_token', 'id_token token'} + allow_skipping_consent = ( + authorize.client.client_type != 'public' or + authorize.client.response_type in implicit_flow_resp_types) + + if not authorize.client.require_consent and ( + allow_skipping_consent and + 'consent' not in authorize.params['prompt']): return redirect(authorize.create_response_uri()) if authorize.client.reuse_consent: # Check if user previously give consent. - if authorize.client_has_user_consent() and not (authorize.client.client_type == 'public') \ - and not (authorize.params['prompt'] == 'consent'): + if authorize.client_has_user_consent() and ( + allow_skipping_consent and + 'consent' not in authorize.params['prompt']): return redirect(authorize.create_response_uri()) - if authorize.params['prompt'] == 'none': - raise AuthorizeError(authorize.params['redirect_uri'], 'interaction_required', authorize.grant_type) - - if authorize.params['prompt'] == 'login': - return redirect_to_login(request.get_full_path(), settings.get('OIDC_LOGIN_URL')) - - if authorize.params['prompt'] == 'select_account': - # TODO: see how we can support multiple accounts for the end-user. - raise AuthorizeError(authorize.params['redirect_uri'], 'account_selection_required', authorize.grant_type) + if 'none' in authorize.params['prompt']: + raise AuthorizeError( + authorize.params['redirect_uri'], 'consent_required', authorize.grant_type) # Generate hidden inputs for the form. context = { @@ -107,8 +142,12 @@ class AuthorizeView(View): return render(request, OIDC_TEMPLATES['authorize'], context) else: - if authorize.params['prompt'] == 'none': - raise AuthorizeError(authorize.params['redirect_uri'], 'login_required', authorize.grant_type) + if 'none' in authorize.params['prompt']: + raise AuthorizeError( + authorize.params['redirect_uri'], 'login_required', authorize.grant_type) + if 'login' in authorize.params['prompt']: + next_page = self.strip_prompt_login(request.get_full_path()) + return redirect_to_login(next_page, settings.get('OIDC_LOGIN_URL')) return redirect_to_login(request.get_full_path(), settings.get('OIDC_LOGIN_URL')) @@ -120,7 +159,7 @@ class AuthorizeView(View): return render(request, OIDC_TEMPLATES['error'], context) - except (AuthorizeError) as error: + except AuthorizeError as error: uri = error.create_uri( authorize.params['redirect_uri'], authorize.params['state']) @@ -134,13 +173,17 @@ class AuthorizeView(View): authorize.validate_params() if not request.POST.get('allow'): - signals.user_decline_consent.send(self.__class__, user=request.user, client=authorize.client, scope=authorize.params['scope']) + signals.user_decline_consent.send( + self.__class__, user=request.user, + client=authorize.client, scope=authorize.params['scope']) raise AuthorizeError(authorize.params['redirect_uri'], 'access_denied', authorize.grant_type) - signals.user_accept_consent.send(self.__class__, user=request.user, client=authorize.client, scope=authorize.params['scope']) + signals.user_accept_consent.send( + self.__class__, user=request.user, client=authorize.client, + scope=authorize.params['scope']) # Save the user consent given to the client. authorize.set_client_user_consent() @@ -149,16 +192,29 @@ class AuthorizeView(View): return redirect(uri) - except (AuthorizeError) as error: + except AuthorizeError as error: uri = error.create_uri( authorize.params['redirect_uri'], authorize.params['state']) return redirect(uri) + @staticmethod + def strip_prompt_login(path): + """ + Strips 'login' from the 'prompt' query parameter. + """ + uri = urlsplit(path) + query_params = parse_qs(uri.query) + if 'login' in query_params['prompt']: + query_params['prompt'].remove('login') + if not query_params['prompt']: + del query_params['prompt'] + uri = uri._replace(query=urlencode(query_params, doseq=True)) + return urlunsplit(uri) + class TokenView(View): - def post(self, request, *args, **kwargs): token = TokenEndpoint(request) @@ -179,10 +235,10 @@ class TokenView(View): @protected_resource_view(['openid']) def userinfo(request, *args, **kwargs): """ - Create a diccionary with all the requested claims about the End-User. + Create a dictionary with all the requested claims about the End-User. See: http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse - Return a diccionary. + Return a dictionary. """ token = kwargs['token'] @@ -206,7 +262,6 @@ def userinfo(request, *args, **kwargs): class ProviderInfoView(View): - def get(self, request, *args, **kwargs): dic = dict() @@ -217,6 +272,7 @@ class ProviderInfoView(View): dic['token_endpoint'] = site_url + reverse('oidc_provider:token') dic['userinfo_endpoint'] = site_url + reverse('oidc_provider:userinfo') dic['end_session_endpoint'] = site_url + reverse('oidc_provider:end-session') + dic['introspection_endpoint'] = site_url + reverse('oidc_provider:token-introspection') types_supported = [x[0] for x in RESPONSE_TYPE_CHOICES] dic['response_types_supported'] = types_supported @@ -241,7 +297,6 @@ class ProviderInfoView(View): class JwksView(View): - def get(self, request, *args, **kwargs): dic = dict(keys=[]) @@ -263,7 +318,6 @@ class JwksView(View): class EndSessionView(View): - def get(self, request, *args, **kwargs): id_token_hint = request.GET.get('id_token_hint', '') post_logout_redirect_uri = request.GET.get('post_logout_redirect_uri', '') @@ -302,10 +356,25 @@ class EndSessionView(View): class CheckSessionIframeView(View): - @method_decorator(xframe_options_exempt) def dispatch(self, request, *args, **kwargs): return super(CheckSessionIframeView, self).dispatch(request, *args, **kwargs) def get(self, request, *args, **kwargs): return render(request, 'oidc_provider/check_session_iframe.html', kwargs) + + +class TokenIntrospectionView(View): + @method_decorator(csrf_exempt) + def dispatch(self, request, *args, **kwargs): + return super(TokenIntrospectionView, self).dispatch(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + introspection = TokenIntrospectionEndpoint(request) + + try: + introspection.validate_params() + dic = introspection.create_response_dic() + return TokenIntrospectionEndpoint.response(dic) + except TokenIntrospectionError: + return TokenIntrospectionEndpoint.response({'active': False}) diff --git a/runtests.py b/runtests.py deleted file mode 100644 index 1557853..0000000 --- a/runtests.py +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python -import os -import sys - -import django - -from django.conf import settings - - -DEFAULT_SETTINGS = dict( - - DEBUG = False, - - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', - } - }, - - SITE_ID = 1, - - MIDDLEWARE_CLASSES = [ - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - ], - - TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, - ], - - LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', - }, - }, - 'loggers': { - 'oidc_provider': { - 'handlers': ['console'], - 'level': os.getenv('DJANGO_LOG_LEVEL', 'DEBUG'), - }, - }, - }, - - INSTALLED_APPS = [ - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.messages', - 'django.contrib.admin', - 'oidc_provider', - ], - - SECRET_KEY = 'this-should-be-top-secret', - - ROOT_URLCONF = 'oidc_provider.tests.app.urls', - - TEMPLATE_DIRS = [ - 'oidc_provider/tests/templates', - ], - - USE_TZ = True, - - # OIDC Provider settings. - - SITE_URL = 'http://localhost:8000', - OIDC_USERINFO = 'oidc_provider.tests.app.utils.userinfo', - -) - - -def runtests(*test_args): - if not settings.configured: - settings.configure(**DEFAULT_SETTINGS) - - django.setup() - - parent = os.path.dirname(os.path.abspath(__file__)) - sys.path.insert(0, parent) - - try: - from django.test.runner import DiscoverRunner - runner_class = DiscoverRunner - if not test_args: - test_args = ["oidc_provider.tests"] - except ImportError: - from django.test.simple import DjangoTestSuiteRunner - runner_class = DjangoTestSuiteRunner - if not test_args: - test_args = ["tests"] - - failures = runner_class(verbosity=1, interactive=True, failfast=False).run_tests(test_args) - sys.exit(failures) - - -if __name__ == "__main__": - runtests(*sys.argv[1:]) diff --git a/setup.py b/setup.py index deae584..5152c26 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( name='django-oidc-provider', - version='0.5.0', + version='0.6.0', packages=find_packages(), include_package_data=True, license='MIT License', @@ -20,6 +20,7 @@ setup( author='Juan Ignacio Fiorentino', author_email='juanifioren@gmail.com', classifiers=[ + 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Framework :: Django', 'Intended Audience :: Developers', @@ -37,11 +38,11 @@ setup( ], test_suite='runtests.runtests', tests_require=[ - 'pyjwkest==1.3.0', - 'mock==2.0.0', + 'pyjwkest>=1.3.0', + 'mock>=2.0.0', ], install_requires=[ - 'pyjwkest==1.3.0', + 'pyjwkest>=1.3.0', ], ) diff --git a/tox.ini b/tox.ini index ef539ff..4a2ade4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,32 +1,45 @@ [tox] - envlist= - clean, - py27-django{17,18,19,110,111}, - py34-django{17,18,19,110,111}, - py35-django{18,19,110,111}, - py36-django{18,19,110,111}, + py27-django{18,19,110,111}, + py34-django{18,19,110,111,20}, + py35-django{18,19,110,111,20}, + py36-django{18,19,110,111,20}, [testenv] - +changedir= + oidc_provider deps = - django17: django>=1.7,<1.8 + mock + psycopg2 + pytest + pytest-django + pytest-flake8 + pytest-cov django18: django>=1.8,<1.9 django19: django>=1.9,<1.10 django110: django>=1.10,<1.11 django111: django>=1.11,<1.12 - coverage - mock + django20: django>=2.0,<2.1 commands = - coverage run setup.py test + pytest --flake8 --cov=oidc_provider {posargs} -[testenv:clean] +[testenv:docs] +basepython = python2.7 +changedir = docs +deps = + sphinx + sphinx_rtd_theme +commands = + mkdir -p _static/ + sphinx-build -v -W -b html -d {envtmpdir}/doctrees -D html_static_path="_static" . {envtmpdir}/html -commands= - coverage erase - -[testenv:stats] - -commands= - coverage report -m +[pytest] +DJANGO_SETTINGS_MODULE = oidc_provider.tests.settings +python_files = test_*.py +flake8-max-line-length = 100 +flake8-ignore = + .git ALL + __pycache__ ALL + .ropeproject ALL + migrations/* ALL