diff --git a/.gitignore b/.gitignore index 78fe9c4c..8a991061 100644 --- a/.gitignore +++ b/.gitignore @@ -130,4 +130,5 @@ dist/ fastly_hourly_stats.ini -test.db-journal \ No newline at end of file +foo.json +test.db-journal diff --git a/arxiv/auth/auth_bridge.py b/arxiv/auth/auth_bridge.py new file mode 100644 index 00000000..05eae329 --- /dev/null +++ b/arxiv/auth/auth_bridge.py @@ -0,0 +1,33 @@ +from . import domain +from .legacy.util import _compute_capabilities +from .user_claims import ArxivUserClaims +from .legacy.authenticate import instantiate_tapir_user, _get_user_by_user_id +from ..db import transaction +from .legacy.sessions import create as legacy_create_session +from .legacy.cookies import pack as legacy_pack + +def populate_user_claims(user_claims: ArxivUserClaims): + """ + Populate the user's claims to the universe + """ + with transaction(): + passdata = _get_user_by_user_id(user_claims.user_id) + d_user, d_auth = instantiate_tapir_user(passdata) + + session: domain.Session = legacy_create_session(d_auth, user=d_user, + tracking_cookie=user_claims.session_id) + user_claims.update_claims('tapir_session_id', session.session_id) + + +def bake_cookies(user_claims: ArxivUserClaims) -> (str, str): + + cit_cookie = legacy_pack(user_claims.tapir_session_id, + issued_at=user_claims.issued_at, + user_id=user_claims.user_id, + capabilities=_compute_capabilities( + user_claims.is_admin, + user_claims.email_verified, + user_claims.is_god + )) + + return cit_cookie, ArxivUserClaims.to_arxiv_token_string diff --git a/arxiv/auth/legacy/authenticate.py b/arxiv/auth/legacy/authenticate.py index 8cf55329..cd60d5df 100644 --- a/arxiv/auth/legacy/authenticate.py +++ b/arxiv/auth/legacy/authenticate.py @@ -66,6 +66,29 @@ def authenticate(username_or_email: Optional[str] = None, except Exception as ex: raise AuthenticationFailed() from ex + return instantiate_tapir_user(passdata) + + +def instantiate_tapir_user(passdata: PassData) -> Tuple[domain.User, domain.Authorizations]: + """ + Make Tapir user data from pass-data + + Parameters + ---------- + passdata : PassData + + Returns + ------- + :class:`domain.User` + :class:`domain.Authorizations` + + Raises + ------ + :class:`AuthenticationFailed` + Failed to authenticate user with provided credentials. + :class:`Unavailable` + Unable to connect to DB. + """ db_user, _, db_nick, db_profile = passdata user = domain.User( user_id=str(db_user.user_id), diff --git a/arxiv/auth/legacy/util.py b/arxiv/auth/legacy/util.py index 9876e86f..dd69577e 100644 --- a/arxiv/auth/legacy/util.py +++ b/arxiv/auth/legacy/util.py @@ -55,9 +55,13 @@ def drop_all(engine: Engine) -> None: def compute_capabilities(tapir_user: TapirUser) -> int: """Calculate the privilege level code for a user.""" - return int(sum([2 * tapir_user.flag_edit_users, - 4 * tapir_user.flag_email_verified, - 8 * tapir_user.flag_edit_system])) + return _compute_capabilities(tapir_user.flag_edit_users, + tapir_user.flag_email_verified, + tapir_user.flag_edit_system) + +def _compute_capabilities(is_admin: int | bool, email_verified: int | bool, is_god: int | bool) -> int: + """Calculate the privilege level code for a user.""" + return int(sum([2 if is_admin else 0, 4 if email_verified else 0, 8 if is_god else 0])) def get_scopes(db_user: TapirUser) -> List[domain.Scope]: diff --git a/arxiv/auth/openid/__init__.py b/arxiv/auth/openid/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/arxiv/auth/openid/oidc_idp.py b/arxiv/auth/openid/oidc_idp.py new file mode 100644 index 00000000..f72993b5 --- /dev/null +++ b/arxiv/auth/openid/oidc_idp.py @@ -0,0 +1,394 @@ +""" +OpenID connect IdP client +""" +import urllib.parse +from typing import List, Optional +import requests +from requests.auth import HTTPBasicAuth +import jwt +from jwt.algorithms import RSAAlgorithm, RSAPublicKey + +import logging +from arxiv.base import logging as arxiv_logging + +from ..user_claims import ArxivUserClaims + +class ArxivOidcIdpClient: + """arXiv OpenID Connect IdP client + This is implemented for Keycloak at the moment. + If the APIs different, you may want to refactor for supreclass/subclass/adjust. + """ + + server_url: str + client_id: str + client_secret: str | None + realm: str + redirect_uri: str # + scope: List[str] # it's okay to be empty. Keycloak should be configured to provide scopes. + _server_certs: dict # Cache for the IdP certs + _logger: logging.Logger + _login_redirect_url: str + _logout_redirect_url: str + jwt_verify_options: dict + + def __init__(self, redirect_uri: str, + server_url: str = "https://openid.arxiv.org", + realm: str = "arxiv", + client_id: str = "arxiv-user", + scope: List[str] | None = None, + client_secret: str | None = None, + login_redirect_url: str | None = None, + logout_redirect_url: str | None = None, + logger: logging.Logger | None = None, + ): + """ + Make Tapir user data from pass-data + + Parameters + ---------- + redirect_uri: Callback URL - typically FOO/callback which is POSTED when the IdP + authentication succeeds. + server_url: IdP's URL + realm: OpenID's realm - for arXiv users, it should be "arxiv" + client_id: Registered client ID. OAuth2 client/callback are registered on IdP and need to + match + scope: List of OAuth2 scopes - Apparently, keycloak (v20?) dropped the "openid" scope. + Trying to include "openid" results in no such scope error. + client_secret: Registered client secret + login_redirect_url: redircet URL after log in + logout_redirect_url: redircet URL after log out + logger: Python logging logger instance + """ + self.server_url = server_url + self.realm = realm + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + self.scope = scope if scope else [] + self._login_redirect_url = login_redirect_url if login_redirect_url else "" + self._logout_redirect_url = logout_redirect_url if logout_redirect_url else "" + self._server_certs = {} + self._logger = logger or arxiv_logging.getLogger(__name__) + self.jwt_verify_options = { + "verify_signature": True, + "verify_iat": True, + "verify_nbf": True, + "verify_exp": True, + "verify_iss": True, + "verify_aud": False, # audience is "account" when it comes from olde tapir but not so for Keycloak. + } + pass + + @property + def oidc(self) -> str: + return f'{self.server_url}/realms/{self.realm}/protocol/openid-connect' + + @property + def auth_url(self) -> str: + return self.oidc + '/auth' + + @property + def token_url(self) -> str: + """https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint""" + return self.oidc + '/token' + + @property + def token_introspect_url(self) -> str: + return self.oidc + '/token/introspect' + + @property + def certs_url(self) -> str: + return self.oidc + '/certs' + + @property + def user_info_url(self) -> str: + return self.oidc + '/userinfo' + + def logout_url(self, user: ArxivUserClaims, redirect_url: str | None = None) -> str: + url = self._logout_redirect_url if redirect_url is None else redirect_url + post_logout = f"&post_logout_redirect_uri={urllib.parse.quote(url)}" if url else "" + return self.oidc + f'/logout?id_token_hint={user.id_token}{post_logout}' + + @property + def login_url(self) -> str: + scope = "&scope=" + "%20".join(self.scope) if self.scope else "" + url = f'{self.auth_url}?client_id={self.client_id}&redirect_uri={self.redirect_uri}&response_type=code{scope}' + self._logger.debug(f'login_url: {url}') + return url + + @property + def server_certs(self) -> dict: + """Get IdP server's SSL certificates""""openid" + # I'm having some 2nd thought about caching this. Fresh cert every time is probably needed + # if not self._server_certs: + # This adds one extra fetch but it avoids weird expired certs situation + certs_response = requests.get(self.certs_url) + self._server_certs = certs_response.json() + return self._server_certs + + def get_public_key(self, kid: str) -> RSAPublicKey | None: + """ + Find the public key for the given key + """ + for key in self.server_certs['keys']: + if key['kid'] == kid: + pkey = RSAAlgorithm.from_jwk(key) + if isinstance(pkey, RSAPublicKey): + return pkey + return None + + def acquire_idp_token(self, code: str) -> Optional[dict]: + """With the callback's code, go get the access token from IdP. + + Parameters + ---------- + code: When IdP calls back, it comes with the authentication code as a query parameter. + """ + auth = None + if self.client_secret: + try: + auth = HTTPBasicAuth(self.client_id, self.client_secret) + self._logger.debug(f'client auth success') + except requests.exceptions.RequestException: + self._logger.debug(f'client auth failed') + return None + except Exception as exc: + self._logger.warning(f'client auth failed', exc_info=True) + raise + + try: + # Exchange the authorization code for an access token + token_response = requests.post( + self.token_url, + data={ + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': self.redirect_uri, + 'client_id': self.client_id, + }, + auth=auth + ) + if token_response.status_code != 200: + self._logger.warning(f'idp %s', token_response.status_code) + return None + # returned data should be + # https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse + return token_response.json() + except requests.exceptions.RequestException: + return None + + def validate_access_token(self, access_token: str) -> dict | None: + """ + Given the IdP's access token, validate it and unpack the token payload. This should + comply to OpenID standard, hopefully. + + Parameters + ---------- + access_token: This is the access token in the IdP's token that you get from the code + + Return + ------ + None -> Invalid access token + dict -> The content of idp token as dict + """ + + try: + unverified_header = jwt.get_unverified_header(access_token) + kid = unverified_header['kid'] # key id + algorithm = unverified_header['alg'] # key algo + if algorithm[0:2] == "RS": + public_key = self.get_public_key(kid) + if public_key is None: + self._logger.info("Validating the token failed. kid=%s alg=%s", kid, algorithm) + return None + else: + public_key = None + + decoded_token: dict = jwt.decode(access_token, + key=public_key, + options=self.jwt_verify_options, + algorithms=[algorithm], + ) + return dict(decoded_token) + except jwt.InvalidAudienceError: + self._logger.error("") + return None + + except jwt.ExpiredSignatureError: + self._logger.error("IdP signature cert is expired.") + return None + except jwt.InvalidTokenError: + self._logger.error("jwt.InvalidTokenError: Token is invalid.", exc_info=True) + return None + # not reached + + def to_arxiv_user_claims(self, + idp_token: Optional[dict] = None, + kc_cliams: Optional[dict] = None) -> ArxivUserClaims: + """ + Given the IdP's access token claims, make Arxiv user claims. + + NOTE: As you can see, this is made for keycloak. If we use a different IdP, you want + to override this. + + Parameters + ---------- + idp_token: This is the IdP token which contains access, refresh, id tokens, etc. + + kc_cliams: This is the contents of (unpacked) access token. IdP signs it with the private + key, and the value is verified using the published public cert. + + NOTE: So this means there are two copies of access token. Unfortunate, but unpacking + every time can be costly. + """ + if idp_token is None: + idp_token = {} + if kc_cliams is None: + kc_cliams = {} + claims = ArxivUserClaims.from_keycloak_claims(idp_token=idp_token, kc_claims=kc_cliams) + return claims + + def from_code_to_user_claims(self, code: str) -> ArxivUserClaims | None: + """ + Put it all together + + When you get the /callback with the code that the IdP returned, go get the access token, + validate it, and then turn it to ArxivUserClaims + user_claims.to_arxiv_token_string, idp_token=idp_token + + Parameters + ---------- + code: The code you get in the /callback + + Returns + ------- + ArxivUserClaims: User's IdP claims + None: Something is wrong + + Note + ---- + When something goes wrong, generally, it is "invisible" from the user's point of view. + At the moment, only way to figure this out is to look at the logging. + + Generally speaking, when /callback gets the auth code, the rest should work. Only time this + isn't the case is something is wrong with IdP server, network issue, or bug in the code. + """ + idp_token = self.acquire_idp_token(code) + if not idp_token: + return None + access_token = idp_token.get('access_token') # oauth 2 access token + if not access_token: + return None + idp_claims = self.validate_access_token(access_token) + if not idp_claims: + return None + return self.to_arxiv_user_claims(idp_token, idp_claims) + + def logout_user(self, user: ArxivUserClaims) -> bool: + """With user's access token, logout user. + + Parameters + ---------- + user: ArxivUserClaims + """ + try: + header = { + "Authorization": f"Bearer {user.access_token}", + "Content-Type": "application/x-www-form-urlencoded" + } + except KeyError: + return None + + data = { + "client_id": self.client_id, + "id_token_hint": user.id_token, + } + if self.client_secret: + data["client_secret"] = self.client_secret + + url = self.logout_url(user) + log_extra = {'header': header, 'body': data} + self._logger.debug('Logout request %s', url, extra=log_extra) + try: + response = requests.post(url, headers=header, data=data, timeout=30) + if response.status_code == 200: + # If Keycloak is misconfigured, this does not log out. + # Turn front channel logout off in the logout settings of the client." + self._logger.info("Uesr %s logged out. - 200", user.user_id) + return True + + if response.status_code == 204: + self._logger.info("Uesr %s logged out.", user.user_id) + return True + + if response.status_code == 400: + self._logger.info("Uesr %s did not log. But, it's likely not logged in", user.user_id) + return True + + return False + + except requests.exceptions.RequestException as exc: + self._logger.error("Logout failed to connect to %s - %s", url, str(exc), exc_info=True, + extra=log_extra) + return False + + except Exception as exc: + self._logger.error("Logout failed to connect to %s - %s", url, str(exc), exc_info=True, + extra=log_extra) + return False + + + def refresh_access_token(self, refresh_token: str) -> ArxivUserClaims: + """With the refresh token, get a new access token + + Parameters + ---------- + refresh_token: str + refresh token which is given by OIDC + """ + + headers = { + "Content-Type": "application/x-www-form-urlencoded" + } + + auth = None + if self.client_secret: + try: + auth = HTTPBasicAuth(self.client_id, self.client_secret) + self._logger.debug(f'client auth success') + except requests.exceptions.RequestException: + self._logger.debug(f'client auth failed') + return None + except Exception as exc: + self._logger.warning(f'client auth failed', exc_info=True) + raise + + try: + # Exchange the refresh token for access token + token_response = requests.post( + self.token_url, + data={ + 'grant_type': 'refresh_token', + 'client_id': self.client_id, + 'refresh_token': refresh_token + }, + auth=auth, + headers=headers, + ) + if token_response.status_code != 200: + self._logger.warning(f'idp %s', token_response.status_code) + return None + # returned data should be + # https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse + # This should be identical shape payload as to the login + refreshed = token_response.json() + # be defensive and don't assume to have access_token + access_token = refreshed.get('access_token') + if not access_token: + return None + idp_claims = self.validate_access_token(access_token) + if not idp_claims: + return None + return self.to_arxiv_user_claims(refreshed, idp_claims) + except requests.exceptions.RequestException: + return None diff --git a/arxiv/auth/openid/tests/__init__.py b/arxiv/auth/openid/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/arxiv/auth/openid/tests/test_keycloak.py b/arxiv/auth/openid/tests/test_keycloak.py new file mode 100644 index 00000000..ef1b3244 --- /dev/null +++ b/arxiv/auth/openid/tests/test_keycloak.py @@ -0,0 +1,44 @@ +import subprocess +import pytest +from selenium import webdriver +from selenium.webdriver.common.by import By +import time + +@pytest.fixture(scope="module") +def web_driver() -> webdriver.Chrome: + # Set up the Selenium WebDriver + _web_driver = webdriver.Chrome() # Ensure you have ChromeDriver installed and in PATH + _web_driver.implicitly_wait(10) # Wait for elements to be ready + yield _web_driver + _web_driver.quit() # Close the browser window after tests + +@pytest.fixture(scope="module") +def toy_flask(): + flask_app = subprocess.Popen(["python3", "-m", "arxiv.auth.openid.tests.toy_flask"]) + time.sleep(5) # Give the server time to start + yield flask_app + # Stop the Flask app + flask_app.terminate() + flask_app.wait() + +def test_login(web_driver, toy_flask): + web_driver.get("http://localhost:5000/login") # URL of your Flask app's login route + + # Simulate user login on the IdP login page + # Replace the following selectors with the actual ones from your IdP login form + username_field = web_driver.find_element(By.ID, "username") # Example selector + password_field = web_driver.find_element(By.ID, "password") # Example selector + login_button = web_driver.find_element(By.ID, "kc-login") # Example selector + + # Enter credentials + username_field.send_keys("testuser") + password_field.send_keys("testpassword") + login_button.click() + + # Wait for the redirect back to the Flask app + time.sleep(5) + + # Check if the login was successful by verifying the presence of a specific element or text + web_driver.get("http://localhost:5000/protected") # URL of your protected route + body_text = web_driver.find_element(By.TAG_NAME, "body").text + assert "Token is valid" in body_text diff --git a/arxiv/auth/openid/tests/toy_flask.py b/arxiv/auth/openid/tests/toy_flask.py new file mode 100644 index 00000000..effa579b --- /dev/null +++ b/arxiv/auth/openid/tests/toy_flask.py @@ -0,0 +1,101 @@ +import os + +from flask import Flask, redirect, request, session, url_for, jsonify +from ..oidc_idp import ArxivOidcIdpClient, ArxivUserClaims +from ...auth.decorators import scoped +from ...auth.middleware import AuthMiddleware +from ....base.middleware import wrap +from ...auth import Auth + +import requests + +KEYCLOAK_SERVER_URL = os.environ.get('KEYCLOAK_SERVER_URL', 'localhost') +#REALM_NAME = 'arxiv' +#CLIENT_ID = 'arxiv-user' +#CLIENT_SECRET = 'your-client-secret' +#REDIRECT_URI = 'http://localhost:5000/callback' + + +def _is_admin (session: Dict, *args, **kwargs) -> bool: + try: + uid = session.user.user_id + except: + return False + return db_session.scalar( + select(TapirUser) + .filter(TapirUser.flag_edit_users == 1) + .filter(TapirUser.user_id == uid)) is not None + +admin_scoped = scoped( + required=None, + resource=None, + authorizer=_is_admin, + unauthorized=None +) + +class ToyFlask(Flask): + def __init__(self, *args: [], **kwargs: dict): + super().__init__(*args, **kwargs) + self.secret_key = 'secret' # Replace with a secure secret key + self.idp = ArxivOidcIdpClient("http://localhost:5000/callback", + server_url=KEYCLOAK_SERVER_URL) + +app = ToyFlask(__name__) + +@app.route('/') +def home(): + session.clear() + return redirect('/login') + +@app.route('/login') +def login(): + return redirect(app.idp.login_url) + + +@app.route('/callback') +def callback(): + # Get the authorization code from the callback URL + code = request.args.get('code') + user_claims = app.idp.from_code_to_user_claims(code) + + if user_claims is None: + session.clear() + return 'Something is wrong' + + + print(user_claims._claims) + session["access_token"] = user_claims.to_arxiv_token_string + + return 'Login successful!' + + +@app.route('/logout') +def logout(): + # Clear the session and redirect to home + session.clear() + return redirect(url_for('home')) + + +@app.route('/protected') +@admin_scoped +def protected(): + arxiv_access_token = session.get('access_token') + if not arxiv_access_token: + return redirect(app.idp.login_url) + + claims = ArxivUserClaims.from_arxiv_token_string(arxiv_access_token) + decoded_token = app.idp.validate_access_token(claims.access_token) + if not decoded_token: + return jsonify({'error': 'Invalid token'}), 401 + + return jsonify({'message': 'Token is valid', 'token': decoded_token}) + + +def create_app(): + return app + +if __name__ == '__main__': + os.environ.putenv('JWT_SECRET', 'secret') + Auth(app) + wrap(app, [AuthMiddleware]) + app.run(debug=True, host='0.0.0.0', port=5000) diff --git a/arxiv/auth/user_claims.py b/arxiv/auth/user_claims.py new file mode 100644 index 00000000..7524e3d1 --- /dev/null +++ b/arxiv/auth/user_claims.py @@ -0,0 +1,282 @@ +""" +User claims. + +The idea is that, when a user is authenticated, the claims represent who that is. +Keycloak: + + unpacked access token looks like this + { + 'exp': 1722520674, + 'iat': 1722484674, + 'auth_time': 1722484674, + 'jti': 'a45020b9-d7c4-4e28-9166-e95897007f4f', + 'iss': 'https://keycloak-service-6lhtms3oua-uc.a.run.app/realms/arxiv', + 'sub': '0cf6ee46-2186-45e0-a960-2012c12d3738', + 'typ': 'Bearer', + 'azp': 'arxiv-user', + 'sid': '7985f0a7-fd8c-4dc5-9261-44fd403a9edb', + 'acr': '1', + 'allowed-origins': ['http://localhost:5000'], + 'realm_access': + { + 'roles': ['Approved', 'AllowTexProduced']}, + 'scope': 'email profile', + 'email_verified': True, + 'name': 'Test User', + 'groups': ['Approved', 'AllowTexProduced'], + 'preferred_username': 'testuser', + 'given_name': 'Test', + 'family_name': 'User', + 'email': 'testuser@example.com' + } + + Tapir cookie data + return self._pack_cookie({ + 'user_id': session.user.user_id, + 'session_id': session.session_id, + 'nonce': session.nonce, + 'expires': session.end_time.isoformat() + }) + +""" + +# This needs to be tied to the tapir user +# + +import json +from datetime import datetime, timezone +from typing import Any, Optional, List, Tuple + +import jwt + + +def get_roles(realm_access: dict) -> Tuple[str, Any]: + return 'roles', realm_access['roles'] + + +claims_map = { + 'sub': 'sub', + 'exp': 'exp', + 'iat': 'iat', + 'realm_access': get_roles, + 'email_verified': 'email_p', + 'email': 'email', + "access_token": "acc", + "id_token": "idt", + "refresh_token": "refresh", +} + +class ArxivUserClaims: + """ + arXiv logged in user claims + """ + _claims: dict + + tapir_session_id: str + email_verified: bool + login_name: str + email: str + name: str + + def __init__(self, claims: dict) -> None: + """ + IdP token + """ + self._claims = claims.copy() + for key in self._claims.keys(): + self._create_property(key) + pass + + + def _create_property(self, name: str) -> None: + if not hasattr(self.__class__, name): + def getter(self: "ArxivUserClaims") -> Any: + return self._claims.get(name) + setattr(self.__class__, name, property(getter)) + + @property + def expires_at(self) -> datetime: + return datetime.utcfromtimestamp(float(self._claims.get('exp', 0))) + + @property + def issued_at(self) -> datetime: + return datetime.utcfromtimestamp(float(self._claims.get('iat', 0))) + + @property + def session_id(self) -> Optional[str]: + return self._claims.get('sid') + + @property + def user_id(self) -> Optional[str]: + return self._claims.get('sub') + + # jwt.encode/decode serialize/deserialize dict, not string so not really needed + @property + def to_arxiv_token_string(self) -> Optional[str]: + return json.dumps(self._claims) + + @property + def is_tex_pro(self) -> bool: + return "AllowTexProduced" in self._roles + + @property + def is_approved(self) -> bool: + return "Approved" in self._roles + + @property + def is_banned(self) -> bool: + return "Banned" in self._roles + + @property + def can_lock(self) -> bool: + return "CanLock" in self._roles + + @property + def is_owner(self) -> bool: + return "Owner" in self._roles + + @property + def is_admin(self) -> bool: + return "Administrator" in self._roles + + @property + def is_mod(self) -> bool: + return "Moderator" in self._roles + + @property + def is_legacy_user(self) -> bool: + return "Legacy user" in self._roles + + @property + def is_public_user(self) -> bool: + return "Public user" in self._roles + + @property + def _roles(self) -> List[str]: + return self._claims.get('roles', []) + + @property + def id_token(self) -> str: + """ + Keycloak id_token + """ + return self._claims.get('idt', "") + + @property + def access_token(self) -> str: + """ + Keycloak access (bearer) token + """ + return self._claims.get('acc', '') + + @property + def refresh_token(self) -> str: + """ + Keycloak refresh token + """ + return self._claims.get('refresh', '') + + @classmethod + def from_arxiv_token_string(cls, token: str) -> 'ArxivUserClaims': + return cls(json.loads(token)) + + @classmethod + def from_keycloak_claims(cls, + idp_token: Optional[dict] = None, + kc_claims: Optional[dict] = None) -> 'ArxivUserClaims': + """Make the user cliams from the IdP token and user claims + + The claims need to be compact as the cookie size is limited to 4096, tossing "uninteresting" + """ + claims = {} + # Flatten the idp token and claims + mushed = idp_token.copy() if idp_token else {} + if kc_claims: + mushed.update(kc_claims) + + for key, mapper in claims_map.items(): + if key not in mushed: + # This may be worth logging. + continue + value = mushed.get(key) + if callable(mapper): + mapped_key, mapped_value = mapper(value) + if mapped_key and mapped_value: + claims[mapped_key] = mapped_value + elif key in mushed: + claims[mapper] = value + return cls(claims) + + def is_expired(self, when: datetime | None = None) -> bool: + """ + Check if the claims is expired + """ + if when is None: + when = datetime.now(timezone.utc) + return when > self.expires_at + + def update_claims(self, tag: str, value: str) -> None: + """ + Add a value to the claims. Somewhat special so use it with caution + """ + self._claims[tag] = value + self._create_property(tag) + + def encode_jwt_token(self, secret: str, algorithm: str = 'HS256') -> str: + """packing user claims""" + claims = self._claims.copy() + del claims['idt'] + del claims['acc'] + if 'refresh' in claims: + del claims['refresh'] + payload = jwt.encode(claims, secret, algorithm=algorithm) + assert(',' not in self.id_token) + assert(',' not in self.access_token) + assert(',' not in payload) + # access_token = self.access_token + # remove the access token from packing. Payload is from access token, and since I'm not using this, + # save the space. + access_token = "-" + tokens = ["4", self.expires_at.isoformat(), self.id_token, access_token, payload] + if self.refresh_token: + assert (',' not in self.refresh_token) + tokens.append(self.refresh_token) + token = ",".join(tokens) + if len(token) > 4096: + raise ValueError(f'JWT token is too long {len(token)} bytes') + return token + + @classmethod + def unpack_token(cls, token: str) -> Tuple[dict, str]: + chunks = token.split(',') + # Chunk 0 should be 4 - is a version number + # chunk 1 is expiration - This is NOT internally used. This is to show + # to the UI when the cookie expires + # chunk 2 is id token + # chunk 3 is access token == '-' (removed) + # chunk 4 is payload + # chunk 5 is refresh token (may or may not exist) + if len(chunks) < 5: + raise ValueError(f'Token is invalid') + tokens = { + 'expires_at': chunks[1], + 'idt': chunks[2], + 'acc': chunks[3] + } + if len(chunks) > 5: + tokens['refresh'] = chunks[5] + return tokens, chunks[4] + + @classmethod + def decode_jwt_payload(cls, claims: dict, jwt_payload: str, secret: str, algorithm: str = 'HS256') -> "ArxivUserClaims": + payload = jwt.decode(jwt_payload, secret, algorithms = [algorithm]) + claims.update(payload) + return cls(claims) + + + def update_keycloak_access_token(self, updates: dict) -> None: + self._claims['acc'] = updates['acc'] + return + + + pass diff --git a/arxiv/auth/user_claims_to_legacy.py b/arxiv/auth/user_claims_to_legacy.py new file mode 100644 index 00000000..16c8bd8b --- /dev/null +++ b/arxiv/auth/user_claims_to_legacy.py @@ -0,0 +1,65 @@ +from typing import Tuple, Optional +from logging import getLogger +from .legacy.authenticate import PassData, _get_user_by_email, instantiate_tapir_user, NoSuchUser, _get_user_by_user_id +from .legacy.sessions import ( + create as create_legacy_session, + generate_cookie as generate_legacy_cookie, + invalidate as legacy_invalidate, +) +from ..db import transaction + + +from .user_claims import ArxivUserClaims +from .domain import Authorizations, Session + +# PassData = Tuple[TapirUser, TapirUsersPassword, TapirNickname, Demographic] + +def create_tapir_session_from_user_claims(user_claims: ArxivUserClaims, + client_host: str, + client_ip: str, + tracking_cookie: str = '', + ) -> Optional[Tuple[str, Session]]: + """ + Using the legacy tapir models, establish the session and return the legacy cookie. + + You need to be in a transaction. + """ + logger = getLogger(__name__) + passdata = None + try: + user_id = int(user_claims.user_id) + except ValueError: + user_id = user_claims.user_id + logger.warning("create_tapir_session_from_user_claims: User ID '%s' is not int", user_id) + pass + + try: + passdata = _get_user_by_user_id(user_id) + except NoSuchUser: + pass + + if passdata is None: + # passdata = create_legacy_user(user_claims) + return None + + tapir_user, legacy_auth = instantiate_tapir_user(passdata) + session: Session = create_legacy_session(legacy_auth, client_ip, client_host, + tracking_cookie=tracking_cookie, + user=tapir_user) + legacy_cookie = generate_legacy_cookie(session) + return legacy_cookie, session + + +def create_legacy_user(user_claims: ArxivUserClaims) -> PassData: + """ + Very likely, this isn't going to happen here. + """ + return "TBD" + + +def terminate_legacy_session(legacy_cookie: str) -> None: + """ + This is an alias to existing session killer. + """ + with transaction() as session: + legacy_invalidate(legacy_cookie) diff --git a/poetry.lock b/poetry.lock index 98db26f2..725e6e62 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "alabaster" @@ -133,6 +133,70 @@ files = [ {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, ] +[[package]] +name = "cffi" +version = "1.16.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"}, + {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"}, + {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"}, + {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"}, + {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"}, + {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"}, + {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"}, + {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"}, + {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"}, + {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"}, + {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"}, + {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"}, + {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"}, + {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"}, + {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "charset-normalizer" version = "3.3.2" @@ -886,6 +950,17 @@ googleapis-common-protos = ">=1.5.5" grpcio = ">=1.62.2" protobuf = ">=4.21.6" +[[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" +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 = "hypothesis" version = "6.102.4" @@ -1149,6 +1224,20 @@ files = [ {file = "mysqlclient-2.2.4.tar.gz", hash = "sha256:33bc9fb3464e7d7c10b1eaf7336c5ff8f2a3d3b88bab432116ad2490beb3bf41"}, ] +[[package]] +name = "outcome" +version = "1.3.0.post0" +description = "Capture the outcome of Python function calls." +optional = false +python-versions = ">=3.7" +files = [ + {file = "outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"}, + {file = "outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8"}, +] + +[package.dependencies] +attrs = ">=19.2.0" + [[package]] name = "packaging" version = "24.0" @@ -1248,6 +1337,17 @@ files = [ [package.dependencies] pyasn1 = ">=0.4.6,<0.7.0" +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + [[package]] name = "pydantic" version = "1.10.15" @@ -1348,6 +1448,18 @@ dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pyte docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] +[[package]] +name = "pysocks" +version = "1.7.1" +description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"}, + {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, + {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, +] + [[package]] name = "pytest" version = "8.2.1" @@ -1519,6 +1631,25 @@ botocore = ">=1.33.2,<2.0a.0" [package.extras] crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"] +[[package]] +name = "selenium" +version = "4.23.0" +description = "Official Python bindings for Selenium WebDriver" +optional = false +python-versions = ">=3.8" +files = [ + {file = "selenium-4.23.0-py3-none-any.whl", hash = "sha256:3fa5124c8ba071a2d22f7512a80e7799f5a5492d5e20ada1909fe66a476b2a44"}, + {file = "selenium-4.23.0.tar.gz", hash = "sha256:88f36e3fe6d1d3a9e0626f527f4bd00f0300d43e93f51f59771a911078d4f472"}, +] + +[package.dependencies] +certifi = ">=2021.10.8" +trio = ">=0.17,<1.0" +trio-websocket = ">=0.9,<1.0" +typing_extensions = ">=4.9.0,<4.10.0" +urllib3 = {version = ">=1.26,<3", extras = ["socks"]} +websocket-client = "1.8.0" + [[package]] name = "setuptools" version = "70.1.0" @@ -1545,6 +1676,17 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +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 = "snowballstemmer" version = "2.2.0" @@ -1836,15 +1978,63 @@ files = [ [package.extras] tests = ["pytest", "pytest-cov"] +[[package]] +name = "trio" +version = "0.26.0" +description = "A friendly Python library for async concurrency and I/O" +optional = false +python-versions = ">=3.8" +files = [ + {file = "trio-0.26.0-py3-none-any.whl", hash = "sha256:bb9c1b259591af941fccfbabbdc65bc7ed764bd2db76428454c894cd5e3d2032"}, + {file = "trio-0.26.0.tar.gz", hash = "sha256:67c5ec3265dd4abc7b1d1ab9ca4fe4c25b896f9c93dac73713778adab487f9c4"}, +] + +[package.dependencies] +attrs = ">=23.2.0" +cffi = {version = ">=1.14", markers = "os_name == \"nt\" and implementation_name != \"pypy\""} +idna = "*" +outcome = "*" +sniffio = ">=1.3.0" +sortedcontainers = "*" + +[[package]] +name = "trio-websocket" +version = "0.11.1" +description = "WebSocket library for Trio" +optional = false +python-versions = ">=3.7" +files = [ + {file = "trio-websocket-0.11.1.tar.gz", hash = "sha256:18c11793647703c158b1f6e62de638acada927344d534e3c7628eedcb746839f"}, + {file = "trio_websocket-0.11.1-py3-none-any.whl", hash = "sha256:520d046b0d030cf970b8b2b2e00c4c2245b3807853ecd44214acd33d74581638"}, +] + +[package.dependencies] +trio = ">=0.11" +wsproto = ">=0.14" + +[[package]] +name = "types-requests" +version = "2.32.0.20240712" +description = "Typing stubs for requests" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-requests-2.32.0.20240712.tar.gz", hash = "sha256:90c079ff05e549f6bf50e02e910210b98b8ff1ebdd18e19c873cd237737c1358"}, + {file = "types_requests-2.32.0.20240712-py3-none-any.whl", hash = "sha256:f754283e152c752e46e70942fa2a146b5bc70393522257bb85bd1ef7e019dcc3"}, +] + +[package.dependencies] +urllib3 = ">=2" + [[package]] name = "typing-extensions" -version = "4.11.0" +version = "4.9.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, - {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, + {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, + {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, ] [[package]] @@ -1858,6 +2048,9 @@ files = [ {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, ] +[package.dependencies] +pysocks = {version = ">=1.5.6,<1.5.7 || >1.5.7,<2.0", optional = true, markers = "extra == \"socks\""} + [package.extras] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] h2 = ["h2 (>=4,<5)"] @@ -1886,6 +2079,22 @@ files = [ {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, ] +[[package]] +name = "websocket-client" +version = "1.8.0" +description = "WebSocket client for Python with low level API options" +optional = false +python-versions = ">=3.8" +files = [ + {file = "websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526"}, + {file = "websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da"}, +] + +[package.extras] +docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx-rtd-theme (>=1.1.0)"] +optional = ["python-socks", "wsaccel"] +test = ["websockets"] + [[package]] name = "werkzeug" version = "3.0.3" @@ -1903,6 +2112,20 @@ MarkupSafe = ">=2.1.1" [package.extras] watchdog = ["watchdog (>=2.3)"] +[[package]] +name = "wsproto" +version = "1.2.0" +description = "WebSockets state-machine based protocol implementation" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"}, + {file = "wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"}, +] + +[package.dependencies] +h11 = ">=0.9.0,<1" + [[package]] name = "wtforms" version = "3.1.2" @@ -1926,4 +2149,4 @@ sphinx = ["sphinx", "sphinx-autodoc-typehints", "sphinxcontrib-websupport"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "c355759252de731ba6f40ffceb152166a7873941532338ee6ba6aa4eb66594cc" +content-hash = "01df212fb0ca6b2cbeacd2802da58a4c4fe40d5d51cab08f168f0172d33a379b" diff --git a/pyproject.toml b/pyproject.toml index 7fc0b850..349c6058 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,10 @@ click = "*" [tool.poetry.extras] sphinx = [ "sphinx", "sphinxcontrib-websupport", "sphinx-autodoc-typehints" ] +[tool.poetry.group.dev.dependencies] +selenium = "^4.23.0" +types-requests = "^2.32.0.20240712" + [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api"