-
Notifications
You must be signed in to change notification settings - Fork 838
Backchannel Logout #1573
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
base: master
Are you sure you want to change the base?
Backchannel Logout #1573
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,110 @@ | ||||||
| import json | ||||||
| import logging | ||||||
| from datetime import timedelta | ||||||
|
|
||||||
| import requests | ||||||
| from django.contrib.auth.signals import user_logged_out | ||||||
| from django.dispatch import receiver | ||||||
| from django.utils import timezone | ||||||
| from jwcrypto import jwt | ||||||
|
|
||||||
| from .exceptions import BackchannelLogoutRequestError | ||||||
| from .models import AbstractApplication, get_id_token_model | ||||||
| from .settings import oauth2_settings | ||||||
|
|
||||||
|
|
||||||
| IDToken = get_id_token_model() | ||||||
|
|
||||||
| logger = logging.getLogger(__name__) | ||||||
|
|
||||||
| BACKCHANNEL_LOGOUT_TIMEOUT = getattr(oauth2_settings, "OIDC_BACKCHANNEL_LOGOUT_TIMEOUT", 5) | ||||||
|
|
||||||
|
Comment on lines
+18
to
+21
|
||||||
|
|
||||||
| def send_backchannel_logout_request(id_token, *args, **kwargs): | ||||||
| """ | ||||||
| Send a logout token to the applications backchannel logout uri | ||||||
| """ | ||||||
|
|
||||||
| ttl = kwargs.get("ttl") or timedelta(minutes=10) | ||||||
|
|
||||||
| if not oauth2_settings.OIDC_BACKCHANNEL_LOGOUT_ENABLED: | ||||||
| raise BackchannelLogoutRequestError("Backchannel logout not enabled") | ||||||
|
|
||||||
| if id_token.application.algorithm == AbstractApplication.NO_ALGORITHM: | ||||||
| raise BackchannelLogoutRequestError("Application must provide signing algorithm") | ||||||
|
|
||||||
| if not id_token.application.backchannel_logout_uri: | ||||||
| raise BackchannelLogoutRequestError("URL for backchannel logout not provided by client") | ||||||
|
|
||||||
| if not oauth2_settings.OIDC_ISS_ENDPOINT: | ||||||
| raise BackchannelLogoutRequestError("OIDC_ISS_ENDPOINT is not set") | ||||||
|
|
||||||
| try: | ||||||
| issued_at = timezone.now() | ||||||
| expiration_date = issued_at + ttl | ||||||
|
|
||||||
| claims = { | ||||||
| "iss": oauth2_settings.OIDC_ISS_ENDPOINT, | ||||||
| "sub": str(id_token.user.pk), | ||||||
| "aud": str(id_token.application.client_id), | ||||||
| "iat": int(issued_at.timestamp()), | ||||||
| "exp": int(expiration_date.timestamp()), | ||||||
| "jti": id_token.jti, | ||||||
| "events": {"http://schemas.openid.net/event/backchannel-logout": {}}, | ||||||
|
Comment on lines
+46
to
+53
|
||||||
| } | ||||||
lullis marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
|
|
||||||
| # Standard JWT header | ||||||
| header = {"typ": "logout+jwt", "alg": id_token.application.algorithm} | ||||||
|
|
||||||
| # RS256 consumers expect a kid in the header for verifying the token | ||||||
| if id_token.application.algorithm == AbstractApplication.RS256_ALGORITHM: | ||||||
| header["kid"] = id_token.application.jwk_key.thumbprint() | ||||||
|
|
||||||
| token = jwt.JWT( | ||||||
| header=json.dumps(header, default=str), | ||||||
| claims=json.dumps(claims, default=str), | ||||||
| ) | ||||||
|
|
||||||
| token.make_signed_token(id_token.application.jwk_key) | ||||||
|
|
||||||
| headers = {"Content-Type": "application/x-www-form-urlencoded"} | ||||||
| data = {"logout_token": token.serialize()} | ||||||
| response = requests.post( | ||||||
| id_token.application.backchannel_logout_uri, | ||||||
| headers=headers, | ||||||
| data=data, | ||||||
| timeout=BACKCHANNEL_LOGOUT_TIMEOUT, | ||||||
| ) | ||||||
| response.raise_for_status() | ||||||
lullis marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
| except requests.RequestException as exc: | ||||||
| raise BackchannelLogoutRequestError(str(exc)) | ||||||
|
||||||
| raise BackchannelLogoutRequestError(str(exc)) | |
| raise BackchannelLogoutRequestError(str(exc)) from exc |
lullis marked this conversation as resolved.
Show resolved
Hide resolved
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| # Generated by Django 5.2 on 2025-06-06 12:42 | ||
|
|
||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
| dependencies = [ | ||
| ("oauth2_provider", "0014_alter_help_text"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AddField( | ||
| model_name="application", | ||
| name="backchannel_logout_uri", | ||
| field=models.URLField( | ||
| blank=True, help_text="Backchannel Logout URI where logout tokens will be sent", null=True | ||
| ), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -79,6 +79,7 @@ class AbstractApplication(models.Model): | |||||||||||
| * :attr:`client_secret` Confidential secret issued to the client during | ||||||||||||
| the registration process as described in :rfc:`2.2` | ||||||||||||
| * :attr:`name` Friendly name for the Application | ||||||||||||
| * :attr:`backchannel_logout_uri` Backchannel Logout URI (OIDC-only) | ||||||||||||
| """ | ||||||||||||
|
|
||||||||||||
| CLIENT_CONFIDENTIAL = "confidential" | ||||||||||||
|
|
@@ -152,6 +153,9 @@ class AbstractApplication(models.Model): | |||||||||||
| help_text=_("Allowed origins list to enable CORS, space separated"), | ||||||||||||
| default="", | ||||||||||||
| ) | ||||||||||||
| backchannel_logout_uri = models.URLField( | ||||||||||||
| blank=True, null=True, help_text=_("Backchannel Logout URI where logout tokens will be sent") | ||||||||||||
|
||||||||||||
| blank=True, null=True, help_text=_("Backchannel Logout URI where logout tokens will be sent") | |
| blank=True, | |
| null=True, | |
| help_text=_("Backchannel Logout URI where logout tokens will be sent"), | |
| validators=[AllowedURIValidator(oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES)], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should validate to the spec, but not with oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES as they're for redirect uris. Also keep in mind that the spec says
backchannel_logout_uri
OPTIONAL. RP URL that will cause the RP to log itself out when
sent a Logout Token by the OP. This URL SHOULD use the "https"
scheme and MAY contain port, path, and query parameter components;
however, it MAY use the "http" scheme, provided that the Client
Type is "confidential", as defined in Section 2.1 of OAuth 2.0
[RFC6749], and provided the OP allows the use of "http" RP URIs.
and we are permissive with http for non-confidential clients on localhost for development and testing scenarios where certs aren't always easy to use. see: docs/settings.rst ALLOWED_SCHEMES for some examples of the language we've used when allowing this in the past and the implementations for how we warn and communicate about these risks at run time.
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -45,6 +45,11 @@ <h3 class="block-center-heading">{{ application.name }}</h3> | |||||
| <p><b>{% trans "Allowed Origins" %}</b></p> | ||||||
| <textarea class="input-block-level" readonly>{{ application.allowed_origins }}</textarea> | ||||||
| </li> | ||||||
|
|
||||||
| <li> | ||||||
| <p><b>{% trans "Backchannel Logout URI" %}</b></p> | ||||||
| <input class="input-block-level" type="text" value="{{ application.backchannel_logout_uri }}" readonly> | ||||||
|
||||||
| <input class="input-block-level" type="text" value="{{ application.backchannel_logout_uri }}" readonly> | |
| <input class="input-block-level" type="text" value="{{ application.backchannel_logout_uri|default_if_none:"" }}" readonly> |
Uh oh!
There was an error while loading. Please reload this page.