diff --git a/e2e/test_provider_oauth2_oidc.py b/e2e/test_provider_oauth2_oidc.py index d504d9c49..c17addd6c 100644 --- a/e2e/test_provider_oauth2_oidc.py +++ b/e2e/test_provider_oauth2_oidc.py @@ -33,6 +33,7 @@ from passbook.providers.oauth2.models import ( ) LOGGER = get_logger() +APPLICATION_SLUG = "grafana" @skipUnless(platform.startswith("linux"), "requires local docker") @@ -69,6 +70,12 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): "GF_AUTH_GENERIC_OAUTH_API_URL": ( self.url("passbook_providers_oauth2:userinfo") ), + "GF_AUTH_SIGNOUT_REDIRECT_URL": ( + self.url( + "passbook_providers_oauth2:end-session", + application_slug=APPLICATION_SLUG, + ) + ), "GF_LOG_LEVEL": "debug", }, } @@ -97,7 +104,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): ) provider.save() Application.objects.create( - name="Grafana", slug="grafana", provider=provider, + name="Grafana", slug=APPLICATION_SLUG, provider=provider, ) self.driver.get("http://localhost:3000") @@ -137,7 +144,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): ) provider.save() Application.objects.create( - name="Grafana", slug="grafana", provider=provider, + name="Grafana", slug=APPLICATION_SLUG, provider=provider, ) self.driver.get("http://localhost:3000") @@ -171,6 +178,72 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): USER().email, ) + def test_authorization_logout(self): + """test OpenID Provider flow with logout""" + sleep(1) + # Bootstrap all needed objects + authorization_flow = Flow.objects.get( + slug="default-provider-authorization-implicit-consent" + ) + provider = OAuth2Provider.objects.create( + name="grafana", + client_type=ClientTypes.CONFIDENTIAL, + client_id=self.client_id, + client_secret=self.client_secret, + rsa_key=CertificateKeyPair.objects.first(), + redirect_uris="http://localhost:3000/login/generic_oauth", + authorization_flow=authorization_flow, + response_type=ResponseTypes.CODE, + ) + provider.property_mappings.set( + ScopeMapping.objects.filter( + scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] + ) + ) + provider.save() + Application.objects.create( + name="Grafana", slug=APPLICATION_SLUG, provider=provider, + ) + + self.driver.get("http://localhost:3000") + self.driver.find_element(By.CLASS_NAME, "btn-service--oauth").click() + self.driver.find_element(By.ID, "id_uid_field").click() + self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username) + self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER) + self.driver.find_element(By.ID, "id_password").send_keys(USER().username) + self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER) + self.driver.find_element(By.XPATH, "//a[contains(@href, '/profile')]").click() + self.assertEqual( + self.driver.find_element(By.CLASS_NAME, "page-header__title").text, + USER().name, + ) + self.assertEqual( + self.driver.find_element(By.CSS_SELECTOR, "input[name=name]").get_attribute( + "value" + ), + USER().name, + ) + self.assertEqual( + self.driver.find_element( + By.CSS_SELECTOR, "input[name=email]" + ).get_attribute("value"), + USER().email, + ) + self.assertEqual( + self.driver.find_element( + By.CSS_SELECTOR, "input[name=login]" + ).get_attribute("value"), + USER().email, + ) + self.driver.find_element(By.CSS_SELECTOR, "[href='/logout']").click() + self.wait_for_url( + self.url( + "passbook_providers_oauth2:end-session", + application_slug=APPLICATION_SLUG, + ) + ) + self.driver.find_element(By.ID, "logout").click() + def test_authorization_consent_explicit(self): """test OpenID Provider flow (default authorization flow with explicit consent)""" sleep(1) @@ -195,7 +268,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): ) provider.save() app = Application.objects.create( - name="Grafana", slug="grafana", provider=provider, + name="Grafana", slug=APPLICATION_SLUG, provider=provider, ) self.driver.get("http://localhost:3000") @@ -271,7 +344,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): ) provider.save() app = Application.objects.create( - name="Grafana", slug="grafana", provider=provider, + name="Grafana", slug=APPLICATION_SLUG, provider=provider, ) negative_policy = ExpressionPolicy.objects.create( diff --git a/passbook/providers/oauth2/templates/providers/oauth2/end_session.html b/passbook/providers/oauth2/templates/providers/oauth2/end_session.html new file mode 100644 index 000000000..c43029483 --- /dev/null +++ b/passbook/providers/oauth2/templates/providers/oauth2/end_session.html @@ -0,0 +1,36 @@ +{% extends 'login/base_full.html' %} + +{% load static %} +{% load i18n %} +{% load passbook_utils %} + +{% block title %} +{% trans 'End session' %} +{% endblock %} + +{% block card_title %} +{% blocktrans with application=application.name %} +You've logged out of {{ application }}. +{% endblocktrans %} +{% endblock %} + +{% block card %} +
+ {% if message %} +

{% trans message %}

+ {% endif %} + + {% trans 'Go back to passbook' %} + + {% trans 'Log out of passbook' %} + + {% if application.get_launch_url %} + + {% blocktrans with application=application.name %} + Log back into {{ application }} + {% endblocktrans %} + + {% endif %} + +
+{% endblock %} diff --git a/passbook/providers/oauth2/urls.py b/passbook/providers/oauth2/urls.py index 7ccd2b00a..725f26a35 100644 --- a/passbook/providers/oauth2/urls.py +++ b/passbook/providers/oauth2/urls.py @@ -20,12 +20,16 @@ urlpatterns = [ csrf_exempt(protected_resource_view([SCOPE_OPENID])(UserInfoView.as_view())), name="userinfo", ), - path("end-session/", EndSessionView.as_view(), name="end-session",), path( "introspect/", csrf_exempt(TokenIntrospectionView.as_view()), name="token-introspection", ), + path( + "/end-session/", + EndSessionView.as_view(), + name="end-session", + ), path("/jwks/", JWKSView.as_view(), name="jwks"), path( "/.well-known/openid-configuration", diff --git a/passbook/providers/oauth2/views/provider.py b/passbook/providers/oauth2/views/provider.py index 000f6ecd6..4b9fe9757 100644 --- a/passbook/providers/oauth2/views/provider.py +++ b/passbook/providers/oauth2/views/provider.py @@ -32,7 +32,10 @@ class ProviderInfoView(View): reverse("passbook_providers_oauth2:userinfo") ), "end_session_endpoint": self.request.build_absolute_uri( - reverse("passbook_providers_oauth2:end-session") + reverse( + "passbook_providers_oauth2:end-session", + kwargs={"application_slug": provider.application.slug}, + ) ), "introspection_endpoint": self.request.build_absolute_uri( reverse("passbook_providers_oauth2:token-introspection") diff --git a/passbook/providers/oauth2/views/session.py b/passbook/providers/oauth2/views/session.py index 5794c13a0..1a889a907 100644 --- a/passbook/providers/oauth2/views/session.py +++ b/passbook/providers/oauth2/views/session.py @@ -1,45 +1,22 @@ """passbook OAuth2 Session Views""" -from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit +from typing import Any, Dict -from django.contrib.auth.views import LogoutView -from django.http import HttpRequest, HttpResponse from django.shortcuts import get_object_or_404 +from django.views.generic.base import TemplateView from passbook.core.models import Application -from passbook.providers.oauth2.models import OAuth2Provider -from passbook.providers.oauth2.utils import client_id_from_id_token -class EndSessionView(LogoutView): +class EndSessionView(TemplateView): """Allow the client to end the Session""" - def dispatch( - self, request: HttpRequest, application_slug: str, *args, **kwargs - ) -> HttpResponse: + template_name = "providers/oauth2/end_session.html" - application = get_object_or_404(Application, slug=application_slug) - provider: OAuth2Provider = get_object_or_404( - OAuth2Provider, pk=application.provider_id + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + + context["application"] = get_object_or_404( + Application, slug=self.kwargs["application_slug"] ) - id_token_hint = request.GET.get("id_token_hint", "") - post_logout_redirect_uri = request.GET.get("post_logout_redirect_uri", "") - state = request.GET.get("state", "") - - if id_token_hint: - client_id = client_id_from_id_token(id_token_hint) - try: - provider = OAuth2Provider.objects.get(client_id=client_id) - if post_logout_redirect_uri in provider.post_logout_redirect_uris: - if state: - uri = urlsplit(post_logout_redirect_uri) - query_params = parse_qs(uri.query) - query_params["state"] = state - uri = uri._replace(query=urlencode(query_params, doseq=True)) - self.next_page = urlunsplit(uri) - else: - self.next_page = post_logout_redirect_uri - except OAuth2Provider.DoesNotExist: - pass - - return super().dispatch(request, *args, **kwargs) + return context