From 914aea320ac94ae3c1ca8ff0e476bc7736eb98b8 Mon Sep 17 00:00:00 2001 From: Mark Hobson Date: Fri, 6 Oct 2023 15:20:17 +0100 Subject: [PATCH] GH-3: Allow the user to sign out --- README.md | 23 ++++++++++--------- schemes/auth.py | 14 +++++++++++ schemes/config.py | 1 + .../govuk_one_login_service_header/macro.html | 14 +++++------ schemes/templates/service_base.html | 7 +++++- tests/e2e/conftest.py | 1 + tests/e2e/oidc_server/app.py | 14 ++++++++--- tests/e2e/pages.py | 13 ++++++++++- tests/e2e/test_home.py | 8 +++++++ tests/integration/conftest.py | 1 + 10 files changed, 72 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 7b171d40..fdb7b9b3 100644 --- a/README.md +++ b/README.md @@ -30,17 +30,18 @@ The application can also be configured with the following environment variables: -| Name | Value | -|---------------------------------|---------------------------------------------------------------------------------------------| -| FLASK_ENV | Application environment name (`dev` / `test`) | -| FLASK_SECRET_KEY | Flask session [secret key](https://flask.palletsprojects.com/en/2.3.x/quickstart/#sessions) | -| FLASK_BASIC_AUTH_USERNAME | HTTP Basic Auth username | -| FLASK_BASIC_AUTH_PASSWORD | HTTP Basic Auth password | -| FLASK_GOVUK_CLIENT_ID | OIDC client id | -| FLASK_GOVUK_CLIENT_SECRET | OIDC client secret | -| FLASK_GOVUK_SERVER_METADATA_URL | OIDC discovery endpoint | -| FLASK_GOVUK_TOKEN_ENDPOINT | OIDC token endpoint | -| FLASK_GOVUK_PROFILE_URL | OIDC profile URL | +| Name | Value | +|----------------------------------|---------------------------------------------------------------------------------------------| +| FLASK_ENV | Application environment name (`dev` / `test`) | +| FLASK_SECRET_KEY | Flask session [secret key](https://flask.palletsprojects.com/en/2.3.x/quickstart/#sessions) | +| FLASK_BASIC_AUTH_USERNAME | HTTP Basic Auth username | +| FLASK_BASIC_AUTH_PASSWORD | HTTP Basic Auth password | +| FLASK_GOVUK_CLIENT_ID | OIDC client id | +| FLASK_GOVUK_CLIENT_SECRET | OIDC client secret | +| FLASK_GOVUK_SERVER_METADATA_URL | OIDC discovery endpoint | +| FLASK_GOVUK_TOKEN_ENDPOINT | OIDC token endpoint | +| FLASK_GOVUK_PROFILE_URL | OIDC profile URL | +| FLASK_GOVUK_END_SESSION_ENDPOINT | OIDC end session endpoint | ## Running locally diff --git a/schemes/auth.py b/schemes/auth.py index f971a20e..5ee048cc 100644 --- a/schemes/auth.py +++ b/schemes/auth.py @@ -1,5 +1,6 @@ from functools import wraps from typing import Callable, ParamSpec, TypeVar +from urllib.parse import urlencode, urlparse from authlib.integrations.flask_client import OAuth from flask import Blueprint, Response, current_app, redirect, session, url_for @@ -17,6 +18,19 @@ def callback() -> BaseResponse: return redirect(url_for("home.index")) +@bp.route("/logout") +def logout() -> BaseResponse: + id_token = session["id_token"] + del session["user"] + del session["id_token"] + + end_session_endpoint = current_app.config["GOVUK_END_SESSION_ENDPOINT"] + post_logout_redirect_uri = url_for("start.index", _external=True) + logout_query = urlencode({"id_token_hint": id_token, "post_logout_redirect_uri": post_logout_redirect_uri}) + logout_url = urlparse(end_session_endpoint)._replace(query=logout_query).geturl() + return redirect(logout_url) + + T = TypeVar("T") P = ParamSpec("P") diff --git a/schemes/config.py b/schemes/config.py index 6e445a22..39eb59c8 100644 --- a/schemes/config.py +++ b/schemes/config.py @@ -2,6 +2,7 @@ class Config: GOVUK_SERVER_METADATA_URL = "https://oidc.integration.account.gov.uk/.well-known/openid-configuration" GOVUK_TOKEN_ENDPOINT = "https://oidc.integration.account.gov.uk/token" GOVUK_PROFILE_URL = "https://home.integration.account.gov.uk/" + GOVUK_END_SESSION_ENDPOINT = "https://oidc.integration.account.gov.uk/logout" class DevConfig(Config): diff --git a/schemes/templates/govuk_one_login_service_header/macro.html b/schemes/templates/govuk_one_login_service_header/macro.html index 065b9bb6..e4e772e4 100644 --- a/schemes/templates/govuk_one_login_service_header/macro.html +++ b/schemes/templates/govuk_one_login_service_header/macro.html @@ -29,6 +29,7 @@ {%- set oneLoginLink = params.oneLoginLink if params.oneLoginLink else "https://home.account.gov.uk/" -%} {%- set homepageLink = params.homepageLink if params.homepageLink else "https://www.gov.uk/" -%} +{%- set signOutLink = params.signOutLink if params.signOutLink else "https://your-service-sign-out-url-goes-here.gov.uk/" -%} {%- macro littlePersonIcon(modifier="default") -%} {%- set class = "focus" if modifier == "focus" else "default" %} {%- set backgroundColour = "black" if modifier == "focus" else "white" %} @@ -84,14 +85,11 @@ {{ littlePersonIcon("focus") }} -{# --- Start change ------------------------------------------------------------------------------------------------ #} -{#
  • #} -{# #} -{# #} -{#
  • #} -{# --- End change -------------------------------------------------------------------------------------------------- #} +
  • + +
  • diff --git a/schemes/templates/service_base.html b/schemes/templates/service_base.html index 64beeb5f..805f3e94 100644 --- a/schemes/templates/service_base.html +++ b/schemes/templates/service_base.html @@ -7,7 +7,12 @@ {% endblock %} {% block header %} - {{ govukOneLoginServiceHeader({"serviceName": "Schemes", "homepageLink": url_for("start.index"), "oneLoginLink": oneLoginLink}) }} + {{ govukOneLoginServiceHeader({ + "serviceName": "Schemes", + "homepageLink": url_for("start.index"), + "oneLoginLink": oneLoginLink, + "signOutLink": url_for("auth.logout") + }) }} {% endblock %} {% block bodyEnd %} diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index c4c36531..d3f4dc49 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -47,6 +47,7 @@ def app_fixture(oidc_server_app: OidcServerFlask) -> Flask: "GOVUK_CLIENT_SECRET": private_key.decode(), "GOVUK_SERVER_METADATA_URL": oidc_server_app.url_for("openid_configuration", _external=True), "GOVUK_TOKEN_ENDPOINT": oidc_server_app.url_for("token", _external=True), + "GOVUK_END_SESSION_ENDPOINT": oidc_server_app.url_for("logout", _external=True), } ) diff --git a/tests/e2e/oidc_server/app.py b/tests/e2e/oidc_server/app.py index 46b3136c..bd4add94 100644 --- a/tests/e2e/oidc_server/app.py +++ b/tests/e2e/oidc_server/app.py @@ -5,7 +5,8 @@ from authlib.jose.rfc7517.key_set import KeySet from authlib.jose.rfc7518.rsa_key import RSAKey from authlib.oauth2.rfc6749.requests import OAuth2Request -from flask import Flask, Response, jsonify, url_for +from flask import Flask, Response, jsonify, redirect, request, url_for +from werkzeug import Response as BaseResponse from tests.e2e.oidc_server.clients import ClientRepository, StubClient from tests.e2e.oidc_server.grants import ( @@ -77,10 +78,10 @@ def _init_grant(self, grant: StubAuthorizationCodeGrant) -> None: grant.authorization_codes = self._authorization_codes grant.users = self._users - def _save_token(self, token: dict[str, str], request: OAuth2Request) -> None: + def _save_token(self, token: dict[str, str], oauth2_request: OAuth2Request) -> None: self._tokens.add( token["access_token"], - StubToken(client_id=request.client.client_id, user_id=request.user.id, scope=token["scope"]), + StubToken(client_id=oauth2_request.client.client_id, user_id=oauth2_request.user.id, scope=token["scope"]), ) @@ -129,6 +130,13 @@ def userinfo() -> Response: def jwks() -> Response: return jsonify(KeySet([key]).as_dict()) + @app.route("/logout") + def logout() -> BaseResponse: + post_logout_redirect_uri = request.args.get("post_logout_redirect_uri") + assert post_logout_redirect_uri is not None + + return redirect(post_logout_redirect_uri) + # register after token endpoint has been defined authorization_server.register_client_auth_method( PrivateKeyJwtClientAssertion.CLIENT_AUTH_METHOD, diff --git a/tests/e2e/pages.py b/tests/e2e/pages.py index 62162354..57746854 100644 --- a/tests/e2e/pages.py +++ b/tests/e2e/pages.py @@ -1,7 +1,7 @@ from __future__ import annotations from flask import Flask -from playwright.sync_api import Page +from playwright.sync_api import Locator, Page class StartPage: @@ -34,6 +34,7 @@ class HomePage: def __init__(self, app: Flask, page: Page): self._app = app self._page = page + self.header = ServiceHeaderComponent(app, page.get_by_role("banner")) def open(self) -> HomePage: self._page.goto(f"{_get_base_url(self._app)}/home") @@ -47,6 +48,16 @@ def visible(self) -> bool: return self._page.get_by_role("heading", name="Home").is_visible() +class ServiceHeaderComponent: + def __init__(self, app: Flask, component: Locator): + self._app = app + self._component = component + + def sign_out(self) -> StartPage: + self._component.get_by_role("link", name="Sign out").click() + return StartPage(self._app, self._component.page) + + class LoginPage: def __init__(self, app: Flask, page: Page): self._app = app diff --git a/tests/e2e/test_home.py b/tests/e2e/test_home.py index e8729cbe..516d135c 100644 --- a/tests/e2e/test_home.py +++ b/tests/e2e/test_home.py @@ -13,6 +13,14 @@ def test_home_when_authenticated(self, app: Flask, page: Page) -> None: assert home_page.visible() + @pytest.mark.usefixtures("live_server", "oidc_server") + def test_header_sign_out(self, app: Flask, page: Page) -> None: + home_page = HomePage(app, page).open() + + start_page = home_page.header.sign_out() + + assert start_page.visible() + class TestUnauthenticated: @pytest.mark.usefixtures("live_server", "oidc_server") diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 178a6988..a9d61721 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -17,6 +17,7 @@ def config_fixture() -> Mapping[str, Any]: "GOVUK_SERVER_METADATA_URL": "test", "GOVUK_TOKEN_ENDPOINT": "test", "GOVUK_PROFILE_URL": "test", + "GOVUK_END_SESSION_ENDPOINT": "test", }