Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement OIDC RP-Initiated Logout #1244

Merged
merged 18 commits into from
May 12, 2023
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ Jordi Sanchez
Joseph Abrahams
Josh Thomas
Jozef Knaperek
Julien Palard
Julian Mundhahs
Julien Palard
Jun Zhou
Kaleb Porter
Kristian Rune Larsen
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added
* Add Japanese(日本語) Language Support
* [OIDC RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html)

### Changed
* #1211 documentation improve on 'AUTHORIZATION_CODE_EXPIRE_SECONDS'.
Expand Down
1 change: 1 addition & 0 deletions docs/advanced_topics.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ logo, acceptance of some user agreement and so on.
* :attr:`client_id` The client identifier issued to the client during the registration process as described in :rfc:`2.2`
* :attr:`user` ref to a Django user
* :attr:`redirect_uris` The list of allowed redirect uri. The string consists of valid URLs separated by space
* :attr:`post_logout_redirect_uris` The list of allowed redirect uris after an RP initiated logout. The string consists of valid URLs separated by space
* :attr:`client_type` Client type as described in :rfc:`2.1`
* :attr:`authorization_grant_type` Authorization flows available to the Application
* :attr:`client_secret` Confidential secret issued to the client during the registration process as described in :rfc:`2.2`
Expand Down
4 changes: 4 additions & 0 deletions docs/management_commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ The ``createapplication`` management command provides a shortcut to create a new

usage: manage.py createapplication [-h] [--client-id CLIENT_ID] [--user USER]
[--redirect-uris REDIRECT_URIS]
[--post-logout-redirect-uris POST_LOGOUT_REDIRECT_URIS]
[--client-secret CLIENT_SECRET]
[--name NAME] [--skip-authorization]
[--algorithm ALGORITHM] [--version]
Expand All @@ -64,6 +65,9 @@ The ``createapplication`` management command provides a shortcut to create a new
--redirect-uris REDIRECT_URIS
The redirect URIs, this must be a space separated
string e.g 'URI1 URI2'
--post-logout-redirect-uris POST_LOGOUT_REDIRECT_URIS
The post logout redirect URIs, this must be a space
separated string e.g 'URI1 URI2'
--client-secret CLIENT_SECRET
The secret for this application
--name NAME The name this application
Expand Down
26 changes: 26 additions & 0 deletions docs/oidc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ We support:
* OpenID Connect Implicit Flow
* OpenID Connect Hybrid Flow

Furthermore ``django-oauth-toolkit`` also supports `OpenID Connect RP-Initiated Logout <https://openid.net/specs/openid-connect-rpinitiated-1_0.html>`_.


Configuration
=============
Expand Down Expand Up @@ -147,6 +149,23 @@ scopes in your ``settings.py``::
If you want to enable ``RS256`` at a later date, you can do so - just add
the private key as described above.


RP-Initiated Logout
~~~~~~~~~~~~~~~~~~~
This feature has to be enabled separately as it is an extension to the core standard.

.. code-block:: python

OAUTH2_PROVIDER = {
# OIDC has to be enabled to use RP-Initiated Logout
"OIDC_ENABLED": True,
# Enable and configure RP-Initiated Logout
"OIDC_RP_INITIATED_LOGOUT_ENABLED": True,
"OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT": True,
# ... any other settings you want
}


Setting up OIDC enabled clients
===============================

Expand Down Expand Up @@ -403,3 +422,10 @@ UserInfoView

Available at ``/o/userinfo/``, this view provides extra user details. You can
customize the details included in the response as described above.


RPInitiatedLogoutView
~~~~~~~~~~~~~~~~~~~~~

Available at ``/o/rp-initiated-logout/``, this view allows a :term:`Client` (Relying Party) to request that a :term:`Resource Owner`
is logged out at the :term:`Authorization Server` (OpenID Provider).
35 changes: 35 additions & 0 deletions docs/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,41 @@ this you must also provide the service at that endpoint.
If unset, the default location is used, eg if ``django-oauth-toolkit`` is
mounted at ``/o/``, it will be ``<server-address>/o/userinfo/``.

OIDC_RP_INITIATED_LOGOUT_ENABLED
~~~~~~~~~~~~~~~~~~~~~~~~
Default: ``False``

When is set to `False` (default) the `OpenID Connect RP-Initiated Logout <https://openid.net/specs/openid-connect-rpinitiated-1_0.html>`_
endpoint is not enabled. OpenID Connect RP-Initiated Logout enables an :term:`Client` (Relying Party)
to request that a :term:`Resource Owner` (End User) is logged out at the :term:`Authorization Server` (OpenID Provider).

OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Default: ``True``

Whether to always prompt the :term:`Resource Owner` (End User) to confirm a logout requested by a
:term:`Client` (Relying Party). If it is disabled the :term:`Resource Owner` (End User) will only be prompted if required by the standard.

OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Default: ``False``

Enable to only allow the `http` scheme in post logout redirect URIs when a :term:`Client` is `confidential`.
Qup42 marked this conversation as resolved.
Show resolved Hide resolved

OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Default: ``True``

Whether expired ID tokens are accepted for RP-Initiated Logout. The Tokens must still be signed by the OP and otherwise valid.

OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Default: ``True``

Whether to delete the access, refresh and ID tokens of the user that is being logged out.
The types of applications for which tokens are deleted can be customized with `RPInitiatedLogoutView.token_types_to_delete`.
The default is to delete the tokens of all applications if this flag is enabled.

OIDC_ISS_ENDPOINT
~~~~~~~~~~~~~~~~~
Default: ``""``
Expand Down
46 changes: 46 additions & 0 deletions oauth2_provider/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,49 @@ class FatalClientError(OAuthToolkitError):
"""

pass


class OIDCError(Exception):
"""
General class to derive from for all OIDC related errors.
"""

status_code = 400
error = None

def __init__(self, description=None):
if description is not None:
self.description = description

message = "({}) {}".format(self.error, self.description)
super().__init__(message)


class InvalidRequestFatalError(OIDCError):
"""
For fatal errors. These are requests with invalid parameter values, missing parameters or otherwise
incorrect requests.
"""

error = "invalid_request"


class ClientIdMissmatch(InvalidRequestFatalError):
description = "Mismatch between the Client ID of the ID Token and the Client ID that was provided."


class InvalidOIDCClientError(InvalidRequestFatalError):
description = "The client is unknown or no client has been included."


class InvalidOIDCRedirectURIError(InvalidRequestFatalError):
description = "Invalid post logout redirect URI."


class InvalidIDTokenError(InvalidRequestFatalError):
description = "The ID Token is expired, revoked, malformed, or otherwise invalid."


class LogoutDenied(OIDCError):
error = "logout_denied"
description = "Logout has been refused by the user."
14 changes: 14 additions & 0 deletions oauth2_provider/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,17 @@ class AllowForm(forms.Form):
code_challenge = forms.CharField(required=False, widget=forms.HiddenInput())
code_challenge_method = forms.CharField(required=False, widget=forms.HiddenInput())
claims = forms.CharField(required=False, widget=forms.HiddenInput())


class ConfirmLogoutForm(forms.Form):
allow = forms.BooleanField(required=False)
id_token_hint = forms.CharField(required=False, widget=forms.HiddenInput())
logout_hint = forms.CharField(required=False, widget=forms.HiddenInput())
client_id = forms.CharField(required=False, widget=forms.HiddenInput())
post_logout_redirect_uri = forms.CharField(required=False, widget=forms.HiddenInput())
state = forms.CharField(required=False, widget=forms.HiddenInput())
ui_locales = forms.CharField(required=False, widget=forms.HiddenInput())

def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request", None)
super(ConfirmLogoutForm, self).__init__(*args, **kwargs)
6 changes: 6 additions & 0 deletions oauth2_provider/management/commands/createapplication.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ def add_arguments(self, parser):
type=str,
help="The redirect URIs, this must be a space separated string e.g 'URI1 URI2'",
)
parser.add_argument(
"--post-logout-redirect-uris",
type=str,
help="The post logout redirect URIs, this must be a space separated string e.g 'URI1 URI2'",
default="",
)
parser.add_argument(
"--client-secret",
type=str,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-01-14 12:32

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("oauth2_provider", "0006_alter_application_client_secret"),
]

operations = [
migrations.AddField(
model_name="application",
name="post_logout_redirect_uris",
field=models.TextField(blank=True, help_text="Allowed Post Logout URIs list, space separated"),
),
]
15 changes: 15 additions & 0 deletions oauth2_provider/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ class AbstractApplication(models.Model):
* :attr:`user` ref to a Django user
* :attr:`redirect_uris` The list of allowed redirect uri. The string
consists of valid URLs separated by space
* :attr:`post_logout_redirect_uris` The list of allowed redirect uris after
an RP initiated logout. The string
consists of valid URLs separated by space
* :attr:`client_type` Client type as described in :rfc:`2.1`
* :attr:`authorization_grant_type` Authorization flows available to the
Application
Expand Down Expand Up @@ -103,6 +106,10 @@ class AbstractApplication(models.Model):
blank=True,
help_text=_("Allowed URIs list, space separated"),
)
post_logout_redirect_uris = models.TextField(
blank=True,
help_text=_("Allowed Post Logout URIs list, space separated"),
)
client_type = models.CharField(max_length=32, choices=CLIENT_TYPES)
authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES)
client_secret = ClientSecretField(
Expand Down Expand Up @@ -150,6 +157,14 @@ def redirect_uri_allowed(self, uri):
"""
return redirect_to_uri_allowed(uri, self.redirect_uris.split())

def post_logout_redirect_uri_allowed(self, uri):
"""
Checks if given URI is one of the items in :attr:`post_logout_redirect_uris` string

:param uri: URI to check
"""
return redirect_to_uri_allowed(uri, self.post_logout_redirect_uris.split())

def clean(self):
from django.core.exceptions import ValidationError

Expand Down
5 changes: 5 additions & 0 deletions oauth2_provider/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@
"client_secret_post",
"client_secret_basic",
],
"OIDC_RP_INITIATED_LOGOUT_ENABLED": False,
"OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT": True,
"OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS": False,
"OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS": True,
"OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS": True,
# Special settings that will be evaluated at runtime
"_SCOPES": [],
"_DEFAULT_SCOPES": [],
Expand Down
37 changes: 37 additions & 0 deletions oauth2_provider/templates/oauth2_provider/logout_confirm.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{% extends "oauth2_provider/base.html" %}

{% load i18n %}
{% block content %}
<div class="block-center">
{% if not error %}
<form id="authorizationForm" method="post">
{% if application %}
<h3 class="block-center-heading">Confirm Logout requested by {{ application.name }}</h3>
{% else %}
<h3 class="block-center-heading">Confirm Logout</h3>
{% endif %}
{% csrf_token %}

{% for field in form %}
{% if field.is_hidden %}
{{ field }}
{% endif %}
{% endfor %}

{{ form.errors }}
{{ form.non_field_errors }}

<div class="control-group">
<div class="controls">
<input type="submit" class="btn btn-large" value="Cancel"/>
<input type="submit" class="btn btn-large btn-primary" name="allow" value="Logout"/>
</div>
</div>
</form>

{% else %}
<h2>Error: {{ error.error }}</h2>
<p>{{ error.description }}</p>
{% endif %}
</div>
{% endblock %}
1 change: 1 addition & 0 deletions oauth2_provider/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
),
re_path(r"^\.well-known/jwks.json$", views.JwksInfoView.as_view(), name="jwks-info"),
re_path(r"^userinfo/$", views.UserInfoView.as_view(), name="user-info"),
re_path(r"^logout/$", views.RPInitiatedLogoutView.as_view(), name="rp-initiated-logout"),
]


Expand Down
2 changes: 1 addition & 1 deletion oauth2_provider/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@
ScopedProtectedResourceView,
)
from .introspect import IntrospectTokenView
from .oidc import ConnectDiscoveryInfoView, JwksInfoView, UserInfoView
from .oidc import ConnectDiscoveryInfoView, JwksInfoView, RPInitiatedLogoutView, UserInfoView
from .token import AuthorizedTokenDeleteView, AuthorizedTokensListView
24 changes: 24 additions & 0 deletions oauth2_provider/views/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,3 +326,27 @@ def dispatch(self, *args, **kwargs):
log.warning(self.debug_error_message)
return HttpResponseNotFound()
return super().dispatch(*args, **kwargs)


class OIDCLogoutOnlyMixin(OIDCOnlyMixin):
"""
Mixin for views that should only be accessible when OIDC and OIDC RP-Initiated Logout are enabled.

If either is not enabled:

* if DEBUG is True, raises an ImproperlyConfigured exception explaining why
* otherwise, returns a 404 response, logging the same warning
"""

debug_error_message = (
"The django-oauth-toolkit OIDC RP-Initiated Logout view is not enabled unless you "
"have configured OIDC_RP_INITIATED_LOGOUT_ENABLED in the settings"
)

def dispatch(self, *args, **kwargs):
if not oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED:
if settings.DEBUG:
raise ImproperlyConfigured(self.debug_error_message)
log.warning(self.debug_error_message)
return HttpResponseNotFound()
return super().dispatch(*args, **kwargs)
Loading