Skip to content

Commit 11294ab

Browse files
Implement OIDC RP-Initiated Logout (#1244)
Implement OIDC RP-Initiated Logout see: https://openid.net/specs/openid-connect-rpinitiated-1_0.html --------- Co-authored-by: Julian Mundhahs <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com.>
1 parent 25f6de5 commit 11294ab

22 files changed

+1116
-49
lines changed

AUTHORS

+1-1
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ Jordi Sanchez
5959
Joseph Abrahams
6060
Josh Thomas
6161
Jozef Knaperek
62-
Julien Palard
6362
Julian Mundhahs
63+
Julien Palard
6464
Jun Zhou
6565
Kaleb Porter
6666
Kristian Rune Larsen

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818

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

2223
### Changed
2324
* #1211 documentation improve on 'AUTHORIZATION_CODE_EXPIRE_SECONDS'.

docs/advanced_topics.rst

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ logo, acceptance of some user agreement and so on.
2020
* :attr:`client_id` The client identifier issued to the client during the registration process as described in :rfc:`2.2`
2121
* :attr:`user` ref to a Django user
2222
* :attr:`redirect_uris` The list of allowed redirect uri. The string consists of valid URLs separated by space
23+
* :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
2324
* :attr:`client_type` Client type as described in :rfc:`2.1`
2425
* :attr:`authorization_grant_type` Authorization flows available to the Application
2526
* :attr:`client_secret` Confidential secret issued to the client during the registration process as described in :rfc:`2.2`

docs/management_commands.rst

+4
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ The ``createapplication`` management command provides a shortcut to create a new
3838
3939
usage: manage.py createapplication [-h] [--client-id CLIENT_ID] [--user USER]
4040
[--redirect-uris REDIRECT_URIS]
41+
[--post-logout-redirect-uris POST_LOGOUT_REDIRECT_URIS]
4142
[--client-secret CLIENT_SECRET]
4243
[--name NAME] [--skip-authorization]
4344
[--algorithm ALGORITHM] [--version]
@@ -64,6 +65,9 @@ The ``createapplication`` management command provides a shortcut to create a new
6465
--redirect-uris REDIRECT_URIS
6566
The redirect URIs, this must be a space separated
6667
string e.g 'URI1 URI2'
68+
--post-logout-redirect-uris POST_LOGOUT_REDIRECT_URIS
69+
The post logout redirect URIs, this must be a space
70+
separated string e.g 'URI1 URI2'
6771
--client-secret CLIENT_SECRET
6872
The secret for this application
6973
--name NAME The name this application

docs/oidc.rst

+26
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ We support:
2323
* OpenID Connect Implicit Flow
2424
* OpenID Connect Hybrid Flow
2525

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

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

152+
153+
RP-Initiated Logout
154+
~~~~~~~~~~~~~~~~~~~
155+
This feature has to be enabled separately as it is an extension to the core standard.
156+
157+
.. code-block:: python
158+
159+
OAUTH2_PROVIDER = {
160+
# OIDC has to be enabled to use RP-Initiated Logout
161+
"OIDC_ENABLED": True,
162+
# Enable and configure RP-Initiated Logout
163+
"OIDC_RP_INITIATED_LOGOUT_ENABLED": True,
164+
"OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT": True,
165+
# ... any other settings you want
166+
}
167+
168+
150169
Setting up OIDC enabled clients
151170
===============================
152171

@@ -403,3 +422,10 @@ UserInfoView
403422

404423
Available at ``/o/userinfo/``, this view provides extra user details. You can
405424
customize the details included in the response as described above.
425+
426+
427+
RPInitiatedLogoutView
428+
~~~~~~~~~~~~~~~~~~~~~
429+
430+
Available at ``/o/rp-initiated-logout/``, this view allows a :term:`Client` (Relying Party) to request that a :term:`Resource Owner`
431+
is logged out at the :term:`Authorization Server` (OpenID Provider).

docs/settings.rst

+35
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,41 @@ this you must also provide the service at that endpoint.
313313
If unset, the default location is used, eg if ``django-oauth-toolkit`` is
314314
mounted at ``/o/``, it will be ``<server-address>/o/userinfo/``.
315315

316+
OIDC_RP_INITIATED_LOGOUT_ENABLED
317+
~~~~~~~~~~~~~~~~~~~~~~~~
318+
Default: ``False``
319+
320+
When is set to `False` (default) the `OpenID Connect RP-Initiated Logout <https://openid.net/specs/openid-connect-rpinitiated-1_0.html>`_
321+
endpoint is not enabled. OpenID Connect RP-Initiated Logout enables an :term:`Client` (Relying Party)
322+
to request that a :term:`Resource Owner` (End User) is logged out at the :term:`Authorization Server` (OpenID Provider).
323+
324+
OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT
325+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
326+
Default: ``True``
327+
328+
Whether to always prompt the :term:`Resource Owner` (End User) to confirm a logout requested by a
329+
:term:`Client` (Relying Party). If it is disabled the :term:`Resource Owner` (End User) will only be prompted if required by the standard.
330+
331+
OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS
332+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
333+
Default: ``False``
334+
335+
Enable this setting to require `https` in post logout redirect URIs. `http` is only allowed when a :term:`Client` is `confidential`.
336+
337+
OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS
338+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
339+
Default: ``True``
340+
341+
Whether expired ID tokens are accepted for RP-Initiated Logout. The Tokens must still be signed by the OP and otherwise valid.
342+
343+
OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS
344+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
345+
Default: ``True``
346+
347+
Whether to delete the access, refresh and ID tokens of the user that is being logged out.
348+
The types of applications for which tokens are deleted can be customized with `RPInitiatedLogoutView.token_types_to_delete`.
349+
The default is to delete the tokens of all applications if this flag is enabled.
350+
316351
OIDC_ISS_ENDPOINT
317352
~~~~~~~~~~~~~~~~~
318353
Default: ``""``

oauth2_provider/exceptions.py

+46
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,49 @@ class FatalClientError(OAuthToolkitError):
1717
"""
1818

1919
pass
20+
21+
22+
class OIDCError(Exception):
23+
"""
24+
General class to derive from for all OIDC related errors.
25+
"""
26+
27+
status_code = 400
28+
error = None
29+
30+
def __init__(self, description=None):
31+
if description is not None:
32+
self.description = description
33+
34+
message = "({}) {}".format(self.error, self.description)
35+
super().__init__(message)
36+
37+
38+
class InvalidRequestFatalError(OIDCError):
39+
"""
40+
For fatal errors. These are requests with invalid parameter values, missing parameters or otherwise
41+
incorrect requests.
42+
"""
43+
44+
error = "invalid_request"
45+
46+
47+
class ClientIdMissmatch(InvalidRequestFatalError):
48+
description = "Mismatch between the Client ID of the ID Token and the Client ID that was provided."
49+
50+
51+
class InvalidOIDCClientError(InvalidRequestFatalError):
52+
description = "The client is unknown or no client has been included."
53+
54+
55+
class InvalidOIDCRedirectURIError(InvalidRequestFatalError):
56+
description = "Invalid post logout redirect URI."
57+
58+
59+
class InvalidIDTokenError(InvalidRequestFatalError):
60+
description = "The ID Token is expired, revoked, malformed, or otherwise invalid."
61+
62+
63+
class LogoutDenied(OIDCError):
64+
error = "logout_denied"
65+
description = "Logout has been refused by the user."

oauth2_provider/forms.py

+14
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,17 @@ class AllowForm(forms.Form):
1212
code_challenge = forms.CharField(required=False, widget=forms.HiddenInput())
1313
code_challenge_method = forms.CharField(required=False, widget=forms.HiddenInput())
1414
claims = forms.CharField(required=False, widget=forms.HiddenInput())
15+
16+
17+
class ConfirmLogoutForm(forms.Form):
18+
allow = forms.BooleanField(required=False)
19+
id_token_hint = forms.CharField(required=False, widget=forms.HiddenInput())
20+
logout_hint = forms.CharField(required=False, widget=forms.HiddenInput())
21+
client_id = forms.CharField(required=False, widget=forms.HiddenInput())
22+
post_logout_redirect_uri = forms.CharField(required=False, widget=forms.HiddenInput())
23+
state = forms.CharField(required=False, widget=forms.HiddenInput())
24+
ui_locales = forms.CharField(required=False, widget=forms.HiddenInput())
25+
26+
def __init__(self, *args, **kwargs):
27+
self.request = kwargs.pop("request", None)
28+
super(ConfirmLogoutForm, self).__init__(*args, **kwargs)

oauth2_provider/management/commands/createapplication.py

+6
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ def add_arguments(self, parser):
3737
type=str,
3838
help="The redirect URIs, this must be a space separated string e.g 'URI1 URI2'",
3939
)
40+
parser.add_argument(
41+
"--post-logout-redirect-uris",
42+
type=str,
43+
help="The post logout redirect URIs, this must be a space separated string e.g 'URI1 URI2'",
44+
default="",
45+
)
4046
parser.add_argument(
4147
"--client-secret",
4248
type=str,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.1.5 on 2023-01-14 12:32
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("oauth2_provider", "0006_alter_application_client_secret"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="application",
15+
name="post_logout_redirect_uris",
16+
field=models.TextField(blank=True, help_text="Allowed Post Logout URIs list, space separated"),
17+
),
18+
]

oauth2_provider/models.py

+15
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ class AbstractApplication(models.Model):
5252
* :attr:`user` ref to a Django user
5353
* :attr:`redirect_uris` The list of allowed redirect uri. The string
5454
consists of valid URLs separated by space
55+
* :attr:`post_logout_redirect_uris` The list of allowed redirect uris after
56+
an RP initiated logout. The string
57+
consists of valid URLs separated by space
5558
* :attr:`client_type` Client type as described in :rfc:`2.1`
5659
* :attr:`authorization_grant_type` Authorization flows available to the
5760
Application
@@ -103,6 +106,10 @@ class AbstractApplication(models.Model):
103106
blank=True,
104107
help_text=_("Allowed URIs list, space separated"),
105108
)
109+
post_logout_redirect_uris = models.TextField(
110+
blank=True,
111+
help_text=_("Allowed Post Logout URIs list, space separated"),
112+
)
106113
client_type = models.CharField(max_length=32, choices=CLIENT_TYPES)
107114
authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES)
108115
client_secret = ClientSecretField(
@@ -150,6 +157,14 @@ def redirect_uri_allowed(self, uri):
150157
"""
151158
return redirect_to_uri_allowed(uri, self.redirect_uris.split())
152159

160+
def post_logout_redirect_uri_allowed(self, uri):
161+
"""
162+
Checks if given URI is one of the items in :attr:`post_logout_redirect_uris` string
163+
164+
:param uri: URI to check
165+
"""
166+
return redirect_to_uri_allowed(uri, self.post_logout_redirect_uris.split())
167+
153168
def clean(self):
154169
from django.core.exceptions import ValidationError
155170

oauth2_provider/settings.py

+5
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@
8888
"client_secret_post",
8989
"client_secret_basic",
9090
],
91+
"OIDC_RP_INITIATED_LOGOUT_ENABLED": False,
92+
"OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT": True,
93+
"OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS": False,
94+
"OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS": True,
95+
"OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS": True,
9196
# Special settings that will be evaluated at runtime
9297
"_SCOPES": [],
9398
"_DEFAULT_SCOPES": [],
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{% extends "oauth2_provider/base.html" %}
2+
3+
{% load i18n %}
4+
{% block content %}
5+
<div class="block-center">
6+
{% if not error %}
7+
<form id="authorizationForm" method="post">
8+
{% if application %}
9+
<h3 class="block-center-heading">Confirm Logout requested by {{ application.name }}</h3>
10+
{% else %}
11+
<h3 class="block-center-heading">Confirm Logout</h3>
12+
{% endif %}
13+
{% csrf_token %}
14+
15+
{% for field in form %}
16+
{% if field.is_hidden %}
17+
{{ field }}
18+
{% endif %}
19+
{% endfor %}
20+
21+
{{ form.errors }}
22+
{{ form.non_field_errors }}
23+
24+
<div class="control-group">
25+
<div class="controls">
26+
<input type="submit" class="btn btn-large" value="Cancel"/>
27+
<input type="submit" class="btn btn-large btn-primary" name="allow" value="Logout"/>
28+
</div>
29+
</div>
30+
</form>
31+
32+
{% else %}
33+
<h2>Error: {{ error.error }}</h2>
34+
<p>{{ error.description }}</p>
35+
{% endif %}
36+
</div>
37+
{% endblock %}

oauth2_provider/urls.py

+1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
),
3939
re_path(r"^\.well-known/jwks.json$", views.JwksInfoView.as_view(), name="jwks-info"),
4040
re_path(r"^userinfo/$", views.UserInfoView.as_view(), name="user-info"),
41+
re_path(r"^logout/$", views.RPInitiatedLogoutView.as_view(), name="rp-initiated-logout"),
4142
]
4243

4344

oauth2_provider/views/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,5 @@
1515
ScopedProtectedResourceView,
1616
)
1717
from .introspect import IntrospectTokenView
18-
from .oidc import ConnectDiscoveryInfoView, JwksInfoView, UserInfoView
18+
from .oidc import ConnectDiscoveryInfoView, JwksInfoView, RPInitiatedLogoutView, UserInfoView
1919
from .token import AuthorizedTokenDeleteView, AuthorizedTokensListView

oauth2_provider/views/mixins.py

+24
Original file line numberDiff line numberDiff line change
@@ -326,3 +326,27 @@ def dispatch(self, *args, **kwargs):
326326
log.warning(self.debug_error_message)
327327
return HttpResponseNotFound()
328328
return super().dispatch(*args, **kwargs)
329+
330+
331+
class OIDCLogoutOnlyMixin(OIDCOnlyMixin):
332+
"""
333+
Mixin for views that should only be accessible when OIDC and OIDC RP-Initiated Logout are enabled.
334+
335+
If either is not enabled:
336+
337+
* if DEBUG is True, raises an ImproperlyConfigured exception explaining why
338+
* otherwise, returns a 404 response, logging the same warning
339+
"""
340+
341+
debug_error_message = (
342+
"The django-oauth-toolkit OIDC RP-Initiated Logout view is not enabled unless you "
343+
"have configured OIDC_RP_INITIATED_LOGOUT_ENABLED in the settings"
344+
)
345+
346+
def dispatch(self, *args, **kwargs):
347+
if not oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED:
348+
if settings.DEBUG:
349+
raise ImproperlyConfigured(self.debug_error_message)
350+
log.warning(self.debug_error_message)
351+
return HttpResponseNotFound()
352+
return super().dispatch(*args, **kwargs)

0 commit comments

Comments
 (0)