From 95ec09fe547f78692d951784b48362cf9189dc76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Fri, 20 May 2022 14:07:56 +0200 Subject: [PATCH] Implemented RP-initiated logout --- CHANGES.rst | 1 + .../ldap_backend/schemas/oauth2-openldap.ldif | 11 +- .../schemas/oauth2-openldap.schema | 12 +- canaille/oidc/clients.py | 12 + canaille/oidc/forms.py | 6 + canaille/oidc/models.py | 1 + canaille/oidc/oauth.py | 136 ++++++- canaille/templates/oidc/user/logout.html | 42 +++ .../translations/fr/LC_MESSAGES/messages.po | 118 ++++--- canaille/translations/messages.pot | 97 +++-- demo/client/__init__.py | 33 +- demo/ldif/bootstrap-data.ldif | 2 + tests/oidc/conftest.py | 15 + tests/oidc/test_client_admin.py | 2 + tests/oidc/test_end_session.py | 331 ++++++++++++++++++ 15 files changed, 743 insertions(+), 76 deletions(-) create mode 100644 canaille/oidc/forms.py create mode 100644 canaille/templates/oidc/user/logout.html create mode 100644 tests/oidc/test_end_session.py diff --git a/CHANGES.rst b/CHANGES.rst index b82ba772..8997ba0e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -11,6 +11,7 @@ Added - ``DISABLE_PASSWORD_RESET`` configuration option to disable password recovery. :pr:`46` - ``edit_self`` ACL permission to control user self edition. :pr:`47` +- Implemented RP-initiated logout :pr:`54` Changed ******* diff --git a/canaille/ldap_backend/schemas/oauth2-openldap.ldif b/canaille/ldap_backend/schemas/oauth2-openldap.ldif index bea07219..bcfb65d5 100644 --- a/canaille/ldap_backend/schemas/oauth2-openldap.ldif +++ b/canaille/ldap_backend/schemas/oauth2-openldap.ldif @@ -327,6 +327,14 @@ olcAttributeTypes: ( 1.3.6.1.4.1.56207.1.1.38 NAME 'oauthAuthorizationCodeID' SINGLE-VALUE USAGE userApplications X-ORIGIN 'OAuth 2.0' ) +olcAttributeTypes: ( 1.3.6.1.4.1.56207.1.1.39 NAME 'oauthPostLogoutRedirectURI' + DESC 'OAuth 2.0 Post logout redirection URI' + EQUALITY caseIgnoreMatch + ORDERING caseIgnoreOrderingMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + USAGE userApplications + X-ORIGIN 'OAuth 2.0' ) olcObjectClasses: ( 1.3.6.1.4.1.56207.1.2.1 NAME 'oauthClient' DESC 'OAuth 2.0 Authorization Code' SUP top @@ -352,7 +360,8 @@ olcObjectClasses: ( 1.3.6.1.4.1.56207.1.2.1 NAME 'oauthClient' oauthSoftwareID $ oauthSoftwareVersion $ oauthAudience $ - oauthPreconsent ) + oauthPreconsent $ + oauthPostLogoutRedirectURI ) ) X-ORIGIN 'OAuth 2.0' ) olcObjectClasses: ( 1.3.6.1.4.1.56207.1.2.2 NAME 'oauthAuthorizationCode' diff --git a/canaille/ldap_backend/schemas/oauth2-openldap.schema b/canaille/ldap_backend/schemas/oauth2-openldap.schema index 1a4b08f1..46614fbf 100644 --- a/canaille/ldap_backend/schemas/oauth2-openldap.schema +++ b/canaille/ldap_backend/schemas/oauth2-openldap.schema @@ -324,6 +324,15 @@ attributetypes ( 1.3.6.1.4.1.56207.1.1.38 NAME 'oauthAuthorizationCodeID' SINGLE-VALUE USAGE userApplications X-ORIGIN 'OAuth 2.0' ) +attributetypes ( 1.3.6.1.4.1.56207.1.1.39 NAME 'oauthPostLogoutRedirectURI' + DESC 'OAuth 2.0 Post logout redirection URI' + EQUALITY caseExactMatch + ORDERING caseExactOrderingMatch + SUBSTR caseExactSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + SINGLE-VALUE + USAGE userApplications + X-ORIGIN 'OAuth 2.0' ) objectclass ( 1.3.6.1.4.1.56207.1.2.1 NAME 'oauthClient' DESC 'OAuth 2.0 Authorization Code' SUP top @@ -349,7 +358,8 @@ objectclass ( 1.3.6.1.4.1.56207.1.2.1 NAME 'oauthClient' oauthSoftwareID $ oauthSoftwareVersion $ oauthAudience $ - oauthPreconsent ) + oauthPreconsent $ + oauthPostLogoutRedirectURI ) ) X-ORIGIN 'OAuth 2.0' ) objectclass ( 1.3.6.1.4.1.56207.1.2.2 NAME 'oauthAuthorizationCode' diff --git a/canaille/oidc/clients.py b/canaille/oidc/clients.py index 09c96576..2b48b314 100644 --- a/canaille/oidc/clients.py +++ b/canaille/oidc/clients.py @@ -52,6 +52,11 @@ class ClientAdd(FlaskForm): validators=[wtforms.validators.DataRequired()], render_kw={"placeholder": "https://mydomain.tld/callback"}, ) + post_logout_redirect_uris = wtforms.URLField( + _("Post logout redirect URIs"), + validators=[wtforms.validators.Optional()], + render_kw={"placeholder": "https://mydomain.tld/you-have-been-disconnected"}, + ) grant_type = wtforms.SelectMultipleField( _("Grant types"), validators=[wtforms.validators.DataRequired()], @@ -163,6 +168,7 @@ def add(user): uri=form["uri"].data, grant_type=form["grant_type"].data, redirect_uris=[form["redirect_uris"].data], + post_logout_redirect_uris=[form["post_logout_redirect_uris"].data], response_type=form["response_type"].data, scope=form["scope"].data.split(" "), token_endpoint_auth_method=form["token_endpoint_auth_method"].data, @@ -209,6 +215,11 @@ def client_edit(client_id): data = dict(client) data["scope"] = " ".join(data["scope"]) data["redirect_uris"] = data["redirect_uris"][0] + data["post_logout_redirect_uris"] = ( + data["post_logout_redirect_uris"][0] + if data["post_logout_redirect_uris"] + else "" + ) data["preconsent"] = client.preconsent form = ClientAdd(request.form or None, data=data, client=client) @@ -230,6 +241,7 @@ def client_edit(client_id): uri=form["uri"].data, grant_type=form["grant_type"].data, redirect_uris=[form["redirect_uris"].data], + post_logout_redirect_uris=[form["post_logout_redirect_uris"].data], response_type=form["response_type"].data, scope=form["scope"].data.split(" "), token_endpoint_auth_method=form["token_endpoint_auth_method"].data, diff --git a/canaille/oidc/forms.py b/canaille/oidc/forms.py new file mode 100644 index 00000000..4423515b --- /dev/null +++ b/canaille/oidc/forms.py @@ -0,0 +1,6 @@ +import wtforms +from flask_wtf import FlaskForm + + +class LogoutForm(FlaskForm): + answer = wtforms.SubmitField() diff --git a/canaille/oidc/models.py b/canaille/oidc/models.py index bbca9552..20e735bc 100644 --- a/canaille/oidc/models.py +++ b/canaille/oidc/models.py @@ -19,6 +19,7 @@ class Client(LDAPObject, ClientMixin): "contact": "oauthClientContact", "uri": "oauthClientURI", "redirect_uris": "oauthRedirectURIs", + "post_logout_redirect_uris": "oauthPostLogoutRedirectURI", "logo_uri": "oauthLogoURI", "issue_date": "oauthIssueDate", "secret": "oauthClientSecret", diff --git a/canaille/oidc/oauth.py b/canaille/oidc/oauth.py index bd6a208a..f3bb7ed8 100644 --- a/canaille/oidc/oauth.py +++ b/canaille/oidc/oauth.py @@ -1,7 +1,10 @@ import datetime +from urllib.parse import urlsplit +from urllib.parse import urlunsplit from authlib.integrations.flask_oauth2 import current_token from authlib.jose import jwk +from authlib.jose import jwt from authlib.oauth2 import OAuth2Error from flask import abort from flask import Blueprint @@ -11,13 +14,16 @@ from flask import jsonify from flask import redirect from flask import request from flask import session +from flask import url_for from flask_babel import gettext from flask_babel import lazy_gettext as _ from flask_themer import render_template +from werkzeug.datastructures import CombinedMultiDict from ..flaskutils import current_user from ..forms import FullLoginForm from ..models import User +from .forms import LogoutForm from .models import Client from .models import Consent from .oauth2utils import authorization @@ -43,6 +49,11 @@ CLAIMS = { } +def get_public_key(): + with open(current_app.config["JWT"]["PUBLIC_KEY"]) as fd: + return fd.read() + + @bp.route("/authorize", methods=["GET", "POST"]) def authorize(): current_app.logger.debug( @@ -184,10 +195,9 @@ def revoke_token(): @bp.route("/jwks.json") def jwks(): - with open(current_app.config["JWT"]["PUBLIC_KEY"]) as fd: - pubkey = fd.read() - - obj = jwk.dumps(pubkey, current_app.config["JWT"].get("KTY", DEFAULT_JWT_KTY)) + obj = jwk.dumps( + get_public_key(), current_app.config["JWT"].get("KTY", DEFAULT_JWT_KTY) + ) return jsonify( { "keys": [ @@ -209,3 +219,121 @@ def userinfo(): response = generate_user_info(current_token.subject, current_token.scope[0]) current_app.logger.debug("userinfo endpoint response: %s", response) return jsonify(response) + + +def set_parameter_in_url_query(url, **kwargs): + split = list(urlsplit(url)) + + parameters = "&".join(f"{key}={value}" for key, value in kwargs.items()) + + if split[3]: + split[3] = f"{split[3]}&{parameters}" + else: + split[3] = parameters + + return urlunsplit(split) + + +@bp.route("/end_session", methods=["GET", "POST"]) +def end_session(): + data = CombinedMultiDict((request.args, request.form)) + user = current_user() + + form = LogoutForm(request.form) + form.action = url_for("oidc.oauth.end_session_submit") + + client = None + valid_uris = [] + + if "client_id" in data: + client = Client.get(data["client_id"]) + if client: + valid_uris = client.post_logout_redirect_uris + + if ( + not data.get("id_token_hint") + or (data.get("logout_hint") and data["logout_hint"] != user.uid[0]) + ) and not session.get("end_session_confirmation"): + session["end_session_data"] = data + return render_template( + "oidc/user/logout.html", form=form, client=client, menu=False + ) + + if data.get("id_token_hint"): + id_token = jwt.decode(data["id_token_hint"], get_public_key()) + if not id_token["iss"] == current_app.config["JWT"]["ISS"]: + return jsonify( + { + "status": "error", + "message": "id_token_hint has not been issued here", + } + ) + + if "client_id" in data: + if ( + data["client_id"] != id_token["aud"] + and data["client_id"] not in id_token["aud"] + ): + return jsonify( + { + "status": "error", + "message": "id_token_hint and client_id don't match", + } + ) + + else: + client_ids = ( + id_token["aud"] + if isinstance(id_token["aud"], list) + else [id_token["aud"]] + ) + for client_id in client_ids: + client = Client.get(client_id) + if client: + valid_uris.extend(client.post_logout_redirect_uris) + + if user.uid[0] != id_token["sub"] and not session.get( + "end_session_confirmation" + ): + session["end_session_data"] = data + return render_template( + "oidc/user/logout.html", form=form, client=client, menu=False + ) + + user.logout() + + if "end_session_confirmation" in session: + del session["end_session_confirmation"] + + if ( + "post_logout_redirect_uri" in data + and data["post_logout_redirect_uri"] in valid_uris + ): + url = data["post_logout_redirect_uri"] + if "state" in data: + url = set_parameter_in_url_query(url, state=data["state"]) + return redirect(data["post_logout_redirect_uri"]) + + flash(_("You have been disconnected"), "success") + return redirect(url_for("account.index")) + + +@bp.route("/end_session_confirm", methods=["POST"]) +def end_session_submit(): + form = LogoutForm(request.form) + if not form.validate(): + flash(_("An error happened during the logout"), "error") + client = Client.get(session.get("end_session_data", {}).get("client_id")) + return render_template("oidc/user/logout.html", form=form, client=client) + + data = session["end_session_data"] + del session["end_session_data"] + + if request.form["answer"] == "logout": + session["end_session_confirmation"] = True + url = set_parameter_in_url_query(url_for("oidc.oauth.end_session"), **data) + return redirect(url) + + flash(_("You have not been disconnected"), "info") + + return redirect(url_for("account.index")) diff --git a/canaille/templates/oidc/user/logout.html b/canaille/templates/oidc/user/logout.html new file mode 100644 index 00000000..045034af --- /dev/null +++ b/canaille/templates/oidc/user/logout.html @@ -0,0 +1,42 @@ +{% extends theme('base.html') %} +{% import 'fomanticui.html' as sui %} + +{% block content %} +
+
+

+ +
{% trans %}Log out{% endtrans %}
+
{% trans %}Do you want to log out?{% endtrans %}
+

+
+

+ {{ _("You are currently logged in as %(username)s.", username=user.name) }} + {% if client %} + {{ _("The application %(client_name)s want to disconnect your account.", client_name=client.name) }} + {% endif %} +

+
+
+
+ +
+
+ {{ form.hidden_tag() if form.hidden_tag }} +
+ + +
+
+
+
+{% endblock %} diff --git a/canaille/translations/fr/LC_MESSAGES/messages.po b/canaille/translations/fr/LC_MESSAGES/messages.po index 1c4ba195..f062c2cf 100644 --- a/canaille/translations/fr/LC_MESSAGES/messages.po +++ b/canaille/translations/fr/LC_MESSAGES/messages.po @@ -3,25 +3,25 @@ # This file is distributed under the same license as the PROJECT project. # FIRST AUTHOR , 2020. # Camille , 2021-2022. -# Éloi Rivard , 2020-2022. +# Éloi Rivard , 2020-2022. # msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: contact@yaal.fr\n" -"POT-Creation-Date: 2022-04-06 17:43+0200\n" -"PO-Revision-Date: 2022-04-06 17:44+0200\n" -"Last-Translator: Éloi Rivard \n" -"Language: fr_FR\n" -"Language-Team: French - France \n" -"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"POT-Creation-Date: 2022-06-02 15:50+0200\n" +"PO-Revision-Date: 2022-06-02 15:55+0200\n" +"Last-Translator: Éloi Rivard \n" +"Language: fr\n" +"Language-Team: French \n" +"Plural-Forms: nplurals=2; plural=(n > 1)\n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" +"Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.1\n" +"Generated-By: Babel 2.10.1\n" "X-Generator: Gtranslator 40.0\n" -#: canaille/account.py:93 canaille/account.py:118 canaille/oidc/oauth.py:77 +#: canaille/account.py:93 canaille/account.py:118 canaille/oidc/oauth.py:88 msgid "Login failed, please check your information" msgstr "La connexion a échoué, veuillez vérifier vos informations." @@ -338,11 +338,11 @@ msgstr "Le groupe %(group)s a bien été supprimé" msgid "You have been invited to create an account on {website_name}" msgstr "Vous êtes invités à vous créer un compte sur {website_name}" -#: canaille/ldap_backend/backend.py:54 +#: canaille/ldap_backend/backend.py:50 msgid "Could not connect to the LDAP server '{uri}'" msgstr "Impossible de se connecter au serveur LDAP '{uri}'" -#: canaille/ldap_backend/backend.py:70 +#: canaille/ldap_backend/backend.py:66 msgid "LDAP authentication failed with user '{user}'" msgstr "" "L'authentification au serveur LDAP a échoué pour l'utilisateur '{user}'" @@ -360,74 +360,78 @@ msgid "Redirect URIs" msgstr "URIs de redirection" #: canaille/oidc/clients.py:56 +msgid "Post logout redirect URIs" +msgstr "URIs de redirection post-déconnexion" + +#: canaille/oidc/clients.py:61 msgid "Grant types" msgstr "Grant types" -#: canaille/oidc/clients.py:68 canaille/templates/oidc/admin/token_view.html:33 +#: canaille/oidc/clients.py:73 canaille/templates/oidc/admin/token_view.html:33 msgid "Scope" msgstr "Scope" -#: canaille/oidc/clients.py:74 +#: canaille/oidc/clients.py:79 msgid "Response types" msgstr "Types de réponse" -#: canaille/oidc/clients.py:80 +#: canaille/oidc/clients.py:85 msgid "Token Endpoint Auth Method" msgstr "Token Endpoint Auth Method" -#: canaille/oidc/clients.py:90 +#: canaille/oidc/clients.py:95 msgid "Token audiences" msgstr "Token audiences" -#: canaille/oidc/clients.py:96 +#: canaille/oidc/clients.py:101 msgid "Logo URI" msgstr "URI du logo" -#: canaille/oidc/clients.py:101 +#: canaille/oidc/clients.py:106 msgid "Terms of service URI" msgstr "URI des conditions d'utilisation" -#: canaille/oidc/clients.py:106 +#: canaille/oidc/clients.py:111 msgid "Policy URI" msgstr "URI de la politique de confidentialité" -#: canaille/oidc/clients.py:111 +#: canaille/oidc/clients.py:116 msgid "Software ID" msgstr "ID du logiciel" -#: canaille/oidc/clients.py:116 +#: canaille/oidc/clients.py:121 msgid "Software Version" msgstr "Version du logiciel" -#: canaille/oidc/clients.py:121 +#: canaille/oidc/clients.py:126 msgid "JWK" msgstr "JWK" -#: canaille/oidc/clients.py:126 +#: canaille/oidc/clients.py:131 msgid "JKW URI" msgstr "URI du JWK" -#: canaille/oidc/clients.py:131 +#: canaille/oidc/clients.py:136 msgid "Pre-consent" msgstr "Pré-autorisé" -#: canaille/oidc/clients.py:149 +#: canaille/oidc/clients.py:154 msgid "The client has not been added. Please check your information." msgstr "Le client n'a pas été ajouté. Veuillez vérifier vos informations." -#: canaille/oidc/clients.py:184 +#: canaille/oidc/clients.py:190 msgid "The client has been created." msgstr "Le client a été créé." -#: canaille/oidc/clients.py:222 +#: canaille/oidc/clients.py:233 msgid "The client has not been edited. Please check your information." msgstr "Le client n'a pas été édité. Veuillez vérifier vos informations." -#: canaille/oidc/clients.py:248 +#: canaille/oidc/clients.py:260 msgid "The client has been edited." msgstr "Le client a été édité." -#: canaille/oidc/clients.py:264 +#: canaille/oidc/clients.py:276 msgid "The client has been deleted." msgstr "Le client a été supprimé." @@ -439,30 +443,43 @@ msgstr "Impossible de supprimer cet accès." msgid "The access has been revoked" msgstr "L'accès a été révoqué." -#: canaille/oidc/oauth.py:37 +#: canaille/oidc/oauth.py:43 msgid "Personnal information about yourself, such as your name or your gender." msgstr "Vos informations personnelles, comme votre nom ou votre genre." -#: canaille/oidc/oauth.py:39 +#: canaille/oidc/oauth.py:45 msgid "Your email address." msgstr "Votre adresse email." -#: canaille/oidc/oauth.py:40 +#: canaille/oidc/oauth.py:46 msgid "Your postal address." msgstr "Votre adresse postale." -#: canaille/oidc/oauth.py:41 +#: canaille/oidc/oauth.py:47 msgid "Your phone number." msgstr "Votre numéro de téléphone." -#: canaille/oidc/oauth.py:42 +#: canaille/oidc/oauth.py:48 msgid "Groups you are belonging to" msgstr "Les groupes auxquels vous appartenez" -#: canaille/oidc/oauth.py:125 +#: canaille/oidc/oauth.py:136 msgid "You have been successfully logged out." msgstr "Vous avez été déconnecté·e." +#: canaille/oidc/oauth.py:317 +#, python-format +msgid "You have been disconnected" +msgstr "Vous avez été déconnecté·e." + +#: canaille/oidc/oauth.py:325 +msgid "An error happened during the logout" +msgstr "Une erreur est survenue lors de la déconnexion" + +#: canaille/oidc/oauth.py:337 +msgid "You have not been disconnected" +msgstr "Vous n'avez pas été déconnecté" + #: canaille/templates/about.html:12 canaille/themes/default/base.html:104 msgid "About canaille" msgstr "À propos de canaille" @@ -1242,6 +1259,33 @@ msgid "You did not authorize applications yet." msgstr "" "Vous n'avez pas encore autorisé d'application à accéder à votre profil." +#: canaille/templates/oidc/user/logout.html:9 +#: canaille/themes/default/base.html:90 +msgid "Log out" +msgstr "Déconnexion" + +#: canaille/templates/oidc/user/logout.html:10 +msgid "Do you want to log out?" +msgstr "Voulez vous vous déconnecter ?" + +#: canaille/templates/oidc/user/logout.html:14 +#, python-format +msgid "You are currently logged in as %(username)s." +msgstr "Vous êtes identifiés en tant que %(username)s" + +#: canaille/templates/oidc/user/logout.html:16 +#, python-format +msgid "The application %(client_name)s want to disconnect your account." +msgstr "L'application %(client_name)s demande votre déconnexion." + +#: canaille/templates/oidc/user/logout.html:33 +msgid "Stay logged" +msgstr "Rester connecté" + +#: canaille/templates/oidc/user/logout.html:36 +msgid "Logout" +msgstr "Déconnexion" + #: canaille/themes/default/base.html:10 msgid "authorization interface" msgstr " - Interface de gestion des autorisations" @@ -1266,10 +1310,6 @@ msgstr "Codes" msgid "Emails" msgstr "Courriels" -#: canaille/themes/default/base.html:90 -msgid "Log out" -msgstr "Déconnexion" - #~ msgid "Logged in as" #~ msgstr "Connecté en tant que" diff --git a/canaille/translations/messages.pot b/canaille/translations/messages.pot index 156d5ebb..b3531cb0 100644 --- a/canaille/translations/messages.pot +++ b/canaille/translations/messages.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2022-06-02 15:41+0200\n" +"POT-Creation-Date: 2022-06-02 15:50+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,7 +17,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.10.1\n" -#: canaille/account.py:93 canaille/account.py:118 canaille/oidc/oauth.py:77 +#: canaille/account.py:93 canaille/account.py:118 canaille/oidc/oauth.py:88 msgid "Login failed, please check your information" msgstr "" @@ -342,74 +342,78 @@ msgid "Redirect URIs" msgstr "" #: canaille/oidc/clients.py:56 +msgid "Post logout redirect URIs" +msgstr "" + +#: canaille/oidc/clients.py:61 msgid "Grant types" msgstr "" -#: canaille/oidc/clients.py:68 canaille/templates/oidc/admin/token_view.html:33 +#: canaille/oidc/clients.py:73 canaille/templates/oidc/admin/token_view.html:33 msgid "Scope" msgstr "" -#: canaille/oidc/clients.py:74 +#: canaille/oidc/clients.py:79 msgid "Response types" msgstr "" -#: canaille/oidc/clients.py:80 +#: canaille/oidc/clients.py:85 msgid "Token Endpoint Auth Method" msgstr "" -#: canaille/oidc/clients.py:90 +#: canaille/oidc/clients.py:95 msgid "Token audiences" msgstr "" -#: canaille/oidc/clients.py:96 +#: canaille/oidc/clients.py:101 msgid "Logo URI" msgstr "" -#: canaille/oidc/clients.py:101 +#: canaille/oidc/clients.py:106 msgid "Terms of service URI" msgstr "" -#: canaille/oidc/clients.py:106 +#: canaille/oidc/clients.py:111 msgid "Policy URI" msgstr "" -#: canaille/oidc/clients.py:111 +#: canaille/oidc/clients.py:116 msgid "Software ID" msgstr "" -#: canaille/oidc/clients.py:116 +#: canaille/oidc/clients.py:121 msgid "Software Version" msgstr "" -#: canaille/oidc/clients.py:121 +#: canaille/oidc/clients.py:126 msgid "JWK" msgstr "" -#: canaille/oidc/clients.py:126 +#: canaille/oidc/clients.py:131 msgid "JKW URI" msgstr "" -#: canaille/oidc/clients.py:131 +#: canaille/oidc/clients.py:136 msgid "Pre-consent" msgstr "" -#: canaille/oidc/clients.py:149 +#: canaille/oidc/clients.py:154 msgid "The client has not been added. Please check your information." msgstr "" -#: canaille/oidc/clients.py:184 +#: canaille/oidc/clients.py:190 msgid "The client has been created." msgstr "" -#: canaille/oidc/clients.py:222 +#: canaille/oidc/clients.py:233 msgid "The client has not been edited. Please check your information." msgstr "" -#: canaille/oidc/clients.py:248 +#: canaille/oidc/clients.py:260 msgid "The client has been edited." msgstr "" -#: canaille/oidc/clients.py:264 +#: canaille/oidc/clients.py:276 msgid "The client has been deleted." msgstr "" @@ -421,30 +425,42 @@ msgstr "" msgid "The access has been revoked" msgstr "" -#: canaille/oidc/oauth.py:37 +#: canaille/oidc/oauth.py:43 msgid "Personnal information about yourself, such as your name or your gender." msgstr "" -#: canaille/oidc/oauth.py:39 +#: canaille/oidc/oauth.py:45 msgid "Your email address." msgstr "" -#: canaille/oidc/oauth.py:40 +#: canaille/oidc/oauth.py:46 msgid "Your postal address." msgstr "" -#: canaille/oidc/oauth.py:41 +#: canaille/oidc/oauth.py:47 msgid "Your phone number." msgstr "" -#: canaille/oidc/oauth.py:42 +#: canaille/oidc/oauth.py:48 msgid "Groups you are belonging to" msgstr "" -#: canaille/oidc/oauth.py:125 +#: canaille/oidc/oauth.py:136 msgid "You have been successfully logged out." msgstr "" +#: canaille/oidc/oauth.py:317 +msgid "You have been disconnected" +msgstr "" + +#: canaille/oidc/oauth.py:325 +msgid "An error happened during the logout" +msgstr "" + +#: canaille/oidc/oauth.py:337 +msgid "You have not been disconnected" +msgstr "" + #: canaille/templates/about.html:12 canaille/themes/default/base.html:104 msgid "About canaille" msgstr "" @@ -1151,6 +1167,33 @@ msgstr "" msgid "You did not authorize applications yet." msgstr "" +#: canaille/templates/oidc/user/logout.html:9 +#: canaille/themes/default/base.html:90 +msgid "Log out" +msgstr "" + +#: canaille/templates/oidc/user/logout.html:10 +msgid "Do you want to log out?" +msgstr "" + +#: canaille/templates/oidc/user/logout.html:14 +#, python-format +msgid "You are currently logged in as %(username)s." +msgstr "" + +#: canaille/templates/oidc/user/logout.html:16 +#, python-format +msgid "The application %(client_name)s want to disconnect your account." +msgstr "" + +#: canaille/templates/oidc/user/logout.html:33 +msgid "Stay logged" +msgstr "" + +#: canaille/templates/oidc/user/logout.html:36 +msgid "Logout" +msgstr "" + #: canaille/themes/default/base.html:10 msgid "authorization interface" msgstr "" @@ -1174,7 +1217,3 @@ msgstr "" #: canaille/themes/default/base.html:83 msgid "Emails" msgstr "" - -#: canaille/themes/default/base.html:90 -msgid "Log out" -msgstr "" diff --git a/demo/client/__init__.py b/demo/client/__init__.py index bdfae375..059253b3 100644 --- a/demo/client/__init__.py +++ b/demo/client/__init__.py @@ -1,5 +1,9 @@ +from urllib.parse import urlsplit +from urllib.parse import urlunsplit + from authlib.integrations.flask_client import OAuth from authlib.oidc.discovery import get_well_known_url +from flask import current_app from flask import flash from flask import Flask from flask import redirect @@ -39,6 +43,7 @@ def create_app(): def authorize(): token = oauth.canaille.authorize_access_token() session["user"] = token.get("userinfo") + session["id_token"] = token["id_token"] flash("You have been successfully logged in.", "success") return redirect(url_for("index")) @@ -49,7 +54,31 @@ def create_app(): except KeyError: pass - flash("You have been successfully logged out.", "success") - return redirect(url_for("index")) + flash("You have been successfully logged out", "success") + + oauth.canaille.load_server_metadata() + end_session_endpoint = oauth.canaille.server_metadata.get( + "end_session_endpoint" + ) + end_session_url = set_parameter_in_url_query( + end_session_endpoint, + client_id=current_app.config["OAUTH_CLIENT_ID"], + id_token_hint=session["id_token"], + post_logout_redirect_uri=url_for("index", _external=True), + ) + return redirect(end_session_url) return app + + +def set_parameter_in_url_query(url, **kwargs): + split = list(urlsplit(url)) + + parameters = "&".join(f"{key}={value}" for key, value in kwargs.items()) + + if split[3]: + split[3] = f"{split[3]}&{parameters}" + else: + split[3] = parameters + + return urlunsplit(split) diff --git a/demo/ldif/bootstrap-data.ldif b/demo/ldif/bootstrap-data.ldif index 271b80b7..691e4579 100644 --- a/demo/ldif/bootstrap-data.ldif +++ b/demo/ldif/bootstrap-data.ldif @@ -79,6 +79,7 @@ oauthClientName: Client1 oauthClientContact: admin@mydomain.tld oauthClientURI: http://localhost:5001 oauthRedirectURIs: http://localhost:5001/authorize +oauthPostLogoutRedirectURI: http://localhost:5001/ oauthGrantType: authorization_code oauthGrantType: refresh_token oauthScope: openid @@ -98,6 +99,7 @@ oauthClientName: Client2 oauthClientContact: admin@mydomain.tld oauthClientURI: http://localhost:5002 oauthRedirectURIs: http://localhost:5002/authorize +oauthPostLogoutRedirectURI: http://localhost:5002/ oauthGrantType: authorization_code oauthGrantType: refresh_token oauthScope: openid diff --git a/tests/oidc/conftest.py b/tests/oidc/conftest.py index c1e56a55..791c4f37 100644 --- a/tests/oidc/conftest.py +++ b/tests/oidc/conftest.py @@ -1,10 +1,13 @@ import datetime import pytest +from authlib.oidc.core.grants.util import generate_id_token from canaille.oidc.models import AuthorizationCode from canaille.oidc.models import Client from canaille.oidc.models import Consent from canaille.oidc.models import Token +from canaille.oidc.oauth2utils import generate_user_info +from canaille.oidc.oauth2utils import get_jwt_config from werkzeug.security import gen_salt @@ -35,6 +38,7 @@ def client(testclient, other_client, slapd_connection): policy_uri="https://mydomain.tld/policy", jwk_uri="https://mydomain.tld/jwk", token_endpoint_auth_method="client_secret_basic", + post_logout_redirect_uris=["https://mydomain.tld/disconnected"], ) c.audience = [c.dn, other_client.dn] c.save() @@ -73,6 +77,7 @@ def other_client(testclient, slapd_connection): policy_uri="https://myotherdomain.tld/policy", jwk_uri="https://myotherdomain.tld/jwk", token_endpoint_auth_method="client_secret_basic", + post_logout_redirect_uris=["https://myotherdomain.tld/disconnected"], ) c.audience = [c.dn] c.save() @@ -131,6 +136,16 @@ def token(testclient, client, user, slapd_connection): pass +@pytest.fixture +def id_token(testclient, client, user, slapd_connection): + return generate_id_token( + {}, + generate_user_info(user.dn, client.scope), + aud=client.client_id, + **get_jwt_config(None) + ) + + @pytest.fixture def consent(testclient, client, user, slapd_connection): t = Consent( diff --git a/tests/oidc/test_client_admin.py b/tests/oidc/test_client_admin.py index 5baaa4bd..43bc922c 100644 --- a/tests/oidc/test_client_admin.py +++ b/tests/oidc/test_client_admin.py @@ -38,6 +38,7 @@ def test_client_add(testclient, logged_admin): "jwk_uri": "https://foo.bar/jwks.json", "audience": [], "preconsent": False, + "post_logout_redirect_uris": ["https://foo.bar/disconnected"], } for k, v in data.items(): res.form[k].force_value(v) @@ -78,6 +79,7 @@ def test_client_edit(testclient, client, logged_admin, other_client): "jwk_uri": "https://foo.bar/jwks.json", "audience": [client.dn, other_client.dn], "preconsent": True, + "post_logout_redirect_uris": ["https://foo.bar/disconnected"], } for k, v in data.items(): res.forms["clientadd"][k].force_value(v) diff --git a/tests/oidc/test_end_session.py b/tests/oidc/test_end_session.py new file mode 100644 index 00000000..f58c8000 --- /dev/null +++ b/tests/oidc/test_end_session.py @@ -0,0 +1,331 @@ +from authlib.oidc.core.grants.util import generate_id_token +from canaille.oidc.oauth2utils import generate_user_info +from canaille.oidc.oauth2utils import get_jwt_config + + +def test_end_session(testclient, slapd_connection, logged_user, client, id_token): + testclient.get(f"/profile/{logged_user.uid[0]}", status=200) + + post_logout_redirect_url = "https://mydomain.tld/disconnected" + res = testclient.get( + "/oauth/end_session", + params={ + "id_token_hint": id_token, + "logout_hint": logged_user.uid[0], + "client_id": client.client_id, + "post_logout_redirect_uri": post_logout_redirect_url, + "state": "foobar", + }, + status=302, + ) + + assert res.location.startswith(post_logout_redirect_url) + + with testclient.session_transaction() as sess: + assert not sess.get("user_dn") + + testclient.get(f"/profile/{logged_user.uid[0]}", status=403) + + +def test_end_session_no_client_id( + testclient, slapd_connection, logged_user, client, id_token +): + testclient.get(f"/profile/{logged_user.uid[0]}", status=200) + + post_logout_redirect_url = "https://mydomain.tld/disconnected" + res = testclient.get( + "/oauth/end_session", + params={ + "id_token_hint": id_token, + "logout_hint": logged_user.uid[0], + "post_logout_redirect_uri": post_logout_redirect_url, + "state": "foobar", + }, + status=302, + ) + + assert res.location.startswith(post_logout_redirect_url) + + with testclient.session_transaction() as sess: + assert not sess.get("user_dn") + + testclient.get(f"/profile/{logged_user.uid[0]}", status=403) + + +def test_no_redirect_uri_no_redirect( + testclient, slapd_connection, logged_user, client, id_token +): + testclient.get(f"/profile/{logged_user.uid[0]}", status=200) + + res = testclient.get( + "/oauth/end_session", + params={ + "id_token_hint": id_token, + "logout_hint": logged_user.uid[0], + "client_id": client.client_id, + "state": "foobar", + }, + status=302, + ) + + assert res.location == "/" + + with testclient.session_transaction() as sess: + assert not sess.get("user_dn") + + testclient.get(f"/profile/{logged_user.uid[0]}", status=403) + + +def test_bad_redirect_uri_no_redirect( + testclient, slapd_connection, logged_user, client, id_token +): + testclient.get(f"/profile/{logged_user.uid[0]}", status=200) + + post_logout_redirect_url = "https://mydomain.tld/invalid-uri" + res = testclient.get( + "/oauth/end_session", + params={ + "id_token_hint": id_token, + "logout_hint": logged_user.uid[0], + "client_id": client.client_id, + "post_logout_redirect_uri": post_logout_redirect_url, + "state": "foobar", + }, + status=302, + ) + + assert res.location == "/" + + with testclient.session_transaction() as sess: + assert not sess.get("user_dn") + + testclient.get(f"/profile/{logged_user.uid[0]}", status=403) + + +def test_no_client_hint_no_redirect( + testclient, slapd_connection, logged_user, client, id_token +): + testclient.get(f"/profile/{logged_user.uid[0]}", status=200) + + post_logout_redirect_url = "https://mydomain.tld/disconnected" + res = testclient.get( + "/oauth/end_session", + params={ + "logout_hint": logged_user.uid[0], + "post_logout_redirect_uri": post_logout_redirect_url, + "state": "foobar", + }, + status=200, + ) + res = res.form.submit(name="answer", value="logout", status=302) + res = res.follow(status=302) + + assert res.location == "/" + + with testclient.session_transaction() as sess: + assert not sess.get("user_dn") + + testclient.get(f"/profile/{logged_user.uid[0]}", status=403) + + +def test_no_jwt_logout(testclient, slapd_connection, logged_user, client): + testclient.get(f"/profile/{logged_user.uid[0]}", status=200) + + post_logout_redirect_url = "https://mydomain.tld/disconnected" + res = testclient.get( + "/oauth/end_session", + params={ + "logout_hint": logged_user.uid[0], + "client_id": client.client_id, + "post_logout_redirect_uri": post_logout_redirect_url, + "state": "foobar", + }, + status=200, + ) + + res = res.form.submit(name="answer", value="logout", status=302) + res = res.follow(status=302) + + with testclient.session_transaction() as sess: + assert not sess.get("user_dn") + + assert res.location.startswith(post_logout_redirect_url) + + testclient.get(f"/profile/{logged_user.uid[0]}", status=403) + + +def test_no_jwt_no_logout(testclient, slapd_connection, logged_user, client): + testclient.get(f"/profile/{logged_user.uid[0]}", status=200) + + post_logout_redirect_url = "https://mydomain.tld/disconnected" + res = testclient.get( + "/oauth/end_session", + params={ + "logout_hint": logged_user.uid[0], + "client_id": client.client_id, + "post_logout_redirect_uri": post_logout_redirect_url, + "state": "foobar", + }, + status=200, + ) + + res = res.form.submit(name="answer", value="stay", status=302) + + with testclient.session_transaction() as sess: + assert sess.get("user_dn") + + assert res.location == "/" + + testclient.get(f"/profile/{logged_user.uid[0]}", status=200) + + +def test_jwt_not_issued_here( + testclient, slapd_connection, logged_user, client, id_token +): + testclient.app.config["JWT"]["ISS"] = "https://foo.bar" + + testclient.get(f"/profile/{logged_user.uid[0]}", status=200) + + post_logout_redirect_url = "https://mydomain.tld/disconnected" + res = testclient.get( + "/oauth/end_session", + params={ + "id_token_hint": id_token, + "logout_hint": logged_user.uid[0], + "client_id": client.client_id, + "post_logout_redirect_uri": post_logout_redirect_url, + "state": "foobar", + }, + status=200, + ) + + assert res.json == { + "message": "id_token_hint has not been issued here", + "status": "error", + } + + +def test_bad_client_hint(testclient, slapd_connection, logged_user, client): + id_token = generate_id_token( + {}, + generate_user_info(logged_user.dn, client.scope), + aud="another_client_id", + **get_jwt_config(None), + ) + + testclient.get(f"/profile/{logged_user.uid[0]}", status=200) + + post_logout_redirect_url = "https://mydomain.tld/disconnected" + res = testclient.get( + "/oauth/end_session", + params={ + "id_token_hint": id_token, + "logout_hint": logged_user.uid[0], + "client_id": client.client_id, + "post_logout_redirect_uri": post_logout_redirect_url, + "state": "foobar", + }, + status=200, + ) + + assert res.json == { + "message": "id_token_hint and client_id don't match", + "status": "error", + } + + +def test_bad_user_id_token_mismatch( + testclient, slapd_connection, logged_user, client, admin +): + testclient.get(f"/profile/{logged_user.uid[0]}", status=200) + + id_token = generate_id_token( + {}, + generate_user_info(admin.dn, client.scope), + aud=client.client_id, + **get_jwt_config(None), + ) + + post_logout_redirect_url = "https://mydomain.tld/disconnected" + res = testclient.get( + "/oauth/end_session", + params={ + "id_token_hint": id_token, + "logout_hint": logged_user.uid[0], + "client_id": client.client_id, + "post_logout_redirect_uri": post_logout_redirect_url, + "state": "foobar", + }, + status=200, + ) + + res = res.form.submit(name="answer", value="logout", status=302) + res = res.follow(status=302) + + with testclient.session_transaction() as sess: + assert not sess.get("user_dn") + + assert res.location.startswith(post_logout_redirect_url) + + testclient.get(f"/profile/{logged_user.uid[0]}", status=403) + + +def test_bad_user_hint( + testclient, slapd_connection, logged_user, client, id_token, admin +): + testclient.get(f"/profile/{logged_user.uid[0]}", status=200) + + post_logout_redirect_url = "https://mydomain.tld/disconnected" + res = testclient.get( + "/oauth/end_session", + params={ + "id_token_hint": id_token, + "logout_hint": admin.uid[0], + "client_id": client.client_id, + "post_logout_redirect_uri": post_logout_redirect_url, + "state": "foobar", + }, + status=200, + ) + + res = res.form.submit(name="answer", value="logout", status=302) + res = res.follow(status=302) + + with testclient.session_transaction() as sess: + assert not sess.get("user_dn") + + assert res.location.startswith(post_logout_redirect_url) + + testclient.get(f"/profile/{logged_user.uid[0]}", status=403) + + +def test_no_jwt_bad_csrf(testclient, slapd_connection, logged_user, client): + testclient.get(f"/profile/{logged_user.uid[0]}", status=200) + + post_logout_redirect_url = "https://mydomain.tld/disconnected" + res = testclient.get( + "/oauth/end_session", + params={ + "logout_hint": logged_user.uid[0], + "client_id": client.client_id, + "post_logout_redirect_uri": post_logout_redirect_url, + "state": "foobar", + }, + status=200, + ) + + form = res.form + form["csrf_token"] = "foobar" + res = form.submit(name="answer", value="logout", status=200) + + assert "An error happened during the logout" in res + + res = res.form.submit(name="answer", value="logout", status=302) + res = res.follow(status=302) + + with testclient.session_transaction() as sess: + assert not sess.get("user_dn") + + assert res.location.startswith(post_logout_redirect_url) + + testclient.get(f"/profile/{logged_user.uid[0]}", status=403)