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"