From 82101dacb80506701c604d4875feb4f5ddc8a084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Brunner?= Date: Thu, 22 Aug 2024 09:34:24 +0200 Subject: [PATCH 1/4] Be able to install Python package from Git --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 31a75942f7..5821b31b2c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ RUN --mount=type=cache,target=/var/lib/apt/lists \ --mount=type=cache,target=/var/cache,sharing=locked \ apt-get update \ && apt-get upgrade --assume-yes \ - && DEBIAN_FRONTEND=noninteractive apt-get install --assume-yes --no-install-recommends python3-pip adduser \ + && DEBIAN_FRONTEND=noninteractive apt-get install --assume-yes --no-install-recommends adduser git python3-pip \ && pip install --upgrade pip # Used to convert the locked packages by poetry to pip requirements format @@ -30,8 +30,8 @@ RUN --mount=type=cache,target=/root/.cache \ # Do the conversion COPY poetry.lock pyproject.toml ./ -RUN poetry export --output=requirements.txt \ - && poetry export --with=dev --output=requirements-dev.txt +RUN poetry export --without-hashes --output=requirements.txt \ + && poetry export --with=dev --without-hashes --output=requirements-dev.txt # Base, the biggest thing is to install the Python packages FROM base-all AS base From 28134f580ef5b053df9d9ea121960d3070ecd6d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Brunner?= Date: Fri, 16 Aug 2024 15:36:22 +0200 Subject: [PATCH 2/4] Add OpenID connect support --- .gitignore | 3 + Dockerfile | 1 - commons/c2cgeoportal_commons/models/static.py | 1 + doc/integrator/authentication.rst | 1 + doc/integrator/authentication_oidc.rst | 90 ++++++++ doc/integrator/security.rst | 2 + geoportal/c2cgeoportal_geoportal/__init__.py | 60 ++++- .../c2cgeoportal_geoportal/lib/__init__.py | 6 +- geoportal/c2cgeoportal_geoportal/lib/oidc.py | 189 ++++++++++++++++ .../geoportal/CONST_config-schema.yaml | 43 ++++ .../geoportal/CONST_vars.yaml | 2 + .../c2cgeoportal_geoportal/views/login.py | 176 ++++++++++++++- geoportal/tests/functional/test_login.py | 12 +- geoportal/tests/functional/test_login_2fa.py | 6 + geoportal/tests/functional/test_oauth2.py | 6 + geoportal/tests/functional/test_oidc.py | 155 +++++++++++++ .../tests/functional/test_themes_editing.py | 34 +-- poetry.lock | 213 +++++++++++++++++- pyproject.toml | 10 +- 19 files changed, 952 insertions(+), 58 deletions(-) create mode 100644 doc/integrator/authentication_oidc.rst create mode 100644 geoportal/c2cgeoportal_geoportal/lib/oidc.py create mode 100644 geoportal/tests/functional/test_oidc.py diff --git a/.gitignore b/.gitignore index 3959ee29d7..a85c77c91c 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ /geoportal/tests/testegg/production.ini /geoportal/tests/tests.ini node_modules/ +# Files generated when we mount c2cgeoportal in the project, created with `make tests` +/geoportal/c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/demo.map +/geoportal/c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/mapserver.map # Files needed to mount c2cgeoportal in the project, created with `make dev` /geoportal/c2cgeoportal_geoportal/locale/ /admin/c2cgeoportal_admin/locale/ diff --git a/Dockerfile b/Dockerfile index 5821b31b2c..75df0d9e19 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,7 +69,6 @@ RUN --mount=type=cache,target=/var/lib/apt/lists \ && DEBIAN_FRONTEND=noninteractive apt-get install --assume-yes --no-install-recommends \ binutils gcc g++ \ && PIP_NO_BINARY=fiona,rasterio GDAL_CONFIG=/usr/bin/gdal-config PROJ_DIR=/usr/local/ python3 -m pip install \ - --use-deprecated=legacy-resolver \ --disable-pip-version-check --no-deps --requirement=/poetry/requirements.txt \ && strip /usr/local/lib/python3.*/dist-packages/*/*.so \ && apt-get auto-remove --assume-yes binutils gcc g++ \ diff --git a/commons/c2cgeoportal_commons/models/static.py b/commons/c2cgeoportal_commons/models/static.py index b61d1a279c..e8c42bd690 100644 --- a/commons/c2cgeoportal_commons/models/static.py +++ b/commons/c2cgeoportal_commons/models/static.py @@ -152,6 +152,7 @@ class User(Base): # type: ignore email: Mapped[str] = mapped_column( Unicode, nullable=False, + index=True, info={ "colanderalchemy": { "title": _("Email"), diff --git a/doc/integrator/authentication.rst b/doc/integrator/authentication.rst index cef5f7cd30..a8ee52ba12 100644 --- a/doc/integrator/authentication.rst +++ b/doc/integrator/authentication.rst @@ -5,6 +5,7 @@ Authentication Supported standards ~~~~~~~~~~~~~~~~~~~ +- `OpenID Connect`: as client, to be able to connect to an external OpenID Connect (OIDC) server. - `TOTP`: for two-factor authentication (2FA), this can be used for example with Google Authenticator. - `OAuth2` as server: An external application can use GeoMapFish as a single sign-on (SSO) for the authentication, even if it was initially implemented to be able to connect from QGIS desktop on an diff --git a/doc/integrator/authentication_oidc.rst b/doc/integrator/authentication_oidc.rst new file mode 100644 index 0000000000..593d64d809 --- /dev/null +++ b/doc/integrator/authentication_oidc.rst @@ -0,0 +1,90 @@ +OpenID Connect +~~~~~~~~~~~~~~ + +We can configure an OpenID connect service as an SSO (Single Sign-On) provider for our application. This allows users to log in to our application using their OpenID Connect credentials. + +We use [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html) with an Authorization Code Flow from [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html), with PKCE (Proof Key for Code Exchange, RFC 7636). + +.. mermaid:: + + sequenceDiagram + actor User + participant Browser + participant Geoportal + participant IAM + Geoportal->>IAM: discovery endpoint + + User->>+Browser: Login + Browser->>+Geoportal: Login + Geoportal->>-Browser: redirect + Browser->>+IAM: authorization endpoint + IAM->>-Browser: redirect + Browser->>+Geoportal: callback endpoint + Geoportal->>IAM: token endpoint + opt on using user info instead of jwt token + Geoportal->>IAM: userinfo endpoint + end + Geoportal->>-Browser: authentication data in cookie + Browser->>-User: Reload + + Browser->>+Geoportal: any auth endpoint + opt on token expiry + Geoportal->>IAM: refresh token endpoint + end + Geoportal->>-Browser: response + +~~~~~~~~~~~~~~~~~~~~~~~ +Authentication provider +~~~~~~~~~~~~~~~~~~~~~~~ + +If we want to use OpenID Connect as an authentication provider, we need to set the following configuration in our ``vars.yaml`` file: + +.. code:: yaml + + vars: + authentication: + openid_connect: + enabled: true + url: + client_id: + user_info_fields: + username: name # Default value + email: email # Default value + +With that the user will be create in the database at the first login, and the access right will be set in the GeoMapFish database. +The user correspondence will be done on the email field. + +~~~~~~~~~~~~~~~~~~~~~~ +Authorization provider +~~~~~~~~~~~~~~~~~~~~~~ + +If we want to use OpenID Connect as an authorization provider, we need to set the following configuration in our ``vars.yaml`` file: + +.. code:: yaml + + vars: + authentication: + openid_connect: + enabled: true + url: + client_id: + provide_roles: true + user_info_fields: + username: name # Default value + email: email # Default value + settings_role: settings_role + roles: roles + +With that the user will not be in the database only the roles will be set in the GeoMapFish database. + +~~~~~~~~~~~~~ +Other options +~~~~~~~~~~~~~ + +``client_secret``: The secret of the client. + +``trusted_audiences``: The list of trusted audiences, if the token audience is not in this list, the token will be rejected. + +``scopes``: The list of scopes to request, default is [``openid``, ``profile``, ``email``]. + +``query_user_info``: If ``true``, the user info will be requested instead if using the ``id_token``, default is false. diff --git a/doc/integrator/security.rst b/doc/integrator/security.rst index db83effe2a..340dc86511 100644 --- a/doc/integrator/security.rst +++ b/doc/integrator/security.rst @@ -5,6 +5,8 @@ Security .. _integrator_authentication: .. include:: authentication.rst +.. _integrator_authentication_oidc: +.. include:: authentication_oidc.rst .. _integrator_authentication_oauth2: .. include:: authentication_oauth2.rst .. include:: https.rst diff --git a/geoportal/c2cgeoportal_geoportal/__init__.py b/geoportal/c2cgeoportal_geoportal/__init__.py index e64772180c..16a58de7cb 100644 --- a/geoportal/c2cgeoportal_geoportal/__init__.py +++ b/geoportal/c2cgeoportal_geoportal/__init__.py @@ -25,7 +25,9 @@ # of the authors and should not be interpreted as representing official policies, # either expressed or implied, of the FreeBSD Project. +import datetime import importlib +import json import logging import os import urllib.parse @@ -36,6 +38,7 @@ import c2cwsgiutils import c2cwsgiutils.db import c2cwsgiutils.index +import dateutil.parser import pyramid.config import pyramid.renderers import pyramid.request @@ -313,6 +316,7 @@ def get_user_from_request( """ from c2cgeoportal_commons.models import DBSession # pylint: disable=import-outside-toplevel from c2cgeoportal_commons.models.static import User # pylint: disable=import-outside-toplevel + from c2cgeoportal_geoportal.lib import oidc # pylint: disable=import-outside-toplevel assert DBSession is not None @@ -327,14 +331,52 @@ def get_user_from_request( if username is None: username = request.authenticated_userid if username is not None: - # We know we will need the role object of the - # user so we use joined loading - request.user_ = ( - DBSession.query(User) - .filter_by(username=username, deactivated=False) - .options(joinedload(User.roles)) - .first() - ) + openid_connect_config = settings.get("authentication", {}).get("openid_connect", {}) + if openid_connect_config.get("enabled", False): + user_info = json.loads(username) + access_token_expires = dateutil.parser.isoparse(user_info["access_token_expires"]) + if access_token_expires < datetime.datetime.now(): + if user_info["refresh_token_expires"] is None: + return None + refresh_token_expires = dateutil.parser.isoparse(user_info["refresh_token_expires"]) + if refresh_token_expires < datetime.datetime.now(): + return None + token_response = oidc.get_oidc_client(request).exchange_refresh_token( + user_info["refresh_token"] + ) + user_info = oidc.OidcRemember(request).remember(token_response) + + if openid_connect_config.get("provide_roles", False) is True: + from c2cgeoportal_commons.models.main import ( # pylint: disable=import-outside-toplevel + Role, + ) + + request.user_ = oidc.DynamicUser( + username=user_info["username"], + email=user_info["email"], + settings_role=( + DBSession.query(Role).filter_by(name=user_info["settings_role"]).first() + if user_info.get("settings_role") is not None + else None + ), + roles=[ + DBSession.query(Role).filter_by(name=role).one() + for role in user_info.get("roles", []) + ], + ) + else: + request.user_ = DBSession.query(User).filter_by(email=user_info["email"]).first() + for user in DBSession.query(User).all(): + _LOG.error(user.username) + else: + # We know we will need the role object of the + # user so we use joined loading + request.user_ = ( + DBSession.query(User) + .filter_by(username=username, deactivated=False) + .options(joinedload(User.roles)) + .first() + ) return cast(User, request.user_) @@ -596,6 +638,8 @@ def handle(event: InvalidateCacheEvent) -> None: add_cors_route(config, "/loginuser", "login") config.add_route("loginuser", "/loginuser", request_method="GET") config.add_route("testi18n", "/testi18n.html", request_method="GET") + config.add_route("oidc_login", "/oidc/login", request_method="GET") + config.add_route("oidc_callback", "/oidc/callback", request_method="GET") config.add_renderer(".map", AssetRendererFactory) config.add_renderer(".css", AssetRendererFactory) diff --git a/geoportal/c2cgeoportal_geoportal/lib/__init__.py b/geoportal/c2cgeoportal_geoportal/lib/__init__.py index f7523ef2c2..24feb0dd1c 100644 --- a/geoportal/c2cgeoportal_geoportal/lib/__init__.py +++ b/geoportal/c2cgeoportal_geoportal/lib/__init__.py @@ -90,19 +90,19 @@ def get_typed( elif type_["type"] == "float": return float(value) elif type_["type"] == "date": - date = dateutil.parser.parse(value, default=datetime.datetime(1, 1, 1, 0, 0, 0)) # type: ignore + date = dateutil.parser.parse(value, default=datetime.datetime(1, 1, 1, 0, 0, 0)) if date.time() != datetime.time(0, 0, 0): errors.add(f"{prefix}The date attribute '{name}'='{value}' should not have any time") else: return datetime.date.strftime(date.date(), "%Y-%m-%d") elif type_["type"] == "time": - date = dateutil.parser.parse(value, default=datetime.datetime(1, 1, 1, 0, 0, 0)) # type: ignore + date = dateutil.parser.parse(value, default=datetime.datetime(1, 1, 1, 0, 0, 0)) if date.date() != datetime.date(1, 1, 1): errors.add(f"{prefix}The time attribute '{name}'='{value}' should not have any date") else: return datetime.time.strftime(date.time(), "%H:%M:%S") elif type_["type"] == "datetime": - date = dateutil.parser.parse(value, default=datetime.datetime(1, 1, 1, 0, 0, 0)) # type: ignore + date = dateutil.parser.parse(value, default=datetime.datetime(1, 1, 1, 0, 0, 0)) return datetime.datetime.strftime(date, "%Y-%m-%dT%H:%M:%S") elif type_["type"] == "url": url = get_url2(f"{prefix}The attribute '{name}'", value, request, errors) diff --git a/geoportal/c2cgeoportal_geoportal/lib/oidc.py b/geoportal/c2cgeoportal_geoportal/lib/oidc.py new file mode 100644 index 0000000000..85acee5993 --- /dev/null +++ b/geoportal/c2cgeoportal_geoportal/lib/oidc.py @@ -0,0 +1,189 @@ +# Copyright (c) 2024, Camptocamp SA +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# The views and conclusions contained in the software and documentation are those +# of the authors and should not be interpreted as representing official policies, +# either expressed or implied, of the FreeBSD Project. + +import datetime +import json +import logging +from typing import NamedTuple, Optional, TypedDict, Union + +import pyramid.request +import pyramid.response +import simple_openid_connect.client +import simple_openid_connect.data +from pyramid.httpexceptions import HTTPBadRequest, HTTPInternalServerError, HTTPUnauthorized +from pyramid.security import remember + +from c2cgeoportal_commons.models import main +from c2cgeoportal_geoportal.lib.caching import get_region + +_LOG = logging.getLogger(__name__) +_CACHE_REGION_OBJ = get_region("obj") + + +# User create on demand +class DynamicUser(NamedTuple): + """ + User created dynamically. + """ + + username: str + email: str + settings_role: Optional[main.Role] + roles: list[main.Role] + + +@_CACHE_REGION_OBJ.cache_on_arguments() +def get_oidc_client(request: pyramid.request.Request) -> simple_openid_connect.client.OpenidClient: + """ + Get the OpenID Connect client from the request settings. + """ + + authentication_settings = request.registry.settings.get("authentication", {}) + openid_connect = authentication_settings.get("openid_connect", {}) + if openid_connect.get("enabled", False) is not True: + raise HTTPBadRequest("OpenID Connect not enabled") + + _LOG.info(openid_connect) + return simple_openid_connect.client.OpenidClient.from_issuer_url( + url=openid_connect["url"], + authentication_redirect_uri=request.route_url("oidc_callback"), + client_id=openid_connect["client_id"], + client_secret=openid_connect.get("client-secret"), + scope=" ".join(openid_connect.get("scopes", ["openid", "profile", "email"])), + ) + + +class OidcRememberObject(TypedDict): + """ + The JSON object that is stored in a cookie to remember the user. + """ + + access_token: str + access_token_expires: str + refresh_token: Optional[str] + refresh_token_expires: Optional[str] + username: Optional[str] + email: Optional[str] + settings_role: Optional[str] + roles: list[str] + + +class OidcRemember: + """ + Build the abject that we want to remember in the cookie. + """ + + def __init__(self, request: pyramid.request.Request): + self.request = request + self.authentication_settings = request.registry.settings.get("authentication", {}) + + @_CACHE_REGION_OBJ.cache_on_arguments() + def remember( + self, + token_response: Union[ + simple_openid_connect.data.TokenSuccessResponse, simple_openid_connect.data.TokenErrorResponse + ], + ) -> OidcRememberObject: + """ + Remember the user in the cookie. + """ + if isinstance(token_response, simple_openid_connect.data.TokenErrorResponse): + _LOG.warning( + "OpenID connect connection error: %s [%s]", + token_response.error_description, + token_response.error_uri, + ) + raise HTTPUnauthorized("See server logs for details") + + if not isinstance(token_response, simple_openid_connect.data.TokenSuccessResponse): + _LOG.warning("OpenID connect connection error: %s", token_response) + raise HTTPUnauthorized("See server logs for details") + + openid_connect = self.authentication_settings.get("openid_connect", {}) + remember_object: OidcRememberObject = { + "access_token": token_response.access_token, + "access_token_expires": ( + datetime.datetime.now() + datetime.timedelta(seconds=token_response.expires_in) + ).isoformat(), + "refresh_token": token_response.refresh_token, + "refresh_token_expires": ( + None + if token_response.refresh_expires_in is None + else ( + datetime.datetime.now() + datetime.timedelta(seconds=token_response.refresh_expires_in) + ).isoformat() + ), + "username": None, + "email": None, + "settings_role": None, + "roles": [], + } + settings_fields = openid_connect.get("user_info_fields", {}) + client = get_oidc_client(self.request) + + if openid_connect.get("query_user_info", False) is True: + user_info = client.fetch_userinfo(token_response.access_token) + else: + un_validated_user_info = simple_openid_connect.data.IdToken.parse_jwt( + token_response.id_token, client.provider_keys + ) + _LOG.info( + "Receive audiences: %s", + ( + un_validated_user_info.aud + if isinstance(un_validated_user_info.aud, str) + else ", ".join(un_validated_user_info.aud) + ), + ) + user_info = client.decode_id_token( + token_response.id_token, + extra_trusted_audiences=openid_connect.get( + "trusted_audiences", [openid_connect.get("client_id")] + ), + ) + + for field_, default_field in ( + ("username", "name"), + ("email", "email"), + ("settings_role", None), + ("roles", None), + ): + user_info_field = settings_fields.get(field_, default_field) + if user_info_field is not None: + user_info_dict = user_info.dict() + if user_info_field not in user_info_dict: + _LOG.error( + "Field '%s' not found in user info, available: %s.", + user_info_field, + ", ".join(user_info_dict.keys()), + ) + raise HTTPInternalServerError(f"Field '{user_info_field}' not found in user info.") + remember_object[field_] = user_info_dict[user_info_field] # type: ignore[literal-required] + + self.request.response.headers.extend(remember(self.request, json.dumps(remember_object))) + + return remember_object diff --git a/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_config-schema.yaml b/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_config-schema.yaml index 3c3167bfdb..078749b575 100644 --- a/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_config-schema.yaml +++ b/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_config-schema.yaml @@ -205,6 +205,49 @@ mapping: oauth2_token_expire_minutes: type: scalar required: false + openid_connect: + type: map + required: false + mapping: + enabled: + type: bool + default: false + url: + type: str + required: false + client_id: + type: str + required: false + client_secret: + type: str + required: false + trusted_audiences: + type: seq + sequence: + - type: str + scopes: + type: seq + sequence: + - type: str + provide_roles: + type: bool + default: false + query_user_info: + type: bool + default: false + user_info_fields: + type: map + mapping: + username: + type: str + default: name + email: + type: str + default: email + settings_role: + type: str + roles: + type: str intranet: type: map required: false diff --git a/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_vars.yaml b/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_vars.yaml index f57d410318..bfb2fa83ab 100644 --- a/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_vars.yaml +++ b/geoportal/c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_vars.yaml @@ -319,6 +319,8 @@ vars: currentInterface: True authenticationBaseUrl: name: base + gmfOidcLoginUrl: + name: oidc_login gmfLayersUrl: name: layers_root gmfRasterUrl: diff --git a/geoportal/c2cgeoportal_geoportal/views/login.py b/geoportal/c2cgeoportal_geoportal/views/login.py index 746faa087c..385db585d7 100644 --- a/geoportal/c2cgeoportal_geoportal/views/login.py +++ b/geoportal/c2cgeoportal_geoportal/views/login.py @@ -34,6 +34,7 @@ import urllib.parse from typing import Any, Optional, Union +import pkce import pyotp import pyramid.request import pyramid.response @@ -51,15 +52,14 @@ from c2cgeoportal_commons import models from c2cgeoportal_commons.lib.email_ import send_email_config -from c2cgeoportal_commons.models import static +from c2cgeoportal_commons.models import main, static from c2cgeoportal_geoportal import is_allowed_url, is_valid_referrer -from c2cgeoportal_geoportal.lib import get_setting, is_intranet, oauth2 +from c2cgeoportal_geoportal.lib import get_setting, is_intranet, oauth2, oidc from c2cgeoportal_geoportal.lib.caching import get_region from c2cgeoportal_geoportal.lib.common_headers import Cache, set_common_headers from c2cgeoportal_geoportal.lib.functionality import get_functionality _LOG = logging.getLogger(__name__) -_CACHE_REGION = get_region("std") class Login: @@ -74,10 +74,10 @@ def __init__(self, request: pyramid.request.Request): self.settings = request.registry.settings self.lang = request.locale_name - authentication_settings = self.settings.get("authentication", {}) + self.authentication_settings = self.settings.get("authentication", {}) - self.two_factor_auth = authentication_settings.get("two_factor", False) - self.two_factor_issuer_name = authentication_settings.get("two_factor_issuer_name") + self.two_factor_auth = self.authentication_settings.get("two_factor", False) + self.two_factor_issuer_name = self.authentication_settings.get("two_factor_issuer_name") def _functionality(self) -> dict[str, list[Union[str, int, float, bool, list[Any], dict[str, Any]]]]: functionality = {} @@ -85,7 +85,7 @@ def _functionality(self) -> dict[str, list[Union[str, int, float, bool, list[Any functionality[func_] = get_functionality(func_, self.request, is_intranet(self.request)) return functionality - def _referer_log(self) -> None: + def _referrer_log(self) -> None: if not hasattr(self.request, "is_valid_referer"): self.request.is_valid_referer = is_valid_referrer(self.request) if not self.request.is_valid_referer: @@ -93,6 +93,14 @@ def _referer_log(self) -> None: @forbidden_view_config(renderer="login.html") # type: ignore def loginform403(self) -> Union[dict[str, Any], pyramid.response.Response]: + if self.authentication_settings.get("openid_connect", {}).get("enabled", False): + return HTTPFound( + location=self.request.route_url( + "login", + _query={"came_from": f"{self.request.path}?{urllib.parse.urlencode(self.request.GET)}"}, + ) + ) + if self.request.authenticated_userid is not None: return HTTPForbidden() @@ -100,14 +108,15 @@ def loginform403(self) -> Union[dict[str, Any], pyramid.response.Response]: return { "lang": self.lang, - "login_params": { - "came_from": (f"{self.request.path}?{urllib.parse.urlencode(self.request.GET)}") - }, + "login_params": {"came_from": f"{self.request.path}?{urllib.parse.urlencode(self.request.GET)}"}, "two_fa": self.two_factor_auth, } @view_config(route_name="loginform", renderer="login.html") # type: ignore def loginform(self) -> dict[str, Any]: + if self.authentication_settings.get("openid_connect", {}).get("enabled", False): + raise HTTPBadRequest("View disabled by OpenID Connect") + set_common_headers(self.request, "login", Cache.PUBLIC) return { @@ -125,8 +134,10 @@ def _validate_2fa_totp(user: static.User, otp: str) -> bool: @view_config(route_name="login") # type: ignore def login(self) -> pyramid.response.Response: assert models.DBSession is not None + if self.authentication_settings.get("openid_connect", {}).get("enabled", False): + raise HTTPBadRequest("View disabled by OpenID Connect") - self._referer_log() + self._referrer_log() login = self.request.POST.get("login") password = self.request.POST.get("password") @@ -281,6 +292,13 @@ def _oauth2_login(self, user: static.User) -> pyramid.response.Response: @view_config(route_name="logout") # type: ignore def logout(self) -> pyramid.response.Response: + if self.authentication_settings.get("openid_connect", {}).get("enabled", False): + client = oidc.get_oidc_client(self.request) + user_info = json.loads(self.request.authenticated_userid) + client.revoke_token(user_info["access_token"]) + if user_info.get("refresh_token") is not None: + client.revoke_token(user_info["refresh_token"]) + headers = forget(self.request) if not self.request.user: @@ -298,8 +316,15 @@ def _user(self, user: Optional[static.User] = None) -> dict[str, Any]: result = { "functionalities": self._functionality(), "is_intranet": is_intranet(self.request), - "two_factor_enable": self.two_factor_auth, + "login_type": ( + "oidc" + if self.authentication_settings.get("openid_connect", {}).get("enabled", False) + else "local" + ), } + if not self.authentication_settings.get("openid_connect", {}).get("enabled", False): + result["two_factor_enable"] = self.two_factor_auth + user = self.request.user if user is None else user if user is not None: result.update( @@ -321,6 +346,9 @@ def loginuser(self) -> dict[str, Any]: def change_password(self) -> pyramid.response.Response: assert models.DBSession is not None + if self.authentication_settings.get("openid_connect", {}).get("enabled", False): + raise HTTPBadRequest("View disabled by OpenID Connect") + set_common_headers(self.request, "login", Cache.PRIVATE_NO) login = self.request.POST.get("login") @@ -406,6 +434,9 @@ def _loginresetpassword( @view_config(route_name="loginresetpassword", renderer="json") # type: ignore def loginresetpassword(self) -> dict[str, Any]: + if self.authentication_settings.get("openid_connect", {}).get("enabled", False): + raise HTTPBadRequest("View disabled by OpenID Connect") + set_common_headers(self.request, "login", Cache.PRIVATE_NO) user, username, password, error = self._loginresetpassword() @@ -435,6 +466,9 @@ def loginresetpassword(self) -> dict[str, Any]: @view_config(route_name="oauth2introspect") # type: ignore def oauth2introspect(self) -> pyramid.response.Response: + if self.authentication_settings.get("openid_connect", {}).get("enabled", False): + raise HTTPBadRequest("View disabled by OpenID Connect") + _LOG.debug( "Call OAuth create_introspect_response with:\nurl: %s\nmethod: %s\nbody:\n%s", self.request.current_route_url(_query=self.request.GET), @@ -465,6 +499,9 @@ def oauth2introspect(self) -> pyramid.response.Response: @view_config(route_name="oauth2token") # type: ignore def oauth2token(self) -> pyramid.response.Response: + if self.authentication_settings.get("openid_connect", {}).get("enabled", False): + raise HTTPBadRequest("View disabled by OpenID Connect") + _LOG.debug( "Call OAuth create_token_response with:\nurl: %s\nmethod: %s\nbody:\n%s", self.request.current_route_url(_query=self.request.GET), @@ -494,6 +531,9 @@ def oauth2token(self) -> pyramid.response.Response: @view_config(route_name="oauth2revoke_token") # type: ignore def oauth2revoke_token(self) -> pyramid.response.Response: + if self.authentication_settings.get("openid_connect", {}).get("enabled", False): + raise HTTPBadRequest("View disabled by OpenID Connect") + _LOG.debug( "Call OAuth create_revocation_response with:\nurl: %s\nmethod: %s\nbody:\n%s", self.request.create_revocation_response(_query=self.request.GET), @@ -521,6 +561,9 @@ def oauth2revoke_token(self) -> pyramid.response.Response: @view_config(route_name="oauth2loginform", renderer="login.html") # type: ignore def oauth2loginform(self) -> dict[str, Any]: + if self.authentication_settings.get("openid_connect", {}).get("enabled", False): + raise HTTPBadRequest("View disabled by OpenID Connect") + set_common_headers(self.request, "login", Cache.PUBLIC) if self.request.user: @@ -539,3 +582,112 @@ def notlogin(self) -> dict[str, Any]: set_common_headers(self.request, "login", Cache.PUBLIC) return {"lang": self.lang} + + @view_config(route_name="oidc_login") # type: ignore + def oidc_login(self) -> pyramid.response.Response: + client = oidc.get_oidc_client(self.request) + if "came_from" in self.request.params: + self.request.response.set_cookie( + "came_from", + self.request.params["came_from"], + httponly=True, + samesite="Lax", + secure=True, + domain=self.request.domain, + max_age=600, + ) + + code_verifier, code_challenge = pkce.generate_pkce_pair() + self.request.response.set_cookie( + "code_verifier", + code_verifier, + httponly=True, + samesite="Lax", + secure=True, + domain=self.request.domain, + max_age=600, + ) + self.request.response.set_cookie( + "code_challenge", + code_challenge, + httponly=True, + samesite="Lax", + secure=True, + domain=self.request.domain, + max_age=600, + ) + + try: + return HTTPFound( + location=client.authorization_code_flow.start_authentication( + code_challenge=code_challenge, + code_challenge_method="S256", + ), + headers=self.request.response.headers, + ) + finally: + client.authorization_code_flow.code_challenge = "" + + @view_config(route_name="oidc_callback") # type: ignore + def oidc_callback(self) -> pyramid.response.Response: + client = oidc.get_oidc_client(self.request) + assert models.DBSession is not None + + token_response = client.authorization_code_flow.handle_authentication_result( + "?" + urllib.parse.urlencode(self.request.params), + code_verifier=self.request.cookies["code_verifier"], + code_challenge=self.request.cookies["code_challenge"], + code_challenge_method="S256", + ) + self.request.response.delete_cookie("code_verifier") + self.request.response.delete_cookie("code_challenge") + + remember_object = oidc.OidcRemember(self.request).remember(token_response) + + user: Optional[Union[static.User, oidc.DynamicUser]] + if self.authentication_settings.get("openid_connect", {}).get("provide_roles", False) is False: + user = models.DBSession.query(static.User).filter_by(email=remember_object["email"]).one_or_none() + if user is None: + user = static.User(username=remember_object["username"], email=remember_object["email"]) + models.DBSession.add(user) + else: + user = oidc.DynamicUser( + username=remember_object["username"], + email=remember_object["email"], + settings_role=( + models.DBSession.query(main.Role).filter_by(name=remember_object["settings_role"]).first() + if remember_object.get("settings_role") is not None + else None + ), + roles=[ + models.DBSession.query(main.Role).filter_by(name=role).one() + for role in remember_object.get("roles", []) + ], + ) + assert user is not None + self.request.user_ = user + + if "came_from" in self.request.cookies: + came_from = self.request.cookies["came_from"] + self.request.response.delete_cookie("came_from") + + return HTTPFound(location=came_from, headers=self.request.response.headers) + + return set_common_headers( + self.request, + "login", + Cache.PRIVATE_NO, + response=Response( + # TODO respect the user interface... + json.dumps( + { + "username": user.username, + "email": user.email, + "is_intranet": is_intranet(self.request), + "functionalities": self._functionality(), + "roles": [{"name": r.name, "id": r.id} for r in user.roles], + } + ), + headers=(("Content-Type", "text/json"),), + ), + ) diff --git a/geoportal/tests/functional/test_login.py b/geoportal/tests/functional/test_login.py index 6d3369ef10..0fada98bec 100644 --- a/geoportal/tests/functional/test_login.py +++ b/geoportal/tests/functional/test_login.py @@ -125,6 +125,7 @@ def test_login_success(self): "username": "__test_user1", "email": "__test_user1@example.com", "is_intranet": False, + "login_type": "local", "two_factor_enable": False, "roles": [{"name": "__test_role1", "id": self.role1_id}], "functionalities": {}, @@ -196,6 +197,7 @@ def test_reset_password(self): "username": "__test_user1", "email": "__test_user1@example.com", "is_intranet": False, + "login_type": "local", "two_factor_enable": False, "roles": [{"name": "__test_role1", "id": self.role1_id}], "functionalities": {}, @@ -333,6 +335,7 @@ def __init__(self, role="__test_role", functionalities=None): "username": "__test_user", "email": "info@example.com", "is_intranet": False, + "login_type": "local", "two_factor_enable": False, "roles": [{"name": "__test_role", "id": 123}], "functionalities": {"func": ["reg"]}, @@ -350,6 +353,7 @@ class F: "username": "__test_user", "email": "info@example.com", "is_intranet": False, + "login_type": "local", "two_factor_enable": False, "roles": [{"name": "__test_role2", "id": 123}], "functionalities": {"func": ["value"]}, @@ -368,13 +372,15 @@ def test_intranet(self): login = Login(request) self.assertEqual( - login.loginuser(), {"is_intranet": False, "functionalities": {}, "two_factor_enable": False} + login.loginuser(), + {"is_intranet": False, "login_type": "local", "functionalities": {}, "two_factor_enable": False}, ) request.client_addr = "192.168.1.20" login = Login(request) self.assertEqual( - login.loginuser(), {"is_intranet": True, "functionalities": {}, "two_factor_enable": False} + login.loginuser(), + {"is_intranet": True, "login_type": "local", "functionalities": {}, "two_factor_enable": False}, ) class G: @@ -404,6 +410,7 @@ def __init__(self, role="__test_role", functionalities=None): "email": "info@example.com", "functionalities": {}, "is_intranet": False, + "login_type": "local", "roles": [{"id": 123, "name": "__test_role"}], "two_factor_enable": False, "username": "__test_user", @@ -418,6 +425,7 @@ def __init__(self, role="__test_role", functionalities=None): "email": "info@example.com", "functionalities": {}, "is_intranet": True, + "login_type": "local", "roles": [{"id": 123, "name": "__test_role"}], "two_factor_enable": False, "username": "__test_user", diff --git a/geoportal/tests/functional/test_login_2fa.py b/geoportal/tests/functional/test_login_2fa.py index 9b3662852e..164abfbbb5 100644 --- a/geoportal/tests/functional/test_login_2fa.py +++ b/geoportal/tests/functional/test_login_2fa.py @@ -119,6 +119,7 @@ def test_new_user(self): "email": "__test_user@example.com", "functionalities": {}, "is_intranet": False, + "login_type": "local", "roles": [], "two_factor_enable": True, "username": "__test_user", @@ -133,6 +134,7 @@ def test_new_user(self): "username": "__test_user", "email": "__test_user@example.com", "is_intranet": False, + "login_type": "local", "two_factor_enable": True, "roles": [], "functionalities": {}, @@ -184,6 +186,7 @@ def test_user_reset_password(self): "email": "__test_user@example.com", "functionalities": {}, "is_intranet": False, + "login_type": "local", "roles": [], "two_factor_enable": True, "username": "__test_user", @@ -198,6 +201,7 @@ def test_user_reset_password(self): "username": "__test_user", "email": "__test_user@example.com", "is_intranet": False, + "login_type": "local", "two_factor_enable": True, "roles": [], "functionalities": {}, @@ -231,6 +235,7 @@ def test_change_password(self): "email": "__test_user@example.com", "functionalities": {}, "is_intranet": False, + "login_type": "local", "roles": [], "two_factor_enable": True, "username": "__test_user", @@ -245,6 +250,7 @@ def test_change_password(self): "username": "__test_user", "email": "__test_user@example.com", "is_intranet": False, + "login_type": "local", "two_factor_enable": True, "roles": [], "functionalities": {}, diff --git a/geoportal/tests/functional/test_oauth2.py b/geoportal/tests/functional/test_oauth2.py index 2e2016dd95..48d0a61bdf 100644 --- a/geoportal/tests/functional/test_oauth2.py +++ b/geoportal/tests/functional/test_oauth2.py @@ -167,6 +167,7 @@ def test_oauth2_protocol_test_login_get_token_is_login(self) -> None: assert set(response.keys()) == { "functionalities", "is_intranet", + "login_type", "two_factor_enable", "username", "email", @@ -305,6 +306,7 @@ def test_oauth2_protocol_test_login_get_token_refresh_token_is_login(self) -> No assert set(response.keys()) == { "functionalities", "is_intranet", + "login_type", "two_factor_enable", "username", "email", @@ -388,6 +390,7 @@ def test_state_oauth2_protocol_test_login_get_token_refresh_token_is_login(self) assert set(response.keys()) == { "functionalities", "is_intranet", + "login_type", "two_factor_enable", "username", "email", @@ -522,6 +525,7 @@ def test_is_login_wrong_token(self) -> None: assert set(response.keys()) == { "functionalities", "is_intranet", + "login_type", "two_factor_enable", } @@ -642,6 +646,7 @@ def test_pkce_oauth2_protocol_test_login_get_token_refresh_token_is_login(self) assert set(response.keys()) == { "functionalities", "is_intranet", + "login_type", "two_factor_enable", "username", "email", @@ -738,6 +743,7 @@ def test_pkce_state_oauth2_protocol_test_login_get_token_refresh_token_is_login( assert set(response.keys()) == { "functionalities", "is_intranet", + "login_type", "two_factor_enable", "username", "email", diff --git a/geoportal/tests/functional/test_oidc.py b/geoportal/tests/functional/test_oidc.py new file mode 100644 index 0000000000..accfb1291f --- /dev/null +++ b/geoportal/tests/functional/test_oidc.py @@ -0,0 +1,155 @@ +import base64 +import re +import urllib.parse +from http.client import responses +from unittest import TestCase + +import jwt +import responses +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from pyramid import testing +from tests.functional import cleanup_db, create_dummy_request +from tests.functional import setup_common as setup_module # noqa, pylint: disable=unused-import +from tests.functional import setup_db +from tests.functional import teardown_common as teardown_module # noqa, pylint: disable=unused-import + +_OIDC_CONFIGURATION = { + "issuer": "https://sso.example.com", + "authorization_endpoint": "https://sso.example.com/authorize", + "token_endpoint": "https://sso.example.com/token", + "jwks_uri": "https://sso.example.com/jwks", + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + "code_challenge_methods_supported": ["S256"], +} +_PRIVATE_KEY = rsa.generate_private_key(public_exponent=65537, key_size=512) +_OIDC_KEYS = { + "keys": [ + { + "use": "sig", + "kty": "RSA", + "alg": "RS256", + "n": base64.urlsafe_b64encode( + _PRIVATE_KEY.public_key().public_numbers().n.to_bytes(64, byteorder="big") + ).decode(), + "e": "AQAB", + # _PRIVATE_KEY.public_key().public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo), + # PKCS8EncodedKeySpec + } + ] +} + + +class TestLogin(TestCase): + def setUp(self): + setup_db() + self.config = testing.setUp() + + def tearDown(self): + testing.tearDown() + cleanup_db() + + @responses.activate + def test_login(self): + from c2cgeoportal_geoportal.views.login import Login + + request = create_dummy_request( + { + "authentication": { + "openid_connect": { + "enabled": True, + "url": "https://sso.example.com", + "client_id": "client_id_1", + } + } + }, + params={"came_from": "/came_from"}, + ) + responses.get("https://sso.example.com/.well-known/openid-configuration", json=_OIDC_CONFIGURATION) + responses.get("https://sso.example.com/jwks", json=_OIDC_KEYS) + + response = Login(request).oidc_login() + assert response.status_int == 302 + location = urllib.parse.urlparse(response.headers["Location"]) + assert location.scheme == "https" + assert location.netloc == "sso.example.com" + assert location.path == "/authorize" + query = urllib.parse.parse_qs(location.query) + assert query["response_type"] == ["code"] + assert query["client_id"] == ["client_id_1"] + assert query["scope"] == ["openid profile email"] + assert query["redirect_uri"] == ["http://example.com/oidc_callback/view"] + assert "code_challenge" in query + assert query["code_challenge_method"] == ["S256"] + + set_cookies = {k: v for k, v in [v.split("=", 1) for v in response.headers.getall("Set-Cookie")]} + assert re.match( + r"^.*; Domain=example\.com; Max\-Age=600; Path=/; expires=.*; secure; HttpOnly; SameSite=Lax$", + set_cookies["code_verifier"], + ) + assert re.match( + r"^.*; Domain=example\.com; Max\-Age=600; Path=/; expires=.*; secure; HttpOnly; SameSite=Lax$", + set_cookies["code_challenge"], + ) + assert re.match( + r"^/came_from; Domain=example\.com; Max\-Age=600; Path=/; expires=.*; secure; HttpOnly; SameSite=Lax$", + set_cookies["came_from"], + ) + + @responses.activate + def test_callback(self): + from c2cgeoportal_geoportal.views.login import Login + + request = create_dummy_request( + { + "authentication": { + "openid_connect": { + "enabled": True, + "url": "https://sso.example.com", + "client_id": "client_id_123", + } + } + }, + params={"code": "code_123"}, + cookies={ + "came_from": "/came_from", + "code_verifier": "code_verifier", + "code_challenge": "code_challenge", + }, + ) + responses.get("https://sso.example.com/.well-known/openid-configuration", json=_OIDC_CONFIGURATION) + responses.get("https://sso.example.com/jwks", json=_OIDC_KEYS) + responses.post( + "https://sso.example.com/token", + json={ + "access_token": "access", + "expires_in": 3600, + "token_type": "Bearer", + "id_token": jwt.encode( + { + "sub": "1234", + "name": "Test User", + "email": "user@example.com", + "iss": "https://sso.example.com", + "aud": "client_id_123", + "exp": 2000000000, + "iat": 1000000000, + }, + _PRIVATE_KEY, + algorithm="RS256", + ), + }, + ) + response = Login(request).oidc_callback() + assert response.status_int == 302 + assert response.headers["Location"] == "/came_from" + + set_cookies = {k: v for k, v in [v.split("=", 1) for v in response.headers.getall("Set-Cookie")]} + assert set_cookies["came_from"].startswith("; Max-Age=0; Path=/; expires="), set_cookies["came_from"] + assert set_cookies["code_verifier"].startswith("; Max-Age=0; Path=/; expires="), set_cookies[ + "code_verifier" + ] + assert set_cookies["code_challenge"].startswith("; Max-Age=0; Path=/; expires="), set_cookies[ + "code_challenge" + ] diff --git a/geoportal/tests/functional/test_themes_editing.py b/geoportal/tests/functional/test_themes_editing.py index b2dbf39248..564c7741ee 100644 --- a/geoportal/tests/functional/test_themes_editing.py +++ b/geoportal/tests/functional/test_themes_editing.py @@ -33,8 +33,9 @@ import transaction from geoalchemy2 import WKTElement from pyramid import testing -from tests.functional import create_default_ogcserver, create_dummy_request, mapserv_url +from tests.functional import cleanup_db, create_default_ogcserver, create_dummy_request, mapserv_url from tests.functional import setup_common as setup_module # noqa +from tests.functional import setup_db from tests.functional import teardown_common as teardown_module # noqa @@ -60,10 +61,11 @@ def setup_method(self, _): ) from c2cgeoportal_commons.models.static import User + setup_db() + ogcserver = create_default_ogcserver() role1 = Role(name="__test_role1") - role1.id = 999 user1 = User(username="__test_user1", password="__test_user1", settings_role=role1, roles=[role1]) user1.email = "__test_user1@example.com" @@ -114,30 +116,10 @@ def setup_method(self, _): def teardown_method(self, _): testing.tearDown() - from c2cgeoportal_commons.models import DBSession - from c2cgeoportal_commons.models.main import ( - Interface, - Layer, - LayerGroup, - OGCServer, - RestrictionArea, - Role, - Theme, - ) - from c2cgeoportal_commons.models.static import User + cleanup_db() - DBSession.delete(DBSession.query(User).filter(User.username == "__test_user1").one()) - DBSession.delete(DBSession.query(User).filter(User.username == "__test_user2").one()) - - ra = DBSession.query(RestrictionArea).filter(RestrictionArea.name == "__test_ra1").one() - ra.roles = [] - DBSession.delete(ra) - ra = DBSession.query(RestrictionArea).filter(RestrictionArea.name == "__test_ra2").one() - ra.roles = [] - DBSession.delete(ra) - - DBSession.query(Role).filter(Role.name == "__test_role1").delete() - DBSession.query(Role).filter(Role.name == "__test_role2").delete() + from c2cgeoportal_commons.models import DBSession + from c2cgeoportal_commons.models.main import Layer, LayerGroup, Theme for t in DBSession.query(Theme).filter(Theme.name == "__test_theme").all(): DBSession.delete(t) @@ -145,8 +127,6 @@ def teardown_method(self, _): DBSession.delete(g) for layer in DBSession.query(Layer).all(): DBSession.delete(layer) - DBSession.query(Interface).filter(Interface.name == "main").delete() - DBSession.query(OGCServer).delete() for table in self._tables[::-1]: table.drop(checkfirst=True, bind=DBSession.c2c_rw_bind) diff --git a/poetry.lock b/poetry.lock index a8ee393514..2d687b2a0d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -34,6 +34,17 @@ typing-extensions = ">=4" [package.extras] tz = ["backports.zoneinfo"] +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + [[package]] name = "arrow" version = "1.3.0" @@ -884,6 +895,21 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "cryptojwt" +version = "1.9.2" +description = "Python implementation of JWT, JWE, JWS and JWK" +optional = false +python-versions = "<4.0,>=3.9" +files = [ + {file = "cryptojwt-1.9.2-py3-none-any.whl", hash = "sha256:feea70ee9fa3421c1133aef7f8a313d8a7f1c252f21c6bb30fce9a8b920af69a"}, + {file = "cryptojwt-1.9.2.tar.gz", hash = "sha256:d619e3033eb0edbf80835e111a9d7c6d61ff3c84428ed72faaddd0c506fc513c"}, +] + +[package.dependencies] +cryptography = ">=3.4.6" +requests = ">=2.25.1,<3.0.0" + [[package]] name = "debian-inspector" version = "31.1.0" @@ -1184,6 +1210,21 @@ test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask-expr", "dask[dataframe, test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas", "panel", "paramiko", "pyarrow", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "smbprotocol", "tqdm", "urllib3", "zarr", "zstandard"] tqdm = ["tqdm"] +[[package]] +name = "furl" +version = "2.1.3" +description = "URL manipulation made simple." +optional = false +python-versions = "*" +files = [ + {file = "furl-2.1.3-py2.py3-none-any.whl", hash = "sha256:9ab425062c4217f9802508e45feb4a83e54324273ac4b202f1850363309666c0"}, + {file = "furl-2.1.3.tar.gz", hash = "sha256:5a6188fe2666c484a12159c18be97a1977a71d632ef5bb867ef15f54af39cc4e"}, +] + +[package.dependencies] +orderedmultidict = ">=1.0.1" +six = ">=1.8.0" + [[package]] name = "geoalchemy2" version = "0.15.2" @@ -2153,6 +2194,20 @@ files = [ [package.extras] ipython = ["graphviz"] +[[package]] +name = "orderedmultidict" +version = "1.0.1" +description = "Ordered Multivalue Dictionary" +optional = false +python-versions = "*" +files = [ + {file = "orderedmultidict-1.0.1-py2.py3-none-any.whl", hash = "sha256:43c839a17ee3cdd62234c47deca1a8508a3f2ca1d0678a3bf791c87cf84adbf3"}, + {file = "orderedmultidict-1.0.1.tar.gz", hash = "sha256:04070bbb5e87291cc9bfa51df413677faf2141c73c61d2a5f7b26bea3cd882ad"}, +] + +[package.dependencies] +six = ">=1.8.0" + [[package]] name = "owslib" version = "0.31.0" @@ -2404,6 +2459,17 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa typing = ["typing-extensions"] xmp = ["defusedxml"] +[[package]] +name = "pkce" +version = "1.0.3" +description = "PKCE Pyhton generator." +optional = false +python-versions = ">=3" +files = [ + {file = "pkce-1.0.3-py3-none-any.whl", hash = "sha256:55927e24c7d403b2491ebe182b95d9dcb1807643243d47e3879fbda5aad4471d"}, + {file = "pkce-1.0.3.tar.gz", hash = "sha256:9775fd76d8a743d39b87df38af1cd04a58c9b5a5242d5a6350ef343d06814ab6"}, +] + [[package]] name = "plaster" version = "1.1.2" @@ -2814,6 +2880,126 @@ files = [ {file = "pycryptodome-3.20.0.tar.gz", hash = "sha256:09609209ed7de61c2b560cc5c8c4fbf892f8b15b1faf7e4cbffac97db1fffda7"}, ] +[[package]] +name = "pydantic" +version = "2.8.2" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, + {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.20.1" +typing-extensions = {version = ">=4.6.1", markers = "python_version < \"3.13\""} + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.20.1" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, + {file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, + {file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"}, + {file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"}, + {file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, + {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, + {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, + {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, + {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"}, + {file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"}, + {file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"}, + {file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"}, + {file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, + {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + [[package]] name = "pydocstyle" version = "6.3.0" @@ -3961,6 +4147,31 @@ numpy = ">=1.14,<3" docs = ["matplotlib", "numpydoc (==1.1.*)", "sphinx", "sphinx-book-theme", "sphinx-remove-toctrees"] test = ["pytest", "pytest-cov"] +[[package]] +name = "simple_openid_connect" +version = "1.0.1" +description = "Simple and opinionated OpenID-Connect relying party and resource server implementation" +optional = false +python-versions = "~=3.9" +files = [] +develop = false + +[package.dependencies] +cryptojwt = ">=1.8,<2.0" +furl = ">=2.1,<3.0" +pydantic = ">=2.6,<3.0" +requests = ">=2.31,<3.0" + +[package.extras] +django = ["django (>=3.2)"] +djangorestframework = ["djangorestframework (>=3.13,<4.0)"] + +[package.source] +type = "git" +url = "https://github.com/sbrunner/py_simple_openid_connect.git" +reference = "allows-pkce" +resolved_reference = "b95ebee2b1bc4318ac514ca7b85ec77fe5cb2a87" + [[package]] name = "six" version = "1.16.0" @@ -4729,4 +4940,4 @@ test = ["zope.testing"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.11" -content-hash = "160a795c5498a4e9187ec0116f48a89b8063a9943c186e71a50db5e17a54a588" +content-hash = "8abd8e18d9df8ba862bb23ed53507b6aff2c0efc9ca752fdf161ecaef7f2d6a2" diff --git a/pyproject.toml b/pyproject.toml index 56d50668cb..2d98c6b49b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,9 +22,9 @@ line_length = 110 known_local_folder = ["c2cgeoportal_commons", "c2cgeoportal_geoportal", "c2cgeoportal_admin", "geomapfish_qgisserver", "{{cookiecutter.package}}_geoportal"] [tool.poetry] -name = 'c2cgeoportal' -version = '0.0.0' -description = 'Not used' +name = "c2cgeoportal" +version = "0.0.0" +description = "Not used" authors = [] [tool.poetry.dependencies] @@ -71,9 +71,11 @@ c2cwsgiutils = { version = "6.0.8", extras = ["broadcast", "standard", "oauth2", oauthlib = "3.2.2" tilecloud = "1.11.0" # geoportal azure-storage-blob = "12.19.1" +# simple_openid_connect = '1.0.1' # geoportal +simple_openid_connect = { git = "https://github.com/sbrunner/py_simple_openid_connect.git", branch = "allows-pkce" } # geoportal +pkce = '1.0.3' # geoportal basicauth = "1.0.0" prospector = { extras = ["with_mypy", "with_bandit", "with_pyroma"], version = "1.10.3" } -# pylint = "2.15.5" setuptools = "74.0.0" [tool.poetry.dev-dependencies] From e5f2cde4b19e47aea82f954e624911d3396a33cf Mon Sep 17 00:00:00 2001 From: "geo-ghci-int[bot]" <146321879+geo-ghci-int[bot]@users.noreply.github.com> Date: Fri, 30 Aug 2024 15:57:42 +0000 Subject: [PATCH 3/4] Add Alembic upgrade script From the artifact of the previous workflow run --- ...e9613256_wip_add_openid_connect_support.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 commons/c2cgeoportal_commons/alembic/static/aa41e9613256_wip_add_openid_connect_support.py diff --git a/commons/c2cgeoportal_commons/alembic/static/aa41e9613256_wip_add_openid_connect_support.py b/commons/c2cgeoportal_commons/alembic/static/aa41e9613256_wip_add_openid_connect_support.py new file mode 100644 index 0000000000..dc330d6689 --- /dev/null +++ b/commons/c2cgeoportal_commons/alembic/static/aa41e9613256_wip_add_openid_connect_support.py @@ -0,0 +1,63 @@ +# Copyright (c) 2024, Camptocamp SA +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: + +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# The views and conclusions contained in the software and documentation are those +# of the authors and should not be interpreted as representing official policies, +# either expressed or implied, of the FreeBSD Project. + +""" +Add OpenID connect support. + +Revision ID: aa41e9613256 +Revises: 910b4ca53b68 +Create Date: 2024-08-30 15:56:31.163378 +""" + +import sqlalchemy as sa +from alembic import op +from c2c.template.config import config +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "aa41e9613256" +down_revision = "910b4ca53b68" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Upgrade.""" + staticschema = config["schema_static"] + + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f("ix_main_static_user_email"), "user", ["email"], unique=False, schema=staticschema) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade.""" + staticschema = config["schema_static"] + + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_main_static_user_email"), table_name="user", schema=staticschema) + # ### end Alembic commands ### From cbcde553b41bd5da01ff23efef1391a9e0280c53 Mon Sep 17 00:00:00 2001 From: "geo-ghci-int[bot]" <146321879+geo-ghci-int[bot]@users.noreply.github.com> Date: Fri, 30 Aug 2024 18:10:54 +0000 Subject: [PATCH 4/4] Update dpkg versions list From the artifact of the previous workflow run --- ci/dpkg-versions.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ci/dpkg-versions.yaml b/ci/dpkg-versions.yaml index 570442a68c..594454af9c 100644 --- a/ci/dpkg-versions.yaml +++ b/ci/dpkg-versions.yaml @@ -1630,6 +1630,8 @@ camptocamp/geomapfish:latest: ubuntu_22_04/gcc-12-base: 12.3.0-1ubuntu1~22.04 ubuntu_22_04/gettext: 0.21-4ubuntu4 ubuntu_22_04/gettext-base: 0.21-4ubuntu4 + ubuntu_22_04/git: 1:2.34.1-1ubuntu1.11 + ubuntu_22_04/git-man: 1:2.34.1-1ubuntu1.11 ubuntu_22_04/gnupg: 2.2.27-3ubuntu2.1 ubuntu_22_04/gnupg-l10n: 2.2.27-3ubuntu2.1 ubuntu_22_04/gnupg-utils: 2.2.27-3ubuntu2.1 @@ -1698,6 +1700,7 @@ camptocamp/geomapfish:latest: ubuntu_22_04/libdrm-common: 2.4.113-2~ubuntu0.22.04.1 ubuntu_22_04/libdrm2: 2.4.113-2~ubuntu0.22.04.1 ubuntu_22_04/libepoxy0: 1.5.10-1 + ubuntu_22_04/liberror-perl: 0.17029-1 ubuntu_22_04/libexpat1: 2.4.7-1ubuntu0.3 ubuntu_22_04/libexpat1-dev: 2.4.7-1ubuntu0.3 ubuntu_22_04/libext2fs2: 1.46.5-2ubuntu1.1 @@ -1951,6 +1954,8 @@ camptocamp/geomapfishapp-geoportal:latest: ubuntu_22_04/gcc-12-base: 12.3.0-1ubuntu1~22.04 ubuntu_22_04/gettext: 0.21-4ubuntu4 ubuntu_22_04/gettext-base: 0.21-4ubuntu4 + ubuntu_22_04/git: 1:2.34.1-1ubuntu1.11 + ubuntu_22_04/git-man: 1:2.34.1-1ubuntu1.11 ubuntu_22_04/gnupg: 2.2.27-3ubuntu2.1 ubuntu_22_04/gnupg-l10n: 2.2.27-3ubuntu2.1 ubuntu_22_04/gnupg-utils: 2.2.27-3ubuntu2.1 @@ -2019,6 +2024,7 @@ camptocamp/geomapfishapp-geoportal:latest: ubuntu_22_04/libdrm-common: 2.4.113-2~ubuntu0.22.04.1 ubuntu_22_04/libdrm2: 2.4.113-2~ubuntu0.22.04.1 ubuntu_22_04/libepoxy0: 1.5.10-1 + ubuntu_22_04/liberror-perl: 0.17029-1 ubuntu_22_04/libexpat1: 2.4.7-1ubuntu0.3 ubuntu_22_04/libexpat1-dev: 2.4.7-1ubuntu0.3 ubuntu_22_04/libext2fs2: 1.46.5-2ubuntu1.1