From a7ad073717561cf7d1c775185394f41b06bbf1f1 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 12 Dec 2024 16:01:19 +0000 Subject: [PATCH] Remove connexion https://mhclgdigital.atlassian.net/browse/FSPT-189 In the process of merging authenticator with pre-award-frontend, we found that the versions of Flask and Connexion in use between everything are incompatible. We remove connexion here because it takes us in the general direction of travel (everything is just a Flask app), and allows us to integrate the authenticator code (after this change) with pre-award-frontend (which doesn't use connexion currently). This feels better than adding connexion to pre-award-frontend, to then remove it again later. --- api/__init__.py | 4 +- api/magic_links/routes.py | 13 +- api/session/auth_session.py | 86 ++++++---- api/sso/routes.py | 145 +++++++++------- app.py | 37 ++-- common/__init__.py | 0 common/blueprints.py | 37 ++++ frontend/magic_links/templates/landing.html | 2 +- .../magic_links/templates/landing_eoi.html | 2 +- frontend/sso/routes.py | 2 +- frontend/user/routes.py | 4 +- openapi/api.yml | 145 ---------------- openapi/components.yml | 38 ----- pyproject.toml | 3 - tests/test_healthchecks.py | 21 --- tests/test_magic_links.py | 6 +- tests/test_sso.py | 2 +- uv.lock | 161 ------------------ 18 files changed, 201 insertions(+), 507 deletions(-) create mode 100644 common/__init__.py create mode 100644 common/blueprints.py delete mode 100644 openapi/api.yml delete mode 100644 openapi/components.yml diff --git a/api/__init__.py b/api/__init__.py index e615b9a4..aefe7ce9 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,3 +1,3 @@ from api.magic_links.routes import MagicLinksView # noqa -from api.session.auth_session import AuthSessionView # noqa -from api.sso.routes import SsoView # noqa +from api.session.auth_session import AuthSessionUserView, AuthSessionSignOutView # noqa +from api.sso.routes import SsoLoginView, SsoLogoutView, SsoGraphCallView, SsoGetTokenView # noqa diff --git a/api/magic_links/routes.py b/api/magic_links/routes.py index 406d172a..f74141f9 100644 --- a/api/magic_links/routes.py +++ b/api/magic_links/routes.py @@ -6,15 +6,18 @@ from flask.views import MethodView from fsd_utils.authentication.decorators import login_requested -from api.session.auth_session import AuthSessionView +from api.session.auth_session import AuthSessionBase +from common.blueprints import Blueprint from config import Config from models.account import AccountMethods from models.magic_link import MagicLinkMethods +api_magic_link_bp = Blueprint("api_magic_links", __name__) + class MagicLinksView(MagicLinkMethods, MethodView): @login_requested - def use(self, link_id: str): + def get(self, link_id: str): """ GET /magic-links/{link_id} endpoint If the link_id matches a valid link key, this then: @@ -28,7 +31,6 @@ def use(self, link_id: str): :param link_id: String short key for the link :return: 302 Redirect / 404 Error """ - fund_short_name = request.args.get("fund") round_short_name = request.args.get("round") @@ -62,7 +64,7 @@ def use(self, link_id: str): # Check link is not expired if link.get("exp") > int(datetime.now().timestamp()): - return AuthSessionView.create_session_and_redirect( + return AuthSessionBase.create_session_and_redirect( account=account, is_via_magic_link=True, redirect_url=link.get("redirectUrl"), @@ -105,3 +107,6 @@ def use(self, link_id: str): round=round_short_name, ) ) + + +api_magic_link_bp.add_url_rule("/magic-links/", view_func=MagicLinksView.as_view("use")) diff --git a/api/session/auth_session.py b/api/session/auth_session.py index 0e8f64cf..296c662e 100644 --- a/api/session/auth_session.py +++ b/api/session/auth_session.py @@ -2,12 +2,13 @@ from typing import TYPE_CHECKING import jwt -from flask import abort, current_app, make_response, redirect, request, session, url_for +from flask import current_app, make_response, redirect, request, session, url_for from flask.views import MethodView from fsd_utils import clear_sentry from api.responses import error_response from api.session.exceptions import SessionCreateError +from common.blueprints import Blueprint from config import Config from models.magic_link import MagicLinkMethods from security.utils import create_token, decode_with_options, validate_token @@ -16,28 +17,11 @@ from models.account import Account as Account -class AuthSessionView(MethodView): - """ - Views for session related operations - """ +api_sessions_bp = Blueprint("api_sessions", __name__) - @staticmethod - def user(): - """ - GET /sessions/user endpoint - Shows the user details of the current user session - or an error if no authenticated user session found - :return: 200 user details json or 404 error - """ - token = request.cookies.get(Config.FSD_USER_TOKEN_COOKIE_NAME) - if token: - try: - valid_token = validate_token(token) - return make_response(valid_token), 200 - except jwt.PyJWTError: - error_response(404, "Session token expired or invalid") - error_response(404, "No session token found") +class AuthSessionBase: + @staticmethod def clear_session(return_app=None, return_path=None): """ Clears the user session (signing them out) @@ -90,7 +74,9 @@ def clear_session(return_app=None, return_path=None): ) else: current_app.logger.warning("{return_app} not listed as a safe app.", extra=dict(return_app=return_app)) - abort(400, "Unknown return app.") + resp = make_response({"detail": "Unknown return app."}, 400) + resp.headers["Content-Type"] = "application/json" + return resp # Clear the cookie and redirect to signed out page signed_out_url = url_for( @@ -110,21 +96,6 @@ def clear_session(return_app=None, return_path=None): ) return response - # Deprecation warning (Use clear_session_post instead) - @staticmethod - def clear_session_get(): - """GET /sessions/sign-out endpoint""" - return_app = request.args.get("return_app") - return_path = request.args.get("return_path") - return AuthSessionView.clear_session(return_app, return_path) - - @staticmethod - def clear_session_post(): - """POST /sessions/sign-out endpoint""" - return_app = request.form.get("return_app") - return_path = request.form.get("return_path") - return AuthSessionView.clear_session(return_app, return_path) - @classmethod def create_session_and_redirect( cls, @@ -233,3 +204,44 @@ def create_session_details_with_fund_and_round( session_details.update({"token": create_token(session_details)}) session.update(session_details) return session_details + + +class AuthSessionSignOutView(AuthSessionBase, MethodView): + """ + Views for session related operations + """ + + # Deprecation warning (Use clear_session_post instead) + def get(self): + """GET /sessions/sign-out endpoint""" + return_app = request.args.get("return_app") + return_path = request.args.get("return_path") + return self.clear_session(return_app, return_path) + + def post(self): + """POST /sessions/sign-out endpoint""" + return_app = request.form.get("return_app") + return_path = request.form.get("return_path") + return self.clear_session(return_app, return_path) + + +class AuthSessionUserView(AuthSessionBase, MethodView): + def get(self): + """ + GET /sessions/user endpoint + Shows the user details of the current user session + or an error if no authenticated user session found + :return: 200 user details json or 404 error + """ + token = request.cookies.get(Config.FSD_USER_TOKEN_COOKIE_NAME) + if token: + try: + valid_token = validate_token(token) + return make_response(valid_token), 200 + except jwt.PyJWTError: + error_response(404, "Session token expired or invalid") + error_response(404, "No session token found") + + +api_sessions_bp.add_url_rule("/sessions/user", view_func=AuthSessionUserView.as_view("user")) +api_sessions_bp.add_url_rule("/sessions/sign-out", view_func=AuthSessionSignOutView.as_view("sign_out")) diff --git a/api/sso/routes.py b/api/sso/routes.py index de9faba4..a5f1be4d 100644 --- a/api/sso/routes.py +++ b/api/sso/routes.py @@ -6,30 +6,56 @@ from flask.views import MethodView from fsd_utils import clear_sentry -from api.session.auth_session import AuthSessionView +from api.session.auth_session import AuthSessionBase +from common.blueprints import Blueprint from config import Config from models.account import AccountMethods +api_sso_bp = Blueprint("api_sso", __name__, url_prefix="/sso") -class SsoView(MethodView): - def login(self): - """ - GET /sso/login endpoint - Redirects to the Azure AD auth uri - :return: 302 redirect to Microsoft Login - """ - session["flow"] = self.build_auth_code_flow(scopes=Config.MS_GRAPH_PERMISSIONS_SCOPE) - if return_app := request.args.get("return_app"): - session["return_app"] = return_app - session["return_path"] = request.args.get("return_path") - current_app.logger.debug( - "Setting return app to {return_app} for this session", extra=dict(return_app=return_app) - ) +class SsoBase: + @staticmethod + def _load_cache(): + cache = msal.SerializableTokenCache() + if session.get("token_cache"): + cache.deserialize(session["token_cache"]) + return cache - return redirect(session["flow"]["auth_uri"]), 302 + @staticmethod + def _save_cache(cache): + if cache.has_state_changed: + session["token_cache"] = cache.serialize() - def logout(post_logout_redirect_uri=None): + @staticmethod + def _build_msal_app(cache=None, authority=None): + return msal.ConfidentialClientApplication( + Config.AZURE_AD_CLIENT_ID, + authority=authority or Config.AZURE_AD_AUTHORITY, + client_credential=Config.AZURE_AD_CLIENT_SECRET, + token_cache=cache, + ) + + @staticmethod + def _get_origin_from_url(url): + parsed_uri = urlparse(url) + return f"{parsed_uri.scheme}://{parsed_uri.netloc}" + + def build_auth_code_flow(self, authority=None, scopes=None): + return self._build_msal_app(authority=authority).initiate_auth_code_flow( + scopes or [], redirect_uri=Config.AZURE_AD_REDIRECT_URI + ) + + def _get_token_from_cache(self, scope=None): + cache = self._load_cache() # This web app maintains one cache per session + cca = self._build_msal_app(cache=cache) + accounts = cca.get_accounts() + if accounts: # So all account(s) belong to the current signed-in user + result = cca.acquire_token_silent(scope, account=accounts[0]) + self._save_cache(cache) + return result + + def logout(self, post_logout_redirect_uri=None): """ Clears the user session then redirects to Azure AD logout endpoint to logout from our tenants web session too @@ -57,8 +83,29 @@ def logout(post_logout_redirect_uri=None): clear_sentry() return response + +class SsoLoginView(SsoBase, MethodView): + def get(self): + """ + GET /sso/login endpoint + Redirects to the Azure AD auth uri + :return: 302 redirect to Microsoft Login + """ + session["flow"] = self.build_auth_code_flow(scopes=Config.MS_GRAPH_PERMISSIONS_SCOPE) + + if return_app := request.args.get("return_app"): + session["return_app"] = return_app + session["return_path"] = request.args.get("return_path") + current_app.logger.debug( + "Setting return app to {return_app} for this session", extra=dict(return_app=return_app) + ) + + return redirect(session["flow"]["auth_uri"]), 302 + + +class SsoLogoutView(SsoBase, MethodView): # Deprecation warning (Use logout_post instead) - def logout_get(self): + def get(self): """GET /sso/logout endpoint""" post_logout_redirect_uri = request.args.get( "post_logout_redirect_uri", @@ -66,9 +113,9 @@ def logout_get(self): if "return_app" in session else "", ) - return SsoView.logout(post_logout_redirect_uri) + return self.logout(post_logout_redirect_uri) - def logout_post(self): + def post(self): """POST /sso/logout endpoint""" post_logout_redirect_uri = request.form.get( "post_logout_redirect_uri", @@ -76,9 +123,11 @@ def logout_post(self): if "return_app" in session else "", ) - return SsoView.logout(post_logout_redirect_uri) + return self.logout(post_logout_redirect_uri) - def get_token(self): + +class SsoGetTokenView(SsoBase, MethodView): + def get(self): """ GET /sso/get-token The endpoint that Azure AD redirects back to @@ -127,17 +176,21 @@ def get_token(self): ) else: current_app.logger.warning("{return_app} not listed as a safe app.", extra=dict(return_app=return_app)) - abort(400, "Unknown return app.") + resp = make_response({"detail": "Unknown return app."}, 400) + resp.headers["Content-Type"] = "application/json" + return resp # Create session token, set cookie and redirect - return AuthSessionView.create_session_and_redirect( + return AuthSessionBase.create_session_and_redirect( account=updated_account, redirect_url=redirect_url, is_via_magic_link=False, timeout_seconds=Config.FSD_ASSESSMENT_SESSION_TIMEOUT_SECONDS, ) - def graph_call(self): + +class SsoGraphCallView(SsoBase, MethodView): + def get(self): """ GET /sso/graph-call endpoint Shows the graph object for the current authenticated user @@ -153,42 +206,8 @@ def graph_call(self): ).json() return graph_data, 200 - @staticmethod - def _load_cache(): - cache = msal.SerializableTokenCache() - if session.get("token_cache"): - cache.deserialize(session["token_cache"]) - return cache - - @staticmethod - def _save_cache(cache): - if cache.has_state_changed: - session["token_cache"] = cache.serialize() - - @staticmethod - def _build_msal_app(cache=None, authority=None): - return msal.ConfidentialClientApplication( - Config.AZURE_AD_CLIENT_ID, - authority=authority or Config.AZURE_AD_AUTHORITY, - client_credential=Config.AZURE_AD_CLIENT_SECRET, - token_cache=cache, - ) - - @staticmethod - def _get_origin_from_url(url): - parsed_uri = urlparse(url) - return f"{parsed_uri.scheme}://{parsed_uri.netloc}" - def build_auth_code_flow(self, authority=None, scopes=None): - return self._build_msal_app(authority=authority).initiate_auth_code_flow( - scopes or [], redirect_uri=Config.AZURE_AD_REDIRECT_URI - ) - - def _get_token_from_cache(self, scope=None): - cache = self._load_cache() # This web app maintains one cache per session - cca = self._build_msal_app(cache=cache) - accounts = cca.get_accounts() - if accounts: # So all account(s) belong to the current signed-in user - result = cca.acquire_token_silent(scope, account=accounts[0]) - self._save_cache(cache) - return result +api_sso_bp.add_url_rule("/login", view_func=SsoLoginView.as_view("login")) +api_sso_bp.add_url_rule("/logout", view_func=SsoLogoutView.as_view("logout")) +api_sso_bp.add_url_rule("/get-token", view_func=SsoGetTokenView.as_view("get_token")) +api_sso_bp.add_url_rule("/graph-call", view_func=SsoGraphCallView.as_view("graph_call")) diff --git a/app.py b/app.py index 3356e193..587b8b03 100644 --- a/app.py +++ b/app.py @@ -1,12 +1,7 @@ from copy import deepcopy from os import getenv -from pathlib import Path -from typing import Any, Dict from urllib.parse import urlencode, urljoin -import connexion -import prance -from connexion.resolver import MethodViewResolver from flask import Flask, request from flask_assets import Environment from flask_babel import Babel, gettext @@ -29,31 +24,16 @@ redis_mlinks = FlaskRedis(config_prefix="REDIS_MLINKS") -def get_bundled_specs(main_file: Path) -> Dict[str, Any]: - parser = prance.ResolvingParser(main_file, strict=False) - parser.parse() - return parser.specification - - def create_app() -> Flask: init_sentry() - # Initialise Connexion Flask App - connexion_options = Config.CONNEXION_OPTIONS - connexion_app = connexion.FlaskApp( - "Authenticator", - specification_dir="/openapi/", - options=connexion_options, - server_args={"static_url_path": "/assets"}, - ) - connexion_app.add_api( - get_bundled_specs(Config.FLASK_ROOT + "/openapi/api.yml"), - validate_responses=True, - resolver=MethodViewResolver("api"), + flask_app = Flask( + __name__, + static_url_path="/assets", + static_folder="static", ) # Configure Flask App - flask_app = connexion_app.app flask_app.config.from_object("config.Config") flask_app.static_folder = Config.STATIC_FOLDER flask_app.jinja_loader = ChoiceLoader( @@ -151,6 +131,15 @@ def _get_service_title(): flask_app.register_blueprint(magic_links_bp) flask_app.register_blueprint(sso_bp) flask_app.register_blueprint(user_bp) + + from api.magic_links.routes import api_magic_link_bp + from api.session.auth_session import api_sessions_bp + from api.sso.routes import api_sso_bp + + flask_app.register_blueprint(api_magic_link_bp) + flask_app.register_blueprint(api_sso_bp) + flask_app.register_blueprint(api_sessions_bp) + flask_app.jinja_env.filters["datetime_format"] = datetime_format # Bundle and compile assets diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/common/blueprints.py b/common/blueprints.py new file mode 100644 index 00000000..f52aa378 --- /dev/null +++ b/common/blueprints.py @@ -0,0 +1,37 @@ +from flask.blueprints import Blueprint as FlaskBlueprint +from flask.blueprints import BlueprintSetupState as FlaskBlueprintSetupState + + +class BlueprintSetupState(FlaskBlueprintSetupState): + """Adds the ability to set a hostname on all routes when registering the blueprint.""" + + def __init__(self, blueprint, app, options, first_registration): + super().__init__(blueprint, app, options, first_registration) + + host = self.options.get("host") + self.host = host + + # This creates a 'blueprint_name.static' endpoint. + # The location of the static folder is shared with the app static folder, + # but all static resources will be served via the blueprint's hostname. + if app.url_map.host_matching and not self.blueprint.has_static_folder: + url_prefix = self.url_prefix + self.url_prefix = None + self.add_url_rule( + f"{app.static_url_path}/", + view_func=app.send_static_file, + endpoint="static", + ) + self.url_prefix = url_prefix + + def add_url_rule(self, rule, endpoint=None, view_func=None, **options): + # Ensure that every route registered by this blueprint has the host parameter + options.setdefault("host", self.host) + super().add_url_rule(rule, endpoint, view_func, **options) + + +class Blueprint(FlaskBlueprint): + """A Flask Blueprint class that supports passing a `host` argument when registering blueprints""" + + def make_setup_state(self, app, options, first_registration=False): + return BlueprintSetupState(self, app, options, first_registration) diff --git a/frontend/magic_links/templates/landing.html b/frontend/magic_links/templates/landing.html index 56314106..8b1a9aaf 100644 --- a/frontend/magic_links/templates/landing.html +++ b/frontend/magic_links/templates/landing.html @@ -23,7 +23,7 @@

{{ govukButton({ "text":gettext("Continue"), - "href": url_for('api_MagicLinksView_use', link_id=link_id, fund=fund_short_name, round=round_short_name), + "href": url_for('api_magic_links.use', link_id=link_id, fund=fund_short_name, round=round_short_name), "isStartButton": true }) }}
diff --git a/frontend/magic_links/templates/landing_eoi.html b/frontend/magic_links/templates/landing_eoi.html index d7a9abd6..d1be680c 100644 --- a/frontend/magic_links/templates/landing_eoi.html +++ b/frontend/magic_links/templates/landing_eoi.html @@ -20,7 +20,7 @@

{{ govukButton({ "text":gettext("Continue"), - "href": url_for('api_MagicLinksView_use', link_id=link_id, fund=fund_short_name, round=round_short_name), + "href": url_for('api_magic_links.use', link_id=link_id, fund=fund_short_name, round=round_short_name), "isStartButton": true }) }}
diff --git a/frontend/sso/routes.py b/frontend/sso/routes.py index 8ff31ef5..6247cab8 100644 --- a/frontend/sso/routes.py +++ b/frontend/sso/routes.py @@ -16,7 +16,7 @@ def signed_out(status): render_template( "sso_signed_out.html", status=status, - login_url=url_for("api_sso_routes_SsoView_login", return_app=return_app, return_path=return_path), + login_url=url_for("api_sso.login", return_app=return_app, return_path=return_path), ), 200, ) diff --git a/frontend/user/routes.py b/frontend/user/routes.py index 494f068f..2ae4032a 100644 --- a/frontend/user/routes.py +++ b/frontend/user/routes.py @@ -38,8 +38,8 @@ def user(): "user.html", roles_required=roles_required, logged_in_user=logged_in_user, - login_url=url_for("api_sso_routes_SsoView_login"), - logout_url=url_for("api_sso_routes_SsoView_logout_get"), + login_url=url_for("api_sso.login"), + logout_url=url_for("api_sso.logout"), support_mailbox=Config.SUPPORT_MAILBOX_EMAIL, ), status_code, diff --git a/openapi/api.yml b/openapi/api.yml deleted file mode 100644 index 455c0afa..00000000 --- a/openapi/api.yml +++ /dev/null @@ -1,145 +0,0 @@ -openapi: "3.0.0" -info: - description: Authentication API for DLUHC Funding Service Design - version: "1.0.0" - title: Funding Service Design - Authenticator -tags: - - name: sso - description: Single sign-on operations - - name: magic links - description: Magic link operations - - name: sessions - description: Session operations -paths: - '/magic-links/{link_id}': - get: - tags: - - magic links - summary: Use a magic link - description: Check if link is valid and redirect to url - operationId: api.MagicLinksView.use - responses: - 302: - description: SUCCESS - Redirect valid magic link to requested redirectUrl - 404: - description: ERROR - Magic link expired or invalid - content: - application/json: - schema: - $ref: 'components.yml#/components/schemas/GeneralError' - parameters: - - name: link_id - in: path - required: true - schema: - type: string - format: path - - /sso/login: - get: - tags: - - sso - summary: Microsoft Authentication Library Login redirect - description: Redirect to Microsoft Authentication Login Flow - operationId: api.sso.routes.SsoView.login - parameters: - - in: query - name: return_app - description: Optional parameter to specify the return app - required: false - schema: - type: string - responses: - 302: - description: SUCCESS - Redirect to Microsoft Authentication Login Flow - /sso/logout: - get: - tags: - - sso - summary: Microsoft Authentication Library Logout redirect - description: Redirect to Microsoft Authentication Logout Flow - operationId: api.sso.routes.SsoView.logout_get - responses: - 302: - description: SUCCESS - Redirect to Microsoft Authentication Logout Flow - post: - tags: - - sso - summary: Microsoft Authentication Library Logout redirect - description: Redirect to Microsoft Authentication Logout Flow - operationId: api.sso.routes.SsoView.logout_post - responses: - 302: - description: SUCCESS - Redirect to Microsoft Authentication Logout Flow - /sso/graph-call: - get: - tags: - - sso - summary: Microsoft Authentication Library graph call - description: Return current user session graph object - operationId: api.sso.routes.SsoView.graph_call - responses: - 200: - description: SUCCESS - Valid user graph object - /sso/get-token: - get: - tags: - - sso - summary: Microsoft Authentication Library get token - description: Return current user session authentication token - operationId: api.sso.routes.SsoView.get_token - responses: - 200: - description: SUCCESS - Valid user token - /sessions/user: - get: - tags: - - sessions - summary: Get a users session details - description: Get a users session details - operationId: api.AuthSessionView.user - responses: - 200: - description: SUCCESS - Active user session details returned - 404: - description: ERROR - User session could not be found - /sessions/sign-out: - get: - tags: - - sessions - summary: Signs out a user - description: Signs out a user who has authenticated via a magic link - operationId: api.AuthSessionView.clear_session_get - parameters: - - in: query - name: return_app - description: Optional parameter to specify the return app - required: false - schema: - type: string - responses: - 302: - description: SUCCESS - Active user session cleared and redirected to the signed out page - post: - tags: - - sessions - summary: Signs out a user - description: Signs out a user who has authenticated via a magic link - operationId: api.AuthSessionView.clear_session_post - requestBody: - description: The return app and return path - required: false - content: - application/x-www-form-urlencoded: - schema: - type: object - properties: - return_app: - type: string - description: Optional parameter to specify the return app - return_path: - type: string - description: Optional parameter to specify the return path - responses: - 302: - description: SUCCESS - Active user session cleared and redirected to the signed out page diff --git a/openapi/components.yml b/openapi/components.yml deleted file mode 100644 index 012a4af3..00000000 --- a/openapi/components.yml +++ /dev/null @@ -1,38 +0,0 @@ -components: - schemas: - GeneralError: - type: object - properties: - status: - type: string - code: - type: integer - format: int32 - message: - type: string - MagicLinkCreate: - type: object - properties: - email: - type: string - redirectUrl: - type: string - MagicLink: - type: object - properties: - accountId: - type: string - exp: - type: integer - format: int64 - iat: - type: integer - format: int64 - link: - type: string - key: - type: string - redirectUrl: - type: string - token: - type: string diff --git a/pyproject.toml b/pyproject.toml index 93b006db..9d290e62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,6 @@ license = "MIT License" requires-python = "~=3.10.0" dependencies = [ "beautifulsoup4==4.12.3", - "connexion==2.14.2", "email-validator==1.2.1", "flask-assets==2.0", "flask-compress==1.14", @@ -22,8 +21,6 @@ dependencies = [ "greenlet==3.1.1", "jsmin==3.0.1", "msal==1.28.0", - "openapi-spec-validator==0.4.0", - "prance==0.21.8.0", "pyjwt==2.4.0", "requests==2.32.3", ] diff --git a/tests/test_healthchecks.py b/tests/test_healthchecks.py index 56b2935a..5e1bc31e 100644 --- a/tests/test_healthchecks.py +++ b/tests/test_healthchecks.py @@ -1,10 +1,5 @@ -from unittest import mock - import pytest -from app import create_app -from config import Config - @pytest.mark.app(debug=False) def test_app(app): @@ -21,19 +16,3 @@ def testChecks(self, flask_test_client): } assert response.status_code == 200, "Unexpected response code" assert response.json == expected_dict, "Unexpected json body" - - @mock.patch.object(Config, "CONNEXION_OPTIONS", {}) - def test_swagger_ui_not_published(self): - with create_app().app_context() as app_context: - with app_context.app.test_client() as test_client: - use_endpoint = "/docs" - response = test_client.get(use_endpoint, follow_redirects=True) - assert response.status_code == 404 - - @mock.patch.object(Config, "CONNEXION_OPTIONS", {"swagger_url": "/docs"}) - def test_swagger_ui_is_published(self): - with create_app().app_context() as app_context: - with app_context.app.test_client() as test_client: - use_endpoint = "/docs" - response = test_client.get(use_endpoint, follow_redirects=True) - assert response.status_code == 200 diff --git a/tests/test_magic_links.py b/tests/test_magic_links.py index 25974fbb..704346d4 100644 --- a/tests/test_magic_links.py +++ b/tests/test_magic_links.py @@ -9,7 +9,7 @@ from bs4 import BeautifulSoup import frontend -from api.session.auth_session import AuthSessionView +from api.session.auth_session import AuthSessionBase from app import app from frontend.magic_links.forms import EmailForm from models.account import AccountMethods @@ -18,7 +18,7 @@ @pytest.mark.usefixtures("flask_test_client") @pytest.mark.usefixtures("mock_redis_magic_links") -class TestMagicLinks(AuthSessionView): +class TestMagicLinks(AuthSessionBase): def test_magic_link_redirects_to_landing(self, flask_test_client, create_magic_link): """ GIVEN a running Flask client, redis instance and @@ -29,7 +29,7 @@ def test_magic_link_redirects_to_landing(self, flask_test_client, create_magic_l :param flask_test_client: """ link_key = create_magic_link - use_endpoint = f"/magic-links/landing/{link_key}" + use_endpoint = f"/magic-links/{link_key}" response = flask_test_client.get(use_endpoint) assert response.status_code == 302 diff --git a/tests/test_sso.py b/tests/test_sso.py index e70f1fad..5d1c49d7 100644 --- a/tests/test_sso.py +++ b/tests/test_sso.py @@ -185,7 +185,7 @@ def test_sso_get_token_prevents_overwrite_of_existing_azure_subject_id(flask_tes def test_sso_get_token_500_when_error_in_auth_code_flow(flask_test_client, mocker, caplog): - mock_build_msal_app = mocker.patch("api.sso.routes.SsoView._build_msal_app") + mock_build_msal_app = mocker.patch("api.sso.routes.SsoBase._build_msal_app") mock_msal_app = mock_build_msal_app.return_value mock_msal_app.acquire_token_by_auth_code_flow.return_value = {"error": "some_error"} diff --git a/uv.lock b/uv.lock index 680c4f70..3195f519 100644 --- a/uv.lock +++ b/uv.lock @@ -212,15 +212,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/82/0a0ebd35bae9981dea55c06f8e6aaf44a49171ad798795c72c6f64cba4c2/cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426", size = 7312 }, ] -[[package]] -name = "chardet" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/2d/9cdc2b527e127b4c9db64b86647d567985940ac3698eeabc7ffaccb4ea61/chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", size = 1907771 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/19/c7/fa589626997dd07bd87d9269342ccb74b1720384a4d739a1872bd84fbe68/chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5", size = 178743 }, -] - [[package]] name = "charset-normalizer" version = "2.0.12" @@ -242,19 +233,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/f1/df59e28c642d583f7dacffb1e0965d0e00b218e0186d7858ac5233dce840/click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48", size = 96588 }, ] -[[package]] -name = "clickclick" -version = "20.10.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c6/19/f91d85941b79964d569a3729bf9f8b7f85ab47240248e77b7c0c8ed6ecc3/clickclick-20.10.2.tar.gz", hash = "sha256:4efb13e62353e34c5eef7ed6582c4920b418d7dedc86d819e22ee089ba01802c", size = 9914 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/7e/c08007d3fb2bbefb430437a3573373590abedc03566b785d7d6763b22480/clickclick-20.10.2-py2.py3-none-any.whl", hash = "sha256:c8f33e6d9ec83f68416dd2136a7950125bd256ec39ccc9a85c6e280a16be2bb5", size = 7368 }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -273,26 +251,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/92/dfd892312d822f36c55366118b95d914e5f16de11044a27cf10a7d71bbbf/commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9", size = 51068 }, ] -[[package]] -name = "connexion" -version = "2.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "clickclick" }, - { name = "flask" }, - { name = "inflection" }, - { name = "itsdangerous" }, - { name = "jsonschema" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "werkzeug" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8f/8b/c1d8a2e9327787354e936184f424b1ae96e526a0dad031bbc218c9dcaf35/connexion-2.14.2.tar.gz", hash = "sha256:dbc06f52ebeebcf045c9904d570f24377e8bbd5a6521caef15a06f634cf85646", size = 82819 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/e6/851b3d7688115b176eb5d3e45055d1dc5b2b91708007064a38b0e93813ed/connexion-2.14.2-py2.py3-none-any.whl", hash = "sha256:a73b96a0e07b16979a42cde7c7e26afe8548099e352cf350f80c57185e0e0b36", size = 95127 }, -] - [[package]] name = "cryptography" version = "42.0.4" @@ -551,7 +509,6 @@ version = "0.1.1" source = { virtual = "." } dependencies = [ { name = "beautifulsoup4" }, - { name = "connexion" }, { name = "email-validator" }, { name = "flask" }, { name = "flask-assets" }, @@ -565,8 +522,6 @@ dependencies = [ { name = "greenlet" }, { name = "jsmin" }, { name = "msal" }, - { name = "openapi-spec-validator" }, - { name = "prance" }, { name = "pyjwt" }, { name = "requests" }, ] @@ -594,7 +549,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "beautifulsoup4", specifier = "==4.12.3" }, - { name = "connexion", specifier = "==2.14.2" }, { name = "email-validator", specifier = "==1.2.1" }, { name = "flask", specifier = "==2.2.5" }, { name = "flask-assets", specifier = "==2.0" }, @@ -608,8 +562,6 @@ requires-dist = [ { name = "greenlet", specifier = "==3.1.1" }, { name = "jsmin", specifier = "==3.0.1" }, { name = "msal", specifier = "==1.28.0" }, - { name = "openapi-spec-validator", specifier = "==0.4.0" }, - { name = "prance", specifier = "==0.21.8.0" }, { name = "pyjwt", specifier = "==2.4.0" }, { name = "requests", specifier = "==2.32.3" }, ] @@ -730,15 +682,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/3e/741d8c82801c347547f8a2a06aa57dbb1992be9e948df2ea0eda2c8b79e8/idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0", size = 66836 }, ] -[[package]] -name = "inflection" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454 }, -] - [[package]] name = "iniconfig" version = "1.1.1" @@ -793,19 +736,6 @@ version = "3.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5e/73/e01e4c5e11ad0494f4407a3f623ad4d87714909f50b17a06ed121034ff6e/jsmin-3.0.1.tar.gz", hash = "sha256:c0959a121ef94542e807a674142606f7e90214a2b3d1eb17300244bbb5cc2bfc", size = 13925 } -[[package]] -name = "jsonschema" -version = "4.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "pyrsistent" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b5/a0/dd13abb5f371f980037d271fd09461df18c85188216008a1e3a9c3f8bd0c/jsonschema-4.6.0.tar.gz", hash = "sha256:9d6397ba4a6c0bf0300736057f649e3e12ecbc07d3e81a0dacb72de4e9801957", size = 269939 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/13/0063ea808b6a1bee57e0780d83bf0c57f0ed25a1b5ed3685524359c485fd/jsonschema-4.6.0-py3-none-any.whl", hash = "sha256:1c92d2db1900b668201f1797887d66453ab1fbfea51df8e4b46236689c427baf", size = 80421 }, -] - [[package]] name = "mako" version = "1.3.0" @@ -882,33 +812,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/a8/d3b5baead78adadacb99e7281b3e842126da825cf53df61688cfc8b8ff91/nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e", size = 21875 }, ] -[[package]] -name = "openapi-schema-validator" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jsonschema" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/69/0d/7ec64ebe984c6c0bb3fe239775bed72c94bcdcf954d091c2565eaf613445/openapi-schema-validator-0.2.3.tar.gz", hash = "sha256:2c64907728c3ef78e23711c8840a423f0b241588c9ed929855e4b2d1bb0cf5f2", size = 7503 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/72/a626e98790e7fd6cb07a1e632c50b310febc094f1f03a9910ba94e9fb8d8/openapi_schema_validator-0.2.3-py3-none-any.whl", hash = "sha256:9bae709212a19222892cabcc60cafd903cbf4b220223f48583afa3c0e3cc6fc4", size = 8277 }, -] - -[[package]] -name = "openapi-spec-validator" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jsonschema" }, - { name = "openapi-schema-validator" }, - { name = "pyyaml" }, - { name = "setuptools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/37/41/199441b0ae1f9522ce511fd65cbcd9e8634aed733bd0ab2a9235fe29dec6/openapi-spec-validator-0.4.0.tar.gz", hash = "sha256:97f258850afc97b048f7c2653855e0f88fa66ac103c2be5077c7960aca2ad49a", size = 26679 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/c9/19eb75423082fdbf9c11ed66ab6f038d245023487ce9720b9ced9efcf57e/openapi_spec_validator-0.4.0-py3-none-any.whl", hash = "sha256:06900ac4d546a1df3642a779da0055be58869c598e3042a2fef067cfd99d04d0", size = 31610 }, -] - [[package]] name = "ordered-set" version = "4.1.0" @@ -960,22 +863,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/01/f38e2ff29715251cf25532b9082a1589ab7e4f571ced434f98d0139336dc/pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3", size = 13667 }, ] -[[package]] -name = "prance" -version = "0.21.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "chardet" }, - { name = "requests" }, - { name = "ruamel-yaml" }, - { name = "semver" }, - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/13/ae94094d5a017e838b67631811c66104ec54fb9ede3a604cd8c1b492d39b/prance-0.21.8.0.tar.gz", hash = "sha256:ce06feef8814c3436645f3b094e91067b1a111bc860a51f239f93437a8d4b00e", size = 2798613 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/77/22d5dc162dbb2f094a1525572a78d2ef5aa1f0e06c9c29498647919e540a/prance-0.21.8.0-py3-none-any.whl", hash = "sha256:51ec41d10b317bf5d4e74782a7f7f0c0488c6042433b5b4fde2a988cd069d235", size = 36309 }, -] - [[package]] name = "pre-commit" version = "4.0.1" @@ -1042,19 +929,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/10/a7d0fa5baea8fe7b50f448ab742f26f52b80bfca85ac2be9d35cdd9a3246/pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc", size = 98338 }, ] -[[package]] -name = "pyrsistent" -version = "0.18.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/42/ac/455fdc7294acc4d4154b904e80d964cc9aae75b087bbf486be04df9f2abd/pyrsistent-0.18.1.tar.gz", hash = "sha256:d4d61f8b993a7255ba714df3aca52700f8125289f84f704cf80916517c46eb96", size = 100522 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/39/86ef49a74280102c5f3df6fce0e48e60c6783cffb2b19b8296d895b8d1ca/pyrsistent-0.18.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:df46c854f490f81210870e509818b729db4488e1f30f2a1ce1698b2295a878d1", size = 81388 }, - { url = "https://files.pythonhosted.org/packages/29/2c/62e466b6e2454598c8d69c5806d6ae7066e1de4e4ddd30ea12ad531d18cd/pyrsistent-0.18.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d45866ececf4a5fff8742c25722da6d4c9e180daa7b405dc0a2a2790d668c26", size = 115800 }, - { url = "https://files.pythonhosted.org/packages/d6/77/77b72be7a1564946f0983c50396c7f306209b2e266cd6403f020f7e0f417/pyrsistent-0.18.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ed6784ceac462a7d6fcb7e9b663e93b9a6fb373b7f43594f9ff68875788e01e", size = 112629 }, - { url = "https://files.pythonhosted.org/packages/9c/0b/61dce3fd068e7cd25bfc3626c4f34dac64f9c8fcf53835d417d19e3548fe/pyrsistent-0.18.1-cp310-cp310-win32.whl", hash = "sha256:e4f3149fd5eb9b285d6bfb54d2e5173f6a116fe19172686797c056672689daf6", size = 59419 }, - { url = "https://files.pythonhosted.org/packages/dc/4f/5588cd16135b6d75a042349df7c4e114eb091ffb213e11c2805a44a7e860/pyrsistent-0.18.1-cp310-cp310-win_amd64.whl", hash = "sha256:636ce2dc235046ccd3d8c56a7ad54e99d5c1cd0ef07d9ae847306c91d11b5fec", size = 61607 }, -] - [[package]] name = "pysocks" version = "1.7.1" @@ -1296,32 +1170,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/3f/1996db12d23733e2834b9c2b094cc59c0d1ab943fedafcdb34b5c0da9ebf/rich-12.4.4-py3-none-any.whl", hash = "sha256:d2bbd99c320a2532ac71ff6a3164867884357da3e3301f0240090c5d2fdac7ec", size = 232023 }, ] -[[package]] -name = "ruamel-yaml" -version = "0.17.21" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ruamel-yaml-clib", marker = "platform_python_implementation == 'CPython' and python_full_version == '3.10.*'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/46/a9/6ed24832095b692a8cecc323230ce2ec3480015fbfa4b79941bd41b23a3c/ruamel.yaml-0.17.21.tar.gz", hash = "sha256:8b7ce697a2f212752a35c1ac414471dc16c424c9573be4926b56ff3f5d23b7af", size = 128123 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/cb/938214ac358fbef7058343b3765c79a1b7ed0c366f7f992ce7ff38335652/ruamel.yaml-0.17.21-py3-none-any.whl", hash = "sha256:742b35d3d665023981bd6d16b3d24248ce5df75fdb4e2924e93a05c1f8b61ca7", size = 109478 }, -] - -[[package]] -name = "ruamel-yaml-clib" -version = "0.2.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/31/a3e6411947eb7a4f1c669f887e9e47d61a68f9d117f10c3c620296694a0b/ruamel.yaml.clib-0.2.7.tar.gz", hash = "sha256:1f08fd5a2bea9c4180db71678e850b995d2a5f4537be0e94557668cf0f5f9497", size = 182535 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/a6/e4b98ce7e3d4534e690ec8b01a2ed674dc31ca9aaae0c259c7afc0828cb7/ruamel.yaml.clib-0.2.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5859983f26d8cd7bb5c287ef452e8aacc86501487634573d260968f753e1d71", size = 141289 }, - { url = "https://files.pythonhosted.org/packages/b3/43/e5cc1451acaccb765810715af835da560299afb244444105aaadf599a9dd/ruamel.yaml.clib-0.2.7-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:debc87a9516b237d0466a711b18b6ebeb17ba9f391eb7f91c649c5c4ec5006c7", size = 125839 }, - { url = "https://files.pythonhosted.org/packages/6a/49/66eab405fbf2d086fc616de095a54deedccc970b0f2ff632e77362f3e009/ruamel.yaml.clib-0.2.7-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:df5828871e6648db72d1c19b4bd24819b80a755c4541d3409f0f7acd0f335c80", size = 606485 }, - { url = "https://files.pythonhosted.org/packages/51/9d/f6189b21e8669a7c8b693b86cbf235db7de4229ea006d9085bbe5c05eea8/ruamel.yaml.clib-0.2.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:efa08d63ef03d079dcae1dfe334f6c8847ba8b645d08df286358b1f5293d24ab", size = 485566 }, - { url = "https://files.pythonhosted.org/packages/3b/e4/9833b563dee7302e11f203da63c458edf9e4b608b56af40106e68cf6ffa1/ruamel.yaml.clib-0.2.7-cp310-cp310-win32.whl", hash = "sha256:763d65baa3b952479c4e972669f679fe490eee058d5aa85da483ebae2009d231", size = 92798 }, - { url = "https://files.pythonhosted.org/packages/79/d9/312648cfc9c212988a3564b041bd6a8ca0e266ff42fd7b74bbb3113b300f/ruamel.yaml.clib-0.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:d000f258cf42fec2b1bbf2863c61d7b8918d31ffee905da62dede869254d3b8a", size = 111685 }, -] - [[package]] name = "ruff" version = "0.8.2" @@ -1376,15 +1224,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/33/b9da8be5b122b8c3c82c35f515ba0a84a9af3ba9629ae9fd5bbba820d592/selenium-4.23.1-py3-none-any.whl", hash = "sha256:3a8d9f23dc636bd3840dd56f00c2739e32ec0c1e34a821dd553e15babef24477", size = 9444443 }, ] -[[package]] -name = "semver" -version = "2.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/a9/b61190916030ee9af83de342e101f192bbb436c59be20a4cb0cdb7256ece/semver-2.13.0.tar.gz", hash = "sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f", size = 45816 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/70/b84f9944a03964a88031ef6ac219b6c91e8ba2f373362329d8770ef36f02/semver-2.13.0-py2.py3-none-any.whl", hash = "sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4", size = 12901 }, -] - [[package]] name = "sentry-sdk" version = "2.16.0"