diff --git a/cg/server/app.py b/cg/server/app.py index 14237b1352..de9f47d54c 100644 --- a/cg/server/app.py +++ b/cg/server/app.py @@ -1,9 +1,6 @@ import coloredlogs -import requests -from flask import Flask, redirect, session, url_for +from flask import Flask from flask_admin.base import AdminIndexView -from flask_dance.consumer import oauth_authorized -from flask_dance.contrib.google import google, make_google_blueprint from sqlalchemy.orm import scoped_session from cg.server import admin, ext, invoices @@ -16,6 +13,8 @@ from cg.server.endpoints.pools import POOLS_BLUEPRINT from cg.server.endpoints.samples import SAMPLES_BLUEPRINT from cg.server.endpoints.users import USERS_BLUEPRINT +from cg.server.endpoints.index import INDEX_BLUEPRINT +from cg.server.endpoints.authentication import AUTH_BLUEPRINT from cg.store.database import get_scoped_session_registry from cg.store.models import ( Analysis, @@ -60,9 +59,6 @@ def _load_config(app: Flask): def _configure_extensions(app: Flask): _initialize_logging(app) - certs_resp = requests.get("https://www.googleapis.com/oauth2/v1/certs") - app.config["GOOGLE_OAUTH_CERTS"] = certs_resp.json() - ext.cors.init_app(app) ext.csrf.init_app(app) ext.db.init_app(app) @@ -77,22 +73,9 @@ def _initialize_logging(app): def _register_blueprints(app: Flask): - oauth_bp = make_google_blueprint( - client_id=app.config["google_oauth_client_id"], - client_secret=app.config["google_oauth_client_secret"], - scope=["openid", "https://www.googleapis.com/auth/userinfo.email"], - ) - - @oauth_authorized.connect_via(oauth_bp) - def logged_in(blueprint, token): - """Called when the user logs in via Google OAuth.""" - resp = google.get("/oauth2/v1/userinfo?alt=json") - assert resp.ok, resp.text - user_data = resp.json() - session["user_email"] = user_data["email"] - + app.register_blueprint(INDEX_BLUEPRINT) + app.register_blueprint(AUTH_BLUEPRINT) app.register_blueprint(invoices.BLUEPRINT, url_prefix="/invoices") - app.register_blueprint(oauth_bp, url_prefix="/login") app.register_blueprint(APPLICATIONS_BLUEPRINT) app.register_blueprint(CASES_BLUEPRINT) app.register_blueprint(ORDERS_BLUEPRINT) @@ -112,15 +95,7 @@ def logged_in(blueprint, token): ext.csrf.exempt(ANALYSES_BLUEPRINT) ext.csrf.exempt(USERS_BLUEPRINT) - @app.route("/") - def index(): - return redirect(url_for("admin.index")) - - @app.route("/logout") - def logout(): - """Log out the user.""" - session["user_email"] = None - return redirect(url_for("index")) + def _register_admin_views(): diff --git a/cg/server/app_config.py b/cg/server/app_config.py index 97f38b3235..510dec4b2b 100644 --- a/cg/server/app_config.py +++ b/cg/server/app_config.py @@ -1,3 +1,4 @@ +from cycler import K from pydantic_settings import BaseSettings @@ -16,8 +17,6 @@ class AppConfig(BaseSettings): lims_password: str = "password" support_system_email: str = "support@mail.com" email_uri: str = "smtp://localhost" - google_oauth_client_id: str = "client_id" - google_oauth_client_secret: str = "client_secret" trailblazer_host: str = "trailblazer_host" trailblazer_service_account: str = "service_account" trailblazer_service_account_auth_file: str = "auth_file.json" @@ -25,6 +24,10 @@ class AppConfig(BaseSettings): freshdesk_api_key: str = "freshdesk_api_key" freshdesk_order_email_id: int = 10 freshdesk_environment: str = "Stage" - + keycloak_client_url: str = "server_url" + keycloak_realm_name = "realm" + keycloak_client_id = "client" + keycloak_client_secret_key = "client_secret" + keycloak_redirect_uri = "redirect_uri" app_config = AppConfig() diff --git a/cg/server/endpoints/authentication.py b/cg/server/endpoints/authentication.py new file mode 100644 index 0000000000..735a8e583f --- /dev/null +++ b/cg/server/endpoints/authentication.py @@ -0,0 +1,33 @@ +from flask import request, redirect, session, Blueprint + +from cg.server.ext import auth_service + + + +AUTH_BLUEPRINT = Blueprint('auth', __name__, url_prefix='/auth') + +@AUTH_BLUEPRINT.route('/login') +def login(): + """Redirect the user to the auth service login page.""" + auth_url = auth_service.get_auth_url() + return redirect(auth_url) + +@AUTH_BLUEPRINT.route('/callback') +def callback(): + code = request.args.get('code') + if code: + token = auth_service.get_token(code) + session['token'] = token + userinfo = auth_service.get_user_info(token) + session['userinfo'] = userinfo + return redirect('/') + return 'Authentication failed', 401 + +@AUTH_BLUEPRINT.route('/logout') +def logout(): + """Logout the user from the auth service.""" + token = session.get('token') + if token: + auth_service.logout_user(token) + session.clear() + return redirect('/') \ No newline at end of file diff --git a/cg/server/endpoints/index.py b/cg/server/endpoints/index.py new file mode 100644 index 0000000000..08a5562730 --- /dev/null +++ b/cg/server/endpoints/index.py @@ -0,0 +1,8 @@ +from flask import Flask, redirect, url_for, Blueprint + + +INDEX_BLUEPRINT = Blueprint("index", __name__) + +@INDEX_BLUEPRINT.route("/") +def index(): + return redirect(url_for("admin.index")) \ No newline at end of file diff --git a/cg/server/endpoints/utils.py b/cg/server/endpoints/utils.py index c538fd5d52..1fa86c327d 100644 --- a/cg/server/endpoints/utils.py +++ b/cg/server/endpoints/utils.py @@ -3,14 +3,15 @@ from http import HTTPStatus import cachecontrol +from keycloak import KeycloakError import requests from flask import abort, current_app, g, jsonify, make_response, request -from google.auth import exceptions -from google.auth.transport import requests as google_requests -from google.oauth2 import id_token -from cg.server.ext import db -from cg.store.models import User +from cg.server.ext import auth_service +from cg.services.authentication.models import AuthenticatedUser + + + LOG = logging.getLogger(__name__) @@ -18,11 +19,6 @@ cached_session = cachecontrol.CacheControl(session) -def verify_google_token(token): - request = google_requests.Request(session=cached_session) - return id_token.verify_oauth2_token(id_token=token, request=request) - - def is_public(route_function): @wraps(route_function) def public_endpoint(*args, **kwargs): @@ -54,17 +50,19 @@ def before_request(): jwt_token = auth_header.split("Bearer ")[-1] try: - user_data = verify_google_token(jwt_token) - except (exceptions.OAuthError, ValueError) as e: - LOG.error(f"Error {e} occurred while decoding JWT token: {jwt_token}") + user: User = auth_service.verify_token(jwt_token) + + except ValueError as error: return abort( - make_response(jsonify(message="outdated login certificate"), HTTPStatus.UNAUTHORIZED) + make_response(jsonify(message=str(error)), HTTPStatus.FORBIDDEN) + ) + except KeycloakError as error: + return abort( + make_response(jsonify(message=str(error)), HTTPStatus.UNAUTHORIZED) + ) + except Exception as error: + return abort( + make_response(jsonify(message=str(error)), HTTPStatus.INTERNAL_SERVER_ERROR) ) - - user: User = db.get_user_by_email(user_data["email"]) - if user is None or not user.order_portal_login: - message = f"{user_data['email']} doesn't have access" - LOG.error(message) - return abort(make_response(jsonify(message=message), HTTPStatus.FORBIDDEN)) g.current_user = user diff --git a/cg/server/ext.py b/cg/server/ext.py index a1f0d97cec..9bb3e58679 100644 --- a/cg/server/ext.py +++ b/cg/server/ext.py @@ -4,12 +4,14 @@ from flask_admin import Admin from flask_cors import CORS from flask_wtf.csrf import CSRFProtect +from keycloak import KeycloakOpenID from cg.apps.lims import LimsAPI from cg.apps.tb.api import TrailblazerAPI from cg.clients.freshdesk.freshdesk_client import FreshdeskClient from cg.server.app_config import app_config from cg.services.application.service import ApplicationsWebService +from cg.services.authentication.service import AuthenticationService from cg.services.delivery_message.delivery_message_service import DeliveryMessageService from cg.services.orders.order_service.order_service import OrderService from cg.services.orders.order_summary_service.order_summary_service import OrderSummaryService @@ -23,6 +25,7 @@ SampleRunMetricsService, ) from cg.services.sample_service.sample_service import SampleService +from cg.services.user.service import UserService from cg.store.database import initialize_database from cg.store.store import Store @@ -108,3 +111,12 @@ def init_app(self, app): system_email_id=app_config.freshdesk_order_email_id, env=app_config.freshdesk_environment, ) +user_service = UserService(store=db) +auth_service = AuthenticationService( + user_service=user_service, + server_url=app_config.keycloak_client_url, + client_id=app_config.keycloak_client_id, + client_secret=app_config.keycloak_client_secret_key, + realm_name=app_config.keycloak_realm_name, + redirect_uri=app_config.keycloak_redirect_uri, +) \ No newline at end of file diff --git a/cg/server/templates/admin/index.html b/cg/server/templates/admin/index.html index a54c21a554..6ee696a2d9 100644 --- a/cg/server/templates/admin/index.html +++ b/cg/server/templates/admin/index.html @@ -1,13 +1,13 @@ {% extends 'admin/master.html' %} {% block body %} - {% if session.user_email %} -
Welcome {{ session.user_email }}!
- Logout + {% if session.userinfo %} +Welcome {{ session.userinfo.name }}!
+ Logout {% else %} - Login + Login {% endif %} {% endblock %} diff --git a/cg/services/authentication/service.py b/cg/services/authentication/service.py new file mode 100644 index 0000000000..12c112d4a7 --- /dev/null +++ b/cg/services/authentication/service.py @@ -0,0 +1,88 @@ +from cg.services.user.service import UserService +from keycloak import KeycloakOpenID + +from cg.store.models import User + + +class AuthenticationService: + """Authentication service user to verify tokens against keycloak and return user information.""" + + def __init__( + self, user_service: UserService, server_url: str, client_id: str, client_secret: str, realm_name: str, redirect_uri: str, + ): + """_summary_ + + Args: + store (Store): Connection to statusDB + server_url (str): server url to the keycloak server or container + realm_name (str): the keycloak realm to connect to (can be found in keycloak) + client_id (str): the client id to use in keycloak realm (can be found in keycloak) + client_secret (str): the client secret to use in keycloak realm (can be found in keycloak) + redirect_uri (str): the redirect uri to use + """ + self.user_service: UserService = user_service + self.server_url: str = server_url + self.client_id: str = client_id + self.client_secret: str = client_secret + self.realm_name: str = realm_name + self.redirect_uri: str = redirect_uri + self.client: KeycloakOpenID = self._get_client() + + def _get_client(self): + """Set the KeycloakOpenID client. + """ + keycloak_openid_client = KeycloakOpenID( + server_url=self.server_url, + client_id=self.client_id, + realm_name=self.realm_name, + client_secret_key=self.client_secret, + ) + return keycloak_openid_client + + def verify_token(self, token: str) -> User: + """Verify the token and return the user. + args: + token: str + returns: + AuthenticatedUser + raises: + ValueError: if the token is not active + """ + token_info = self.client.introspect(token) + if not token_info['active']: + raise ValueError('Token is not active') + verified_token = self.client.decode_token(token) + user_email = verified_token["email"] + return self.user_service.get_user_by_email(user_email) + + + + def get_auth_url(self): + """Get the authentication url. + """ + return self.client.auth_url( + redirect_uri=self.redirect_uri, + scope="openid profile email" + ) + + def logout_user(self, token: str): + """Logout the user. + """ + self.client.logout(token) + + def get_user_info(self, token): + """ + Get the user information. + """ + return self.client.userinfo(token["access_token"]) + + def get_token(self, code: str): + """ + Get the token the user token. + """ + return self.client.token( + grant_type='authorization_code', + code=code, + redirect_uri=self.redirect_uri + ) + \ No newline at end of file diff --git a/cg/services/user/service.py b/cg/services/user/service.py new file mode 100644 index 0000000000..8202e93100 --- /dev/null +++ b/cg/services/user/service.py @@ -0,0 +1,28 @@ +from cg.store.models import User +from cg.store.store import Store + + +class UserService: + """Service to handle user related operations.""" + + + def __init__(self, store: Store): + self.store = store + + def get_user_by_email(self, email: str): + """ + Get user by email. + args: + email: str + returns: + User + raises: + ValueError: if the user is not found + """ + user: User | None = self.store.get_user_by_email(email) + if not user: + raise ValueError(f"User with email {email} not found") + return user + + + \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index f315ea9994..da8ed7233d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,17 @@ -# This file is automatically @generated by Poetry 2.0.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. + +[[package]] +name = "aiofiles" +version = "24.1.0" +description = "File support for asyncio." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" +files = [ + {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, + {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, +] [[package]] name = "alembic" @@ -34,6 +47,43 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "anyio" +version = "4.8.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" +files = [ + {file = "anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a"}, + {file = "anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] +trio = ["trio (>=0.26.1)"] + +[[package]] +name = "async-property" +version = "0.2.2" +description = "Python decorator for async properties." +optional = false +python-versions = "*" +groups = ["main"] +markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" +files = [ + {file = "async_property-0.2.2-py2.py3-none-any.whl", hash = "sha256:8924d792b5843994537f8ed411165700b27b2bd966cefc4daeefc1253442a9d7"}, + {file = "async_property-0.2.2.tar.gz", hash = "sha256:17d9bd6ca67e27915a75d92549df64b5c7174e9dc806b30a3934dc4ff0506380"}, +] + [[package]] name = "bcrypt" version = "4.2.1" @@ -223,7 +273,7 @@ description = "Validate configuration and produce human readable error messages. optional = true python-versions = ">=3.8" groups = ["main"] -markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"pre-commit\"" +markers = "python_version <= \"3.11\" and extra == \"pre-commit\" or python_version >= \"3.12\" and extra == \"pre-commit\"" files = [ {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, @@ -355,7 +405,7 @@ description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main"] -markers = "(extra == \"pytest-cov\" or platform_system == \"Windows\" or extra == \"pytest-mock\" or extra == \"pytest\" or extra == \"pytest-xdist\") and (sys_platform == \"win32\" or platform_system == \"Windows\") and (python_version <= \"3.11\" or python_version >= \"3.12\")" +markers = "python_version <= \"3.11\" and extra == \"pytest-cov\" and sys_platform == \"win32\" or python_version <= \"3.11\" and platform_system == \"Windows\" or python_version <= \"3.11\" and extra == \"pytest-mock\" and sys_platform == \"win32\" or python_version <= \"3.11\" and extra == \"pytest\" and sys_platform == \"win32\" or python_version <= \"3.11\" and extra == \"pytest-xdist\" and sys_platform == \"win32\" or python_version >= \"3.12\" and extra == \"pytest-cov\" and sys_platform == \"win32\" or python_version >= \"3.12\" and platform_system == \"Windows\" or python_version >= \"3.12\" and extra == \"pytest-mock\" and sys_platform == \"win32\" or python_version >= \"3.12\" and extra == \"pytest\" and sys_platform == \"win32\" or python_version >= \"3.12\" and extra == \"pytest-xdist\" and sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -387,7 +437,7 @@ description = "Code coverage measurement for Python" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "(extra == \"coveralls\" or extra == \"pytest-cov\") and (python_version <= \"3.11\" or python_version >= \"3.12\")" +markers = "python_version <= \"3.11\" and extra == \"coveralls\" or python_version <= \"3.11\" and extra == \"pytest-cov\" or python_version >= \"3.12\" and extra == \"coveralls\" or python_version >= \"3.12\" and extra == \"pytest-cov\"" files = [ {file = "coverage-7.6.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5c912978f7fbf47ef99cec50c4401340436d200d41d714c7a4766f377c5b7b78"}, {file = "coverage-7.6.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a01ec4af7dfeb96ff0078ad9a48810bb0cc8abcb0115180c6013a6b26237626c"}, @@ -466,7 +516,7 @@ description = "Show coverage stats online via coveralls.io" optional = true python-versions = "<3.13,>=3.8" groups = ["main"] -markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"coveralls\"" +markers = "python_version <= \"3.11\" and extra == \"coveralls\" or python_version >= \"3.12\" and extra == \"coveralls\"" files = [ {file = "coveralls-4.0.1-py3-none-any.whl", hash = "sha256:7a6b1fa9848332c7b2221afb20f3df90272ac0167060f41b5fe90429b30b1809"}, {file = "coveralls-4.0.1.tar.gz", hash = "sha256:7b2a0a2bcef94f295e3cf28dcc55ca40b71c77d1c2446b538e85f0f7bc21aa69"}, @@ -531,6 +581,22 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "deprecation" +version = "2.1.0" +description = "A library to handle automated deprecations" +optional = false +python-versions = "*" +groups = ["main"] +markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" +files = [ + {file = "deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a"}, + {file = "deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff"}, +] + +[package.dependencies] +packaging = "*" + [[package]] name = "distlib" version = "0.3.9" @@ -538,7 +604,7 @@ description = "Distribution utilities" optional = true python-versions = "*" groups = ["main"] -markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"pre-commit\"" +markers = "python_version <= \"3.11\" and extra == \"pre-commit\" or python_version >= \"3.12\" and extra == \"pre-commit\"" files = [ {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, @@ -573,7 +639,7 @@ description = "Pythonic argument parser, that will make you smile" optional = true python-versions = "*" groups = ["main"] -markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"coveralls\"" +markers = "python_version <= \"3.11\" and extra == \"coveralls\" or python_version >= \"3.12\" and extra == \"coveralls\"" files = [ {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, ] @@ -612,10 +678,10 @@ files = [ name = "exceptiongroup" version = "1.2.2" description = "Backport of PEP 654 (exception groups)" -optional = true +optional = false python-versions = ">=3.7" groups = ["main"] -markers = "(extra == \"pytest-cov\" or extra == \"pytest-mock\" or extra == \"pytest\" or extra == \"pytest-xdist\") and python_version < \"3.11\"" +markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -631,7 +697,7 @@ description = "execnet: rapid multi-Python deployment" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"pytest-xdist\"" +markers = "python_version <= \"3.11\" and extra == \"pytest-xdist\" or python_version >= \"3.12\" and extra == \"pytest-xdist\"" files = [ {file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"}, {file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"}, @@ -647,7 +713,7 @@ description = "A platform independent file lock." optional = true python-versions = ">=3.8" groups = ["main"] -markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"pre-commit\"" +markers = "python_version <= \"3.11\" and extra == \"pre-commit\" or python_version >= \"3.12\" and extra == \"pre-commit\"" files = [ {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, @@ -817,7 +883,7 @@ description = "Lightweight in-process concurrent programming" optional = false python-versions = ">=3.7" groups = ["main"] -markers = "(platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\") and (python_version <= \"3.11\" or python_version >= \"3.12\")" +markers = "python_version <= \"3.11\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\") or python_version >= \"3.12\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")" files = [ {file = "greenlet-3.1.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563"}, {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83"}, @@ -921,6 +987,19 @@ setproctitle = ["setproctitle"] testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] tornado = ["tornado (>=0.2)"] +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + [[package]] name = "housekeeper" version = "4.13.2" @@ -944,6 +1023,55 @@ pyyaml = "*" rich = "*" SQLAlchemy = "*" +[[package]] +name = "httpcore" +version = "1.0.7" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" +files = [ + {file = "httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"}, + {file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "humanfriendly" version = "10.0" @@ -967,7 +1095,7 @@ description = "File identification library for Python" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"pre-commit\"" +markers = "python_version <= \"3.11\" and extra == \"pre-commit\" or python_version >= \"3.12\" and extra == \"pre-commit\"" files = [ {file = "identify-2.6.5-py2.py3-none-any.whl", hash = "sha256:14181a47091eb75b337af4c23078c9d09225cd4c48929f521f3bf16b09d02566"}, {file = "identify-2.6.5.tar.gz", hash = "sha256:c10b33f250e5bba374fae86fb57f3adcebf1161bce7cdf92031915fd480c13bc"}, @@ -1024,7 +1152,7 @@ description = "brain-dead simple config-ini parsing" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "(extra == \"pytest-cov\" or extra == \"pytest-mock\" or extra == \"pytest\" or extra == \"pytest-xdist\") and (python_version <= \"3.11\" or python_version >= \"3.12\")" +markers = "python_version <= \"3.11\" and extra == \"pytest-cov\" or python_version <= \"3.11\" and extra == \"pytest-mock\" or python_version <= \"3.11\" and extra == \"pytest\" or python_version <= \"3.11\" and extra == \"pytest-xdist\" or python_version >= \"3.12\" and extra == \"pytest-cov\" or python_version >= \"3.12\" and extra == \"pytest-mock\" or python_version >= \"3.12\" and extra == \"pytest\" or python_version >= \"3.12\" and extra == \"pytest-xdist\"" files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, @@ -1062,6 +1190,23 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "jwcrypto" +version = "1.5.6" +description = "Implementation of JOSE Web standards" +optional = false +python-versions = ">= 3.8" +groups = ["main"] +markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" +files = [ + {file = "jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789"}, + {file = "jwcrypto-1.5.6.tar.gz", hash = "sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039"}, +] + +[package.dependencies] +cryptography = ">=3.4" +typing-extensions = ">=4.5.0" + [[package]] name = "lxml" version = "5.3.0" @@ -1378,7 +1523,7 @@ description = "Rolling backport of unittest.mock for all Pythons" optional = true python-versions = ">=3.6" groups = ["main"] -markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"mock\"" +markers = "python_version <= \"3.11\" and extra == \"mock\" or python_version >= \"3.12\" and extra == \"mock\"" files = [ {file = "mock-5.1.0-py3-none-any.whl", hash = "sha256:18c694e5ae8a208cdb3d2c20a993ca1a7b0efa258c247a1e565150f477f83744"}, {file = "mock-5.1.0.tar.gz", hash = "sha256:5e96aad5ccda4718e0a229ed94b2024df75cc2d55575ba5762d31f5767b8767d"}, @@ -1471,7 +1616,7 @@ description = "Node.js virtual environment builder" optional = true python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main"] -markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"pre-commit\"" +markers = "python_version <= \"3.11\" and extra == \"pre-commit\" or python_version >= \"3.12\" and extra == \"pre-commit\"" files = [ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, @@ -1776,7 +1921,7 @@ description = "A small Python package for determining appropriate platform-speci optional = true python-versions = ">=3.8" groups = ["main"] -markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"pre-commit\"" +markers = "python_version <= \"3.11\" and extra == \"pre-commit\" or python_version >= \"3.12\" and extra == \"pre-commit\"" files = [ {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, @@ -1794,7 +1939,7 @@ description = "plugin and hook calling mechanisms for python" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "(extra == \"pytest-cov\" or extra == \"pytest-mock\" or extra == \"pytest\" or extra == \"pytest-xdist\") and (python_version <= \"3.11\" or python_version >= \"3.12\")" +markers = "python_version <= \"3.11\" and extra == \"pytest-cov\" or python_version <= \"3.11\" and extra == \"pytest-mock\" or python_version <= \"3.11\" and extra == \"pytest\" or python_version <= \"3.11\" and extra == \"pytest-xdist\" or python_version >= \"3.12\" and extra == \"pytest-cov\" or python_version >= \"3.12\" and extra == \"pytest-mock\" or python_version >= \"3.12\" and extra == \"pytest\" or python_version >= \"3.12\" and extra == \"pytest-xdist\"" files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -1811,7 +1956,7 @@ description = "A framework for managing and maintaining multi-language pre-commi optional = true python-versions = ">=3.9" groups = ["main"] -markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"pre-commit\"" +markers = "python_version <= \"3.11\" and extra == \"pre-commit\" or python_version >= \"3.12\" and extra == \"pre-commit\"" files = [ {file = "pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878"}, {file = "pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2"}, @@ -2102,7 +2247,7 @@ description = "A python implementation of GNU readline." optional = false python-versions = ">=3.8" groups = ["main"] -markers = "sys_platform == \"win32\" and (python_version <= \"3.11\" or python_version >= \"3.12\")" +markers = "python_version <= \"3.11\" and sys_platform == \"win32\" or python_version >= \"3.12\" and sys_platform == \"win32\"" files = [ {file = "pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6"}, {file = "pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7"}, @@ -2118,7 +2263,7 @@ description = "pytest: simple powerful testing with Python" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "(extra == \"pytest-cov\" or extra == \"pytest-mock\" or extra == \"pytest\" or extra == \"pytest-xdist\") and (python_version <= \"3.11\" or python_version >= \"3.12\")" +markers = "python_version <= \"3.11\" and extra == \"pytest-cov\" or python_version <= \"3.11\" and extra == \"pytest-mock\" or python_version <= \"3.11\" and extra == \"pytest\" or python_version <= \"3.11\" and extra == \"pytest-xdist\" or python_version >= \"3.12\" and extra == \"pytest-cov\" or python_version >= \"3.12\" and extra == \"pytest-mock\" or python_version >= \"3.12\" and extra == \"pytest\" or python_version >= \"3.12\" and extra == \"pytest-xdist\"" files = [ {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, @@ -2142,7 +2287,7 @@ description = "Pytest plugin for measuring coverage." optional = true python-versions = ">=3.9" groups = ["main"] -markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"pytest-cov\"" +markers = "python_version <= \"3.11\" and extra == \"pytest-cov\" or python_version >= \"3.12\" and extra == \"pytest-cov\"" files = [ {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, @@ -2162,7 +2307,7 @@ description = "Thin-wrapper around the mock package for easier use with pytest" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"pytest-mock\"" +markers = "python_version <= \"3.11\" and extra == \"pytest-mock\" or python_version >= \"3.12\" and extra == \"pytest-mock\"" files = [ {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, @@ -2181,7 +2326,7 @@ description = "pytest xdist plugin for distributed testing, most importantly acr optional = true python-versions = ">=3.8" groups = ["main"] -markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"pytest-xdist\"" +markers = "python_version <= \"3.11\" and extra == \"pytest-xdist\" or python_version >= \"3.12\" and extra == \"pytest-xdist\"" files = [ {file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"}, {file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"}, @@ -2228,6 +2373,28 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-keycloak" +version = "5.1.2" +description = "python-keycloak is a Python package providing access to the Keycloak API." +optional = false +python-versions = "<4.0,>=3.9" +groups = ["main"] +markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" +files = [ + {file = "python_keycloak-5.1.2-py3-none-any.whl", hash = "sha256:a8c9a7de0d9dee75657ac1327e09955eb187593f683360a8b9ed1729ad17b4d8"}, + {file = "python_keycloak-5.1.2.tar.gz", hash = "sha256:fa59884cd01f4d84a0bde169bc0e799fd1974a312e0f0e2bdb871c58fd53c35a"}, +] + +[package.dependencies] +aiofiles = ">=24.1.0" +async-property = ">=0.2.2" +deprecation = ">=2.1.0" +httpx = ">=0.23.2" +jwcrypto = ">=1.5.4" +requests = ">=2.20.0" +requests-toolbelt = ">=0.6.0" + [[package]] name = "pytz" version = "2024.2" @@ -2348,6 +2515,22 @@ requests = ">=2.0.0" [package.extras] rsa = ["oauthlib[signedtoken] (>=3.0.0)"] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +description = "A utility belt for advanced users of python-requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] +markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" +files = [ + {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, + {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, +] + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + [[package]] name = "rich" version = "13.9.4" @@ -2414,7 +2597,7 @@ description = "An extremely fast Python linter and code formatter, written in Ru optional = true python-versions = ">=3.7" groups = ["main"] -markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"ruff\"" +markers = "python_version <= \"3.11\" and extra == \"ruff\" or python_version >= \"3.12\" and extra == \"ruff\"" files = [ {file = "ruff-0.9.1-py3-none-linux_armv6l.whl", hash = "sha256:84330dda7abcc270e6055551aca93fdde1b0685fc4fd358f26410f9349cf1743"}, {file = "ruff-0.9.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3cae39ba5d137054b0e5b472aee3b78a7c884e61591b100aeb544bcd1fc38d4f"}, @@ -2449,6 +2632,19 @@ files = [ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_version <= \"3.11\" or python_version >= \"3.12\"" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + [[package]] name = "sqlalchemy" version = "2.0.37" @@ -2569,7 +2765,7 @@ description = "A lil' TOML parser" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "(extra == \"coveralls\" or extra == \"pytest-cov\" or extra == \"pytest-mock\" or extra == \"pytest\" or extra == \"pytest-xdist\") and python_version < \"3.11\"" +markers = "extra == \"coveralls\" and python_full_version <= \"3.11.0a6\" or extra == \"pytest-cov\" and python_full_version <= \"3.11.0a6\" or python_version < \"3.11\" and extra == \"pytest-mock\" or python_version < \"3.11\" and extra == \"pytest\" or python_version < \"3.11\" and extra == \"pytest-xdist\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -2669,7 +2865,7 @@ description = "Virtual Python Environment builder" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "(python_version <= \"3.11\" or python_version >= \"3.12\") and extra == \"pre-commit\"" +markers = "python_version <= \"3.11\" and extra == \"pre-commit\" or python_version >= \"3.12\" and extra == \"pre-commit\"" files = [ {file = "virtualenv-20.28.1-py3-none-any.whl", hash = "sha256:412773c85d4dab0409b83ec36f7a6499e72eaf08c80e81e9576bca61831c71cb"}, {file = "virtualenv-20.28.1.tar.gz", hash = "sha256:5d34ab240fdb5d21549b76f9e8ff3af28252f5499fb6d6f031adac4e5a8c5329"}, @@ -2756,4 +2952,4 @@ ruff = ["ruff"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<3.13" -content-hash = "5fe4fc06f95ac9872370b1a56a363a59902ba5eb3e28dd99885bfbca1780fd7e" +content-hash = "5587fe30c398d181222742c2ec299eebd910664a72a6556c64c8af78abfa0a89" diff --git a/pyproject.toml b/pyproject.toml index 35a226f9b5..62a78cd7df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ dependencies = [ "pydantic-settings>=2.3.3", "email-validator>=2.2.0", "rich-click>=1.8.4", +"python-keycloak (>=5.1.2,<6.0.0)", ] [project.optional-dependencies] coveralls = ["coveralls"]