Implemented RP-initiated logout

This commit is contained in:
Éloi Rivard 2022-05-20 14:07:56 +02:00
parent b378d27b23
commit 95ec09fe54
15 changed files with 743 additions and 76 deletions

View file

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

View file

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

View file

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

View file

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

6
canaille/oidc/forms.py Normal file
View file

@ -0,0 +1,6 @@
import wtforms
from flask_wtf import FlaskForm
class LogoutForm(FlaskForm):
answer = wtforms.SubmitField()

View file

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

View file

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

View file

@ -0,0 +1,42 @@
{% extends theme('base.html') %}
{% import 'fomanticui.html' as sui %}
{% block content %}
<div class="ui segment">
<div class="ui center aligned">
<h2 class="ui center aligned icon header">
<i class="sign out icon"></i>
<div class="content">{% trans %}Log out{% endtrans %}</div>
<div class="sub header">{% trans %}Do you want to log out?{% endtrans %}</div>
</h2>
<div class="ui message">
<p>
{{ _("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 %}
</p>
</div>
</div>
<br>
<div class="ui center aligned container">
<form method="POST"
id="{{ form.id or form.__class__.__name__|lower }}"
action="{{ form.action }}"
role="form"
class="ui form"
>
{{ form.hidden_tag() if form.hidden_tag }}
<div class="ui stackable buttons">
<button name="answer" type="submit" class="ui button" value="stay" id="stay">
{% trans %}Stay logged{% endtrans %}
</button>
<button name="answer" type="submit" class="ui primary button" value="logout" id="logout">
{% trans %}Logout{% endtrans %}
</button>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -3,25 +3,25 @@
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2020.
# Camille <camille@yaal.coop>, 2021-2022.
# Éloi Rivard <eloi@yaal.fr>, 2020-2022.
# Éloi Rivard <eloi.rivard@aquilenet.fr>, 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 <eloi@yaal.fr>\n"
"Language: fr_FR\n"
"Language-Team: French - France <equipe@yaal.fr>\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 <eloi.rivard@aquilenet.fr>\n"
"Language: fr\n"
"Language-Team: French <traduc@traduc.org>\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"

View file

@ -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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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 ""

View file

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

View file

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

View file

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

View file

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

View file

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