Merge branch 'develop' of github.com:juanifioren/django-oidc-provider

* 'develop' of github.com:juanifioren/django-oidc-provider:
  Update changelog.rst
  include request in password grant authenticate call
  Update setup.py
  Update changelog.rst
  Update changelog.rst
  Adjust import order and method order in introspection tests
  Replace resource with client in docs.
  Update settings docs to add extra introspection setting
  Update README.md
  Update README.md
  Remove the Resource model
  Skip csrf protection on introspection endpoint
  Add token introspection endpoint to satisfy https://tools.ietf.org/html/rfc7662
  Test docs with tox.
  Remove Django 1.7 for travis.
  Drop support for Django 1.7.
  Move extract_client_auth to oauth2 utils.
  Remove duplicate link in docs.
  Bump version v0.6.0.
  Fix BaseCodeTokenModel and user attr.
  Update README.md
  Edit README and contribute doc.
  Edit changelog.
  Update changelog.rst
  Add protected_resource_view test using client_credentials.
  Fix docs.
  Improve docs.
  Client credentials implementation.
  Move changelog into docs.
  Update README.md
  Update CHANGELOG.md
  Fixed infinite callback loop in check-session iframe
  Fix PEP8. New migration.
  Update example project.
  Fix PEP8.
  Fix PEP8.
  PEP8 errors and urls.
  PEP8 models.
  Fix contribute docs.
  Fix tox for checking PEP8 all files.
  Update README.md
  Update README.md
  Simplify test suit.
  Update CHANGELOG.md
  Bump version 0.5.3.
  Update installation.rst
  Update CHANGELOG.md
  Fixed wrong Object in Template
  Update project to support Django 2.0
  Now passing along the token to create_id_token function.
  Made token and token_refresh endpoint return requested claims.
  Sphinx documentation fixes (#219)
  Use request.user.is_authenticated as a bool with recent Django (#216)
  Fixed client id retrieval when aud is a list of str. (#210)
  Add owner field to Client (#211)
  Update CHANGELOG
  removed tab char
  Add pep8 compliance and checker
  Bump version
  Update CHANGELOG.md
  Preparing v0.5.2 (#201)
  Fix Django 2.0 deprecation warnings (#185)
  Fix infinite login loop if "prompt=login" (#198)
  fixed typos
  Bump version
  Fix scope handling of token endpoint (#193)
  Fixes #192
  Use stored user consent for public clients too (#189)
  Redirect URIs must match exactly. (#191)
  Bug #187 prompt handling (#188)
  Don't pin exact versions in install_requires.
This commit is contained in:
Tuomas Suutari 2018-05-24 00:16:26 +03:00
commit 93420461b4
81 changed files with 2042 additions and 1014 deletions

1
.gitignore vendored
View file

@ -12,3 +12,4 @@ src/
docs/_build/
.eggs/
.python-version
.pytest_cache/

View file

@ -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:

View file

@ -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

View file

@ -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)

View file

@ -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
# "<project> v<release> 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 <link> 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

View file

@ -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

View file

@ -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" \

337
docs/sections/changelog.rst Normal file
View file

@ -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*

View file

@ -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 <https://pypi.python.org/pypi/tox>`_ for running tests in each of the environments, also to run coverage among::
Use `tox <https://pypi.python.org/pypi/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 <https://travis-ci.org/juanifioren/django-oidc-provider/>`_ for this.
We also use `travis <https://travis-ci.org/juanifioren/django-oidc-provider/>`_ to automatically test every commit to the project.
Improve Documentation
=====================
@ -34,4 +37,4 @@ We use `Sphinx <http://www.sphinx-doc.org/>`_ 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.

View file

@ -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**

View file

@ -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 <https://github.com/juanifioren/django-oidc-provider/tree/v0.5.x/example_project>`_.
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/'

View file

@ -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"
}

View file

@ -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 <http://tools.ietf.org/html/rfc6749#section-2>`_
`Read more about client creation in the OAuth2 spec <http://tools.ietf.org/html/rfc6749#section-2>`_

View file

@ -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::

View file

@ -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 <https://docs.djangoproject.com/en/1.7/ref/settings/#login-url>`_
OPTIONAL. ``str``. Used to log the user in. By default Django's ``LOGIN_URL`` will be used. `Read more in the Django docs <https://docs.djangoproject.com/en/1.11/ref/settings/#login-url>`_
``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.

View file

@ -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 }}
<ul>
{% for scope in params.scope %}
{% for scope in scopes %}
<li><strong>{{ scope.name }}</strong><br><i>{{ scope.description }}</i></li>
{% endfor %}
</ul>
@ -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'
}

View file

@ -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')
[<UserConsent: Example Client - some@email.com>]
Note: the ``UserConsent`` model is not included in the admin.
Properties
==========

View file

@ -1,4 +1,4 @@
FROM python:2-onbuild
FROM python:3-onbuild
RUN [ "python", "manage.py", "migrate" ]
RUN [ "python", "manage.py", "creatersakey" ]

View file

@ -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 .
```

View file

@ -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

View file

@ -3,7 +3,7 @@
{% block content %}
<section class="text-xs-center">
<section>
<div class="container">
<h1 class="jumbotron-heading">{% trans 'Welcome' %}{% if user.is_authenticated %} {{ user.username }}{% endif %}!</h1>
<p class="lead text-muted">{% trans 'This is an example of an OpenID Connect 1.0 Provider. Built with the <a href="https://www.djangoproject.com/" target="_BLANK"><u>Django Framework</u></a> and <a href="https://github.com/juanifioren/django-oidc-provider" target="_BLANK"><u>django-oidc-provider</u></a> package.' %}</p>

16
example/app/urls.py Normal file
View file

@ -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),
]

View file

@ -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()

View file

@ -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

2
example/requirements.txt Normal file
View file

@ -0,0 +1,2 @@
django
https://github.com/juanifioren/django-oidc-provider/archive/master.zip

View file

@ -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)),
]

View file

@ -1,2 +0,0 @@
django==1.10
https://github.com/juanifioren/django-oidc-provider/archive/v0.4.x.zip

View file

@ -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)

5
oidc_provider/compat.py Normal file
View file

@ -0,0 +1,5 @@
def get_attr_or_callable(obj, name):
target = getattr(obj, name)
if callable(target):
return target()
return target

View file

@ -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': {

View file

@ -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']:

View file

@ -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

View file

@ -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):
"""

View file

@ -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 = {

View file

@ -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

View file

@ -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)

View file

@ -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.

View file

@ -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

View file

@ -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,
),
]

View file

@ -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,

View file

@ -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):

View file

@ -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(

View file

@ -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='<b>Confidential</b> clients are capable of maintaining the confidentiality of their credentials. <b>Public</b> clients are incapable.', max_length=30),
field=models.CharField(
choices=[(b'confidential', b'Confidential'), (b'public', b'Public')],
default=b'confidential',
help_text='<b>Confidential</b> clients are capable of maintaining the confidentiality of their '
'credentials. <b>Public</b> clients are incapable.',
max_length=30),
),
]

View file

@ -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'),
),
]

View file

@ -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='<b>Confidential</b> clients are capable of maintaining the confidentiality of their credentials. <b>Public</b> clients are incapable.', max_length=30),
field=models.CharField(
choices=[('confidential', 'Confidential'), ('public', 'Public')],
default='confidential',
help_text='<b>Confidential</b> clients are capable of maintaining the confidentiality of their'
' credentials. <b>Public</b> 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',

View file

@ -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='<b>Confidential</b> clients are capable of maintaining the confidentiality of their credentials. <b>Public</b> 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='<b>Confidential</b> clients are capable of maintaining the confidentiality of their '
'credentials. <b>Public</b> 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'),
),
]

View file

@ -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='<b>Confidential</b> clients are capable of maintaining the confidentiality of their credentials. <b>Public</b> clients are incapable.', max_length=30, verbose_name='Client Type'),
field=models.CharField(
choices=[('confidential', 'Confidential'), ('public', 'Public')],
default='confidential',
help_text='<b>Confidential</b> clients are capable of maintaining the confidentiality of their '
'credentials. <b>Public</b> 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',

View file

@ -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'),
),
]

View file

@ -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'),
),
]

View file

@ -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?'),
),
]

View file

@ -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'),
),
]

View file

@ -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?'),
),
]

View file

@ -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'),
),
]

View file

@ -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'<b>Confidential</b> clients are capable of maintaining the confidentiality of their credentials. <b>Public</b> 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'<b>Confidential</b> clients are capable of maintaining the confidentiality'
u' of their credentials. <b>Public</b> 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')

View file

@ -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()

View file

@ -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];

View file

@ -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),
]

View file

@ -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)

View file

@ -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):

View file

@ -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')

View file

@ -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
})

View file

@ -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')

View file

@ -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

View file

@ -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'])

View file

@ -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.')

View file

@ -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)

View file

@ -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'

View file

@ -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'),
]

View file

@ -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})

View file

@ -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:])

View file

@ -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',
],
)

51
tox.ini
View file

@ -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