diff --git a/dash_cognito_auth/cognito_oauth.py b/dash_cognito_auth/cognito_oauth.py index eea768b..d0a88c2 100644 --- a/dash_cognito_auth/cognito_oauth.py +++ b/dash_cognito_auth/cognito_oauth.py @@ -1,10 +1,14 @@ +from urllib.parse import quote + from oauthlib.oauth2.rfc6749.errors import InvalidGrantError, TokenExpiredError from dash import Dash from flask import ( redirect, + request, url_for, Response, session, + make_response, ) from .cognito import make_cognito_blueprint, cognito @@ -16,7 +20,14 @@ class CognitoOAuth(Auth): Wraps a Dash App and adds Cognito based OAuth2 authentication. """ - def __init__(self, app: Dash, domain: str, region=None, additional_scopes=None): + def __init__( + self, + app: Dash, + domain: str, + region=None, + additional_scopes=None, + logout_url: str = None, + ): """ Wrap a Dash App with Cognito authentication. @@ -43,6 +54,12 @@ def __init__(self, app: Dash, domain: str, region=None, additional_scopes=None): AWS region of the User Pool. Mandatory if domain is NOT a custom domain, by default None additional_scopes : Additional OAuth Scopes to request, optional By default openid, email, and profile are requested - default value: None + logout_url : str, optional + Add a URL to the app that logs out the user when accessed via HTTP GET. + The URL automatically respects path prefixes, i.e. if your app is hosted + at example.com/some/prefix and you set logout_url to "logout", the actual + URL will be example.com/some/prefix/logout. By default, no logout URL is + added and you will have to create your own. """ super().__init__(app) @@ -62,6 +79,34 @@ def __init__(self, app: Dash, domain: str, region=None, additional_scopes=None): app.server.register_blueprint(cognito_bp, url_prefix=f"{dash_base_path}/login") + if logout_url is not None: + logout_url = ( + dash_base_path.removesuffix("/") + "/" + logout_url.removeprefix("/") + ) + + cognito_hostname = ( + f"{domain}.auth.{region}.amazoncognito.com" + if region is not None + else domain + ) + + @app.server.route(logout_url) + def handle_logout(): + + post_logout_redirect = ( + request.host_url.removesuffix("/") + dash_base_path + ) + cognito_logout_url = ( + f"https://{cognito_hostname}/logout?" + + f"client_id={cognito_bp.client_id}&logout_uri={quote(post_logout_redirect)}" + ) + + response = make_response(redirect(cognito_logout_url)) + + # Invalidate the session cookie + response.set_cookie("session", "empty", max_age=-3600) + return response + def is_authorized(self): if not cognito.authorized: # send to cognito login diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index abbb556..cc92fd1 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -45,6 +45,7 @@ def end_to_end_app() -> CognitoOAuth: dash_app, domain=os.environ["COGNITO_DOMAIN"], region=os.environ["COGNITO_REGION"], + logout_url="logout", ) auth.app.server.config["COGNITO_OAUTH_CLIENT_ID"] = os.environ[ "COGNITO_OAUTH_CLIENT_ID" @@ -70,6 +71,8 @@ def test_end_to_end(end_to_end_app: CognitoOAuth): - Follow the redirect to the authorization endpoint - Follow the redirect to the app home page (logged in) - Check the /session-info endpoint to verify the correct user is logged in + - Call the /logout endpoint to end the current session + - Check a call to the homepage redirects us to the local cognito endpoint """ # Arrange @@ -121,3 +124,14 @@ def test_end_to_end(end_to_end_app: CognitoOAuth): # Verify that the logged in users' email matches the one from the env session_info_response = client.get("/session-info") assert session_info_response.json["email"] == os.environ["COGNITO_EMAIL"] + + # Log out + logout_response = client.get("/logout") + assert logout_response.status_code == HTTPStatus.FOUND + assert "/logout" in logout_response.location + + # Since we're not longer logged in, we should be redirected to the local + # Cognito endpoint. + homepage_response = client.get("/") + assert homepage_response.status_code == HTTPStatus.FOUND + assert homepage_response.location == "/login/cognito" diff --git a/tests/test_end_to_end_with_path_prefix.py b/tests/test_end_to_end_with_path_prefix.py index e455e39..e0fff38 100644 --- a/tests/test_end_to_end_with_path_prefix.py +++ b/tests/test_end_to_end_with_path_prefix.py @@ -49,6 +49,7 @@ def end_to_end_app_with_prefix() -> CognitoOAuth: dash_app, domain=os.environ["COGNITO_DOMAIN"], region=os.environ["COGNITO_REGION"], + logout_url="logout", ) auth.app.server.config["COGNITO_OAUTH_CLIENT_ID"] = os.environ[ "COGNITO_OAUTH_CLIENT_ID" @@ -77,6 +78,8 @@ def test_end_to_end_with_path_prefix(end_to_end_app_with_prefix: CognitoOAuth): - Follow the redirect to the authorization endpoint - Follow the redirect to the app home page (logged in) - Check the /session-info endpoint to verify the correct user is logged in + - Call the /some/prefix/logout endpoint to end the current session + - Check a call to the homepage redirects us to the local cognito endpoint """ # Arrange @@ -129,3 +132,14 @@ def test_end_to_end_with_path_prefix(end_to_end_app_with_prefix: CognitoOAuth): # Verify that the logged in users' email matches the one from the env session_info_response = client.get("/some/prefix/session-info") assert session_info_response.json["email"] == os.environ["COGNITO_EMAIL"] + + # Log out + logout_response = client.get("/some/prefix/logout") + assert logout_response.status_code == HTTPStatus.FOUND + assert "/logout" in logout_response.location + + # Since we're not longer logged in, we should be redirected to the local + # Cognito endpoint. + homepage_response = client.get("/some/prefix/") + assert homepage_response.status_code == HTTPStatus.FOUND + assert homepage_response.location == "/some/prefix/login/cognito"