diff --git a/poetry.lock b/poetry.lock index 267ce416..2800bdf0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -357,19 +357,6 @@ sphinx = ">=3.0" [package.extras] testing = ["pytest", "pytest-cov", "matplotlib"] -[[package]] -name = "oauthlib" -version = "3.2.0" -description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" -category = "main" -optional = true -python-versions = ">=3.6" - -[package.extras] -rsa = ["cryptography (>=3.0.0)"] -signals = ["blinker (>=1.4.0)"] -signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] - [[package]] name = "packaging" version = "21.3" @@ -396,17 +383,6 @@ importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] -[[package]] -name = "psutil" -version = "5.9.1" -description = "Cross-platform lib for process and system monitoring in Python." -category = "main" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[package.extras] -test = ["ipaddress", "mock", "enum34", "pywin32", "wmi"] - [[package]] name = "py" version = "1.11.0" @@ -622,6 +598,20 @@ urllib3 = ">=1.21.1,<1.27" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "requests-auth" +version = "6.0.0" +description = "Authentication for Requests" +category = "main" +optional = true +python-versions = ">=3.6" + +[package.dependencies] +requests = ">=2.0.0,<3.0.0" + +[package.extras] +testing = ["pyjwt (>=2.0.0,<3.0.0)", "pytest-responses (>=0.5.0,<0.6.0)", "pytest-cov (>=3.0.0,<4.0.0)"] + [[package]] name = "requests-kerberos" version = "0.13.0" @@ -679,21 +669,6 @@ cryptography = ">=1.3" ntlm-auth = ">=1.0.2" requests = ">=2.0.0" -[[package]] -name = "requests-oauthlib" -version = "1.3.1" -description = "OAuthlib authentication support for Requests." -category = "main" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[package.dependencies] -oauthlib = ">=3.0.0" -requests = ">=2.0.0" - -[package.extras] -rsa = ["oauthlib[signedtoken] (>=3.0.0)"] - [[package]] name = "secretstorage" version = "3.3.1" @@ -961,13 +936,13 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [extras] doc = ["pyansys-sphinx-theme", "numpydoc", "sphinx", "sphinx_autodoc_typehints", "sphinx-notfound-page", "sphinx-copybutton"] linux-kerberos = ["requests-kerberos"] -oidc = ["requests_oauthlib", "keyring"] -test = ["pytest", "pytest-cov", "uvicorn", "fastapi", "pydantic", "requests-mock", "pytest-mock", "covertable", "asgi_gssapi", "psutil"] +oidc = ["requests_auth", "keyring"] +test = ["pytest", "pytest-cov", "uvicorn", "fastapi", "pydantic", "requests-mock", "pytest-mock", "covertable", "asgi_gssapi"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "502f43c179f14bfa323ed1e2c6be0d65599ca16ad8634faf0ec2a4ce9daf1f6a" +content-hash = "bbafa52b84037293c139da42484508793e99f0b05884d67b648be96c161c7cb9" [metadata.files] alabaster = [ @@ -1261,10 +1236,6 @@ numpydoc = [ {file = "numpydoc-1.4.0-py3-none-any.whl", hash = "sha256:fd26258868ebcc75c816fe68e1d41e3b55bd410941acfb969dee3eef6e5cf260"}, {file = "numpydoc-1.4.0.tar.gz", hash = "sha256:9494daf1c7612f59905fa09e65c9b8a90bbacb3804d91f7a94e778831e6fcfa5"}, ] -oauthlib = [ - {file = "oauthlib-3.2.0-py3-none-any.whl", hash = "sha256:6db33440354787f9b7f3a6dbd4febf5d0f93758354060e802f6c06cb493022fe"}, - {file = "oauthlib-3.2.0.tar.gz", hash = "sha256:23a8208d75b902797ea29fd31fa80a15ed9dc2c6c16fe73f5d346f83f6fa27a2"}, -] packaging = [ {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, @@ -1273,40 +1244,6 @@ pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] -psutil = [ - {file = "psutil-5.9.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:799759d809c31aab5fe4579e50addf84565e71c1dc9f1c31258f159ff70d3f87"}, - {file = "psutil-5.9.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:9272167b5f5fbfe16945be3db475b3ce8d792386907e673a209da686176552af"}, - {file = "psutil-5.9.1-cp27-cp27m-win32.whl", hash = "sha256:0904727e0b0a038830b019551cf3204dd48ef5c6868adc776e06e93d615fc5fc"}, - {file = "psutil-5.9.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e7e10454cb1ab62cc6ce776e1c135a64045a11ec4c6d254d3f7689c16eb3efd2"}, - {file = "psutil-5.9.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:56960b9e8edcca1456f8c86a196f0c3d8e3e361320071c93378d41445ffd28b0"}, - {file = "psutil-5.9.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:44d1826150d49ffd62035785a9e2c56afcea66e55b43b8b630d7706276e87f22"}, - {file = "psutil-5.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7be9d7f5b0d206f0bbc3794b8e16fb7dbc53ec9e40bbe8787c6f2d38efcf6c9"}, - {file = "psutil-5.9.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd9246e4cdd5b554a2ddd97c157e292ac11ef3e7af25ac56b08b455c829dca8"}, - {file = "psutil-5.9.1-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29a442e25fab1f4d05e2655bb1b8ab6887981838d22effa2396d584b740194de"}, - {file = "psutil-5.9.1-cp310-cp310-win32.whl", hash = "sha256:20b27771b077dcaa0de1de3ad52d22538fe101f9946d6dc7869e6f694f079329"}, - {file = "psutil-5.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:58678bbadae12e0db55186dc58f2888839228ac9f41cc7848853539b70490021"}, - {file = "psutil-5.9.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3a76ad658641172d9c6e593de6fe248ddde825b5866464c3b2ee26c35da9d237"}, - {file = "psutil-5.9.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6a11e48cb93a5fa606306493f439b4aa7c56cb03fc9ace7f6bfa21aaf07c453"}, - {file = "psutil-5.9.1-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:068935df39055bf27a29824b95c801c7a5130f118b806eee663cad28dca97685"}, - {file = "psutil-5.9.1-cp36-cp36m-win32.whl", hash = "sha256:0f15a19a05f39a09327345bc279c1ba4a8cfb0172cc0d3c7f7d16c813b2e7d36"}, - {file = "psutil-5.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:db417f0865f90bdc07fa30e1aadc69b6f4cad7f86324b02aa842034efe8d8c4d"}, - {file = "psutil-5.9.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:91c7ff2a40c373d0cc9121d54bc5f31c4fa09c346528e6a08d1845bce5771ffc"}, - {file = "psutil-5.9.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fea896b54f3a4ae6f790ac1d017101252c93f6fe075d0e7571543510f11d2676"}, - {file = "psutil-5.9.1-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3054e923204b8e9c23a55b23b6df73a8089ae1d075cb0bf711d3e9da1724ded4"}, - {file = "psutil-5.9.1-cp37-cp37m-win32.whl", hash = "sha256:d2d006286fbcb60f0b391741f520862e9b69f4019b4d738a2a45728c7e952f1b"}, - {file = "psutil-5.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:b14ee12da9338f5e5b3a3ef7ca58b3cba30f5b66f7662159762932e6d0b8f680"}, - {file = "psutil-5.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:19f36c16012ba9cfc742604df189f2f28d2720e23ff7d1e81602dbe066be9fd1"}, - {file = "psutil-5.9.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:944c4b4b82dc4a1b805329c980f270f170fdc9945464223f2ec8e57563139cf4"}, - {file = "psutil-5.9.1-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b6750a73a9c4a4e689490ccb862d53c7b976a2a35c4e1846d049dcc3f17d83b"}, - {file = "psutil-5.9.1-cp38-cp38-win32.whl", hash = "sha256:a8746bfe4e8f659528c5c7e9af5090c5a7d252f32b2e859c584ef7d8efb1e689"}, - {file = "psutil-5.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:79c9108d9aa7fa6fba6e668b61b82facc067a6b81517cab34d07a84aa89f3df0"}, - {file = "psutil-5.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:28976df6c64ddd6320d281128817f32c29b539a52bdae5e192537bc338a9ec81"}, - {file = "psutil-5.9.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b88f75005586131276634027f4219d06e0561292be8bd6bc7f2f00bdabd63c4e"}, - {file = "psutil-5.9.1-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:645bd4f7bb5b8633803e0b6746ff1628724668681a434482546887d22c7a9537"}, - {file = "psutil-5.9.1-cp39-cp39-win32.whl", hash = "sha256:32c52611756096ae91f5d1499fe6c53b86f4a9ada147ee42db4991ba1520e574"}, - {file = "psutil-5.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:f65f9a46d984b8cd9b3750c2bdb419b2996895b005aefa6cbaba9a143b1ce2c5"}, - {file = "psutil-5.9.1.tar.gz", hash = "sha256:57f1819b5d9e95cdfb0c881a8a5b7d542ed0b7c522d575706a80bedc848c8954"}, -] py = [ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, @@ -1428,6 +1365,10 @@ requests = [ {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, ] +requests-auth = [ + {file = "requests_auth-6.0.0-py3-none-any.whl", hash = "sha256:77214cbbe067a209881735e24ca3a4366dabdff5c96c2b8179e961f108e9ca3e"}, + {file = "requests_auth-6.0.0.tar.gz", hash = "sha256:ef8b5d4c79edcecb919d2ee8001bd4c57d2e81601990542e808173f272b85772"}, +] requests-kerberos = [ {file = "requests-kerberos-0.13.0.tar.gz", hash = "sha256:477e153773cc430c514d0a679e1f5ea89a0e675ac2342c413866e03d1e82a817"}, {file = "requests_kerberos-0.13.0-py2.py3-none-any.whl", hash = "sha256:12766e209b975e081916b550b77b7a8b084c43f98beba1407182ef9a7bef88d5"}, @@ -1443,10 +1384,6 @@ requests-ntlm = [ {file = "requests_ntlm-1.1.0-py2.py3-none-any.whl", hash = "sha256:1eb43d1026b64d431a8e0f1e8a8c8119ac698e72e9b95102018214411a8463ea"}, {file = "requests_ntlm-1.1.0.tar.gz", hash = "sha256:9189c92e8c61ae91402a64b972c4802b2457ce6a799d658256ebf084d5c7eb71"}, ] -requests-oauthlib = [ - {file = "requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"}, - {file = "requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5"}, -] secretstorage = [ {file = "SecretStorage-3.3.1-py3-none-any.whl", hash = "sha256:422d82c36172d88d6a0ed5afdec956514b189ddbfb72fefab0c8a1cee4eaf71f"}, {file = "SecretStorage-3.3.1.tar.gz", hash = "sha256:fd666c51a6bf200643495a04abb261f83229dcb6fd8472ec393df7ffc8b6f195"}, diff --git a/pyproject.toml b/pyproject.toml index e9a1923f..72ac15f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ python-dateutil ="^2.6.1" typing-extensions = { version = "^4.1", python = "<3.8" } # Packages for oidc extra -requests_oauthlib = { version = "^1.3", optional = true } +requests_auth = { version = "^6.0", optional = true } keyring = { version = ">=22,<24", optional = true } # Packages for linux-kerberos extra @@ -63,7 +63,6 @@ requests-mock = { version = "*", optional = true } pytest-mock = { version = "*", optional = true } covertable = { version = "*", optional = true } asgi_gssapi = { version = "*", markers = "sys_platform == 'linux'", optional = true } -psutil = {version = "*", optional = true} # Doc packages pyansys-sphinx-theme = { version = "0.3.1", optional = true } @@ -75,7 +74,7 @@ sphinx-copybutton = { version = "0.5.0", optional = true } [tool.poetry.extras] oidc = [ - "requests_oauthlib", + "requests_auth", "keyring" ] @@ -92,8 +91,7 @@ test = [ "requests-mock", "pytest-mock", "covertable", - "asgi_gssapi", - "psutil", + "asgi_gssapi" ] doc = [ diff --git a/src/ansys/openapi/common/_oidc.py b/src/ansys/openapi/common/_oidc.py index e2aa028c..94ac69bb 100644 --- a/src/ansys/openapi/common/_oidc.py +++ b/src/ansys/openapi/common/_oidc.py @@ -1,16 +1,13 @@ -import asyncio import os -import threading -import webbrowser -from typing import Dict, Optional, Any +from typing import Optional import keyring import requests from requests.models import CaseInsensitiveDict -from requests_oauthlib import OAuth2Session # type: ignore +from requests_auth import OAuth2AuthorizationCodePKCE, InvalidGrantRequest # type: ignore[import] +from requests_auth.authentication import OAuth2 # type: ignore[import] from ._util import ( - OIDCCallbackHTTPServer, parse_authenticate, SessionConfiguration, set_session_kwargs, @@ -33,10 +30,6 @@ class OIDCSessionFactory: Creates an OpenID Connect session with the configuration fetched from the API server. This class uses either the provided token credentials or authorizes a user with a browser-based interactive prompt. - If your identity provider does not provide the exact scopes requested by your API server, you will be unable to - connect for security reasons. To force the client to proceed with non-matching scopes, set the environment variable - ``OAUTHLIB_RELAX_TOKEN_SCOPE`` to ``TRUE``. - Parameters ---------- initial_session : requests.Session @@ -61,9 +54,7 @@ def __init__( api_session_configuration: Optional[SessionConfiguration] = None, idp_session_configuration: Optional[SessionConfiguration] = None, ) -> None: - self._callback_server: "OIDCCallbackHTTPServer" self._initial_session = initial_session - self._oauth_session: OAuth2Session self._api_url = initial_response.url logger.debug("Creating OIDC session handler...") @@ -88,7 +79,6 @@ def __init__( self._idp_session_configuration = OIDCSessionFactory._override_idp_header( idp_session_configuration.get_configuration_for_requests() ) - self._well_known_parameters = self._fetch_and_parse_well_known( self._authenticate_parameters["authority"] ) @@ -101,54 +91,70 @@ def __init__( if "scope" in self._authenticate_parameters else [] ) - self._oauth_session = OAuth2Session( + + self._auth = OAuth2AuthorizationCodePKCE( + authorization_url=self._well_known_parameters["authorization_endpoint"], + token_url=self._well_known_parameters["token_endpoint"], + redirect_uri_port=32284, + audience=self._authenticate_parameters["apiAudience"] + if "apiAudience" in self._authenticate_parameters + else None, client_id=self._authenticate_parameters["clientid"], - redirect_uri=self._authenticate_parameters["redirecturi"], scope=scopes, + session=self._initial_session, ) - set_session_kwargs(self._oauth_session, self._api_session_configuration) - if "offline_access" in scopes: - self._configure_token_refresh() + # If using Auth0 we cannot provide an audience with requests + # to the token endpoint with grant_type=refresh_token. This + # causes the token to be returned without the audience + # required to access the user_info endpoint. + self._auth.refresh_data.pop("audience", None) - self._callback_server = OIDCCallbackHTTPServer() + self._authorized_session = requests.Session() + set_session_kwargs(self._authorized_session, self._api_session_configuration) logger.info("Configuration complete.") - def get_session_with_provided_token( - self, refresh_token: str, access_token: Optional[str] = None - ) -> OAuth2Session: + def get_session_with_provided_token(self, refresh_token: str) -> requests.Session: """Create a :class:`OAuth2Session` object with provided tokens. - This method configures a session to request an access token with the provided refresh token. - This will happen automatically when the first request is sent to the server. Alternatively, - an access token can be provided, and it will be used until it expires. After it expires, - the refresh token will be used to request a new access token. + This method configures a session to request an access token with the provided refresh token, + an access token will be requested immediately. Parameters ---------- refresh_token : str Refresh token for the API server, typically a Base64-encoded JSON Web Token. - access_token : Optional[str] - Access token for the API server, typically a Base64-encoded JSON web token. """ logger.info("Setting tokens...") - if access_token is not None: - if _log_tokens: - logger.debug(f"Setting access token: {access_token}") - self._oauth_session.token = { - "token_type": "bearer", - "access_token": access_token, - } - if refresh_token is not None: - if _log_tokens: - logger.debug(f"Setting refresh token: {refresh_token}") + if refresh_token is None: + raise ValueError("Must provide a value for 'refresh_token', not None") + if _log_tokens: + logger.debug(f"Setting refresh token: {refresh_token}") + try: + state, token, expires_in, new_refresh_token = self._auth.refresh_token( + refresh_token + ) + except InvalidGrantRequest as excinfo: + logger.debug(str(excinfo)) + raise ValueError( + "The provided refresh token was invalid, please request a new token." + ) + with OAuth2.token_cache.forbid_concurrent_missing_token_function_call: + # If we were provided with a new refresh token it's likely that the Identity + # Provider is configured to rotate refresh tokens. Store the new one and + # discard the old one. Otherwise use the existing refresh token. + if new_refresh_token is not None: + refresh_token = new_refresh_token # noinspection PyProtectedMember - self._oauth_session._client.refresh_token = refresh_token - return self._oauth_session + OAuth2.token_cache._add_access_token( + state, token, expires_in, refresh_token + ) + self._authorized_session.auth = self._auth + return self._authorized_session def get_session_with_stored_token( self, token_name: str = "ansys-openapi-common-oidc" - ) -> OAuth2Session: + ) -> requests.Session: """Create a :class:`OAuth2Session` object with a stored token. This method uses a token stored in the system keyring to authenticate the session. It requires a correctly @@ -172,7 +178,7 @@ def get_session_with_stored_token( def get_session_with_interactive_authorization( self, login_timeout: int = 60 - ) -> OAuth2Session: + ) -> requests.Session: """Create a :class:`OAuth2Session` object, authorizing the user via the system web browser. Parameters @@ -180,77 +186,11 @@ def get_session_with_interactive_authorization( login_timeout : int, optional Number of seconds to wait for the user to authenticate. The default is ``60s``. """ - authorization_url, state = self._oauth_session.authorization_url( - self._well_known_parameters["authorization_endpoint"] - ) - logger.info("Authenticating user...") - logger.debug(f"Opening web browser with URL {authorization_url}") - webbrowser.open(authorization_url) - auth_code = self._get_auth_code(login_timeout) - logger.info("Authentication complete, fetching token...") - if _log_tokens: - logger.debug(f"Received authorization code: {auth_code}") - _ = self._oauth_session.fetch_token( - self._well_known_parameters["token_endpoint"], - authorization_response=auth_code, - include_client_id=True, - **self._idp_session_configuration, - ) - if _log_tokens: - logger.debug(f"Access token: {self._oauth_session.token}") - if self._oauth_session.auto_refresh_url is not None: - # noinspection PyProtectedMember - logger.debug( - f"Refresh token: {self._oauth_session._client.refresh_token}" - ) - logger.info("Tokens retrieved successfully. Authentication complete.") - return self._oauth_session - - def _get_auth_code(self, login_timeout: int) -> str: - """Receive the callback request from the OpenID Connect identity provider. - - Parameters - ---------- - login_timeout : int, optional - Number of seconds to wait for the user to authenticate. - - Returns - ------- - str - The url of the callback request. The url contains the authentication code as a parameter. - """ - - self._callback_server.timeout = login_timeout - self._callback_server.handle_request() - auth_code = self._callback_server.auth_code - if not auth_code: - raise ValueError( - "No authorization code returned by OpenID Connect identity provider." - ) - del ( # Ensures bound port is released for subsequent OpenID Connect authentication sessions - self._callback_server - ) - return auth_code - - def _configure_token_refresh(self) -> None: - """Configure automatic token refresh, if available. - - This method only supports Authorization Code Flow currently. - """ - - def token_updater(token: Dict[str, str]) -> None: - self.token = token - self.access_token = token["access_token"] - - logger.info("Refresh tokens supported, configuring...") - self._oauth_session.auto_refresh_url = self._well_known_parameters[ - "token_endpoint" - ] - self._oauth_session.auto_refresh_kwargs = { - "client_id": self._authenticate_parameters["clientid"] - } - self._oauth_session.token_updater = token_updater + self._auth.timeout = login_timeout + self._authorized_session.auth = self._auth + self._authorized_session.get(self._api_url) + return self._authorized_session @staticmethod def _parse_unauthorized_header( @@ -358,7 +298,7 @@ def _override_idp_header( requests_configuration: RequestsConfiguration, ) -> RequestsConfiguration: """Override user-provided ``Accept`` and ``Content-Type`` headers to ensure correct - response from the OpenID identity povider. + response from the OpenID identity provider. Parameters ---------- diff --git a/src/ansys/openapi/common/_session.py b/src/ansys/openapi/common/_session.py index 8b190493..4cc7fa07 100644 --- a/src/ansys/openapi/common/_session.py +++ b/src/ansys/openapi/common/_session.py @@ -6,7 +6,7 @@ from urllib3.util.retry import Retry from requests.adapters import HTTPAdapter from requests.auth import HTTPBasicAuth -from requests_ntlm import HttpNtlmAuth # type: ignore +from requests_ntlm import HttpNtlmAuth # type: ignore[import] from . import __version__ from ._api_client import ApiClient @@ -29,7 +29,7 @@ try: # noinspection PyUnresolvedReferences - import requests_oauthlib # type: ignore + import requests_auth # type: ignore[import] import keyring from ._oidc import OIDCSessionFactory except ImportError: @@ -429,21 +429,16 @@ def with_stored_token( return self.with_token(refresh_token=refresh_token) - def with_token( - self, refresh_token: str, access_token: Optional[str] = None - ) -> ApiClientFactory: - """Use a provided access token or refresh token to authenticate the session. + def with_token(self, refresh_token: str) -> ApiClientFactory: + """Use a provided refresh token to authenticate the session. - If an access token is provided, it will be used immediately. When it expires, the token will - be refreshed. If no access token is provided, the refresh token is used immediately to fetch an - access token. + The refresh token will be used to request a new access token from the Identity Provider, + this will be automatically refreshed shortly before expiration. Parameters ---------- refresh_token : str Refresh token. - access_token : str - Access token. Returns ------- @@ -454,7 +449,7 @@ def with_token( return self._client_factory self._client_factory._session = ( self._session_factory.get_session_with_provided_token( - access_token=access_token, refresh_token=refresh_token + refresh_token=refresh_token ) ) self._client_factory._configured = True diff --git a/src/ansys/openapi/common/_util.py b/src/ansys/openapi/common/_util.py index 3cb88b1d..7577cade 100644 --- a/src/ansys/openapi/common/_util.py +++ b/src/ansys/openapi/common/_util.py @@ -184,59 +184,6 @@ def set_session_kwargs( session.__dict__[k] = v -class ResponseHandler(BaseHTTPRequestHandler): - """Provides an OpenID Connect callback handler. This class returns a page indicating authentication - completion when the authentication flow completes. - - Attributes - ---------- - _response_html : str - User-facing HTML to render when redirected after successful authentication with the identity provider. - """ - - def __init__(self, *args: Any, **kwargs: Any) -> None: - self._response_html = ( - r"" - r' ' - r" " - r' ' - r" {title}" - r" " - r" " - r"

{title}

" - r"

{paragraph}

" - r" " - r"".format( - title="Login successful", paragraph="You can now close this tab." - ).encode("utf-8") - ) - super().__init__(*args, **kwargs) - - # noinspection PyPep8Naming - def do_GET(self) -> None: - """Handle GET requests to the callback URL.""" - self.server.auth_code = "https://localhost{}".format(self.path) # type: ignore[attr-defined] - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.end_headers() - self.wfile.write(self._response_html) - - -class OIDCCallbackHTTPServer(HTTPServer): - """Provides the HTTP Server that is to handle callback requests on successful OpenID Connect authentication. - - Attributes - ---------- - auth_code : str, optional - Authentication code received from the user's browser when authentication completes. - The default is ``None`` when the ``auth_code`` has not yet been received. - """ - - def __init__(self) -> None: - super().__init__(("", 32284), ResponseHandler) - self.auth_code: Optional[str] = None - - class RequestsConfiguration(TypedDict): cert: Union[None, str, Tuple[str, str]] verify: Union[None, str, bool] diff --git a/tests/test_missing_imports.py b/tests/test_missing_imports.py index 54905ca6..7d1be8c1 100644 --- a/tests/test_missing_imports.py +++ b/tests/test_missing_imports.py @@ -36,7 +36,7 @@ def mocked_import(self, name, *args): return self.real_import(name, *args) def test_create_oidc_with_no_extra_throws(self, mocker): - self.blocked_import = "requests_oauthlib" + self.blocked_import = "requests_auth" mocker.patch("builtins.__import__", side_effect=self.mocked_import) from ansys.openapi.common import ApiClientFactory diff --git a/tests/test_oidc.py b/tests/test_oidc.py index 039d7364..a39530ac 100644 --- a/tests/test_oidc.py +++ b/tests/test_oidc.py @@ -1,9 +1,11 @@ import json +from urllib.parse import parse_qs import pytest import requests import requests_mock -from unittest.mock import Mock +from requests_auth.authentication import OAuth2 +from unittest.mock import Mock, MagicMock from covertable import make from ansys.openapi.common import ApiClientFactory @@ -169,17 +171,72 @@ def test_override_idp_configuration_with_no_headers_does_nothing(): assert response == configuration -@pytest.mark.parametrize("access_token", [None, "dGhpcyBpcyBhIHRva2VuLCBob25lc3Qh"]) -def test_setting_tokens_sets_tokens(access_token): +def test_setting_refresh_token_with_no_token_throws(): + mock_factory = Mock() + with pytest.raises(ValueError): + OIDCSessionFactory.get_session_with_provided_token(mock_factory, None) + + +def test_setting_refresh_token_sets_refresh_token(): mock_factory = Mock() refresh_token = "dGhpcyBpcyBhIHRva2VuLCBob25lc3Qh" + mock_factory._auth = Mock() + mock_factory._auth.refresh_token = MagicMock( + return_value=(0, "token", 1, refresh_token) + ) session = OIDCSessionFactory.get_session_with_provided_token( - mock_factory, refresh_token, access_token + mock_factory, refresh_token + ) + session.auth.refresh_token.assert_called_once_with(refresh_token) + assert OAuth2.token_cache.tokens[0][2] == refresh_token + + +def test_invalid_refresh_token_throws(): + api_url = "https://mi-api.com/api" + authority_url = "https://www.example.com/authority/" + client_id = "b4e44bfa-6b73-4d6a-9df6-8055216a5836" + refresh_token = "RrRNWQCQok6sXRn8eAGY4QXus1zq8fk9ZfDN-BeWEmUes" + redirect_uri = "https://www.example.com/login/" + authenticate_header = f'Bearer redirecturi="{redirect_uri}", authority="{authority_url}", clientid="{client_id}"' + well_known_response = json.dumps( + { + "token_endpoint": f"{authority_url}token", + "authorization_endpoint": f"{authority_url}authorization", + } ) - if access_token: - assert "access_token" in session.token - assert session.token["access_token"] == access_token - assert session._client.refresh_token == refresh_token + + def match_token_request(request): + if request.text is None: + return False + data = parse_qs(request.text) + return ( + data.get("client_id", "") == [client_id] + and data.get("grant_type", "") == ["refresh_token"] + and data.get("refresh_token", "") == [refresh_token] + ) + + with requests_mock.Mocker() as m: + m.get( + api_url, + status_code=401, + headers={"WWW-Authenticate": authenticate_header}, + ) + m.get( + f"{authority_url}.well-known/openid-configuration", + status_code=200, + text=well_known_response, + ) + m.post( + f"{authority_url}token", + status_code=401, + additional_matcher=match_token_request, + headers={"WWW-Authenticate": "Bearer error=invalid_token"}, + ) + with pytest.raises(ValueError) as exception_info: + ApiClientFactory(api_url).with_oidc().with_token( + refresh_token=refresh_token + ) + assert "refresh token was invalid" in str(exception_info) def test_endpoint_with_refresh_configures_correctly(): @@ -211,7 +268,7 @@ def test_endpoint_with_refresh_configures_correctly(): ) session = ApiClientFactory(secure_servicelayer_url).with_oidc() - oidc_factory = session._session_factory._oauth_session - assert oidc_factory.auto_refresh_url == f"{authority_url}token" - assert oidc_factory.auto_refresh_kwargs["client_id"] == client_id - session._session_factory._callback_server.server_close() + auth = session._session_factory._auth + + assert auth.token_url == f"{authority_url}token" + assert auth.refresh_data["client_id"] == client_id diff --git a/tests/test_session_creation.py b/tests/test_session_creation.py index 1266fd5a..bcd38ad1 100644 --- a/tests/test_session_creation.py +++ b/tests/test_session_creation.py @@ -1,6 +1,7 @@ import json import os from functools import wraps +from urllib.parse import parse_qs import pytest import requests_mock @@ -230,6 +231,7 @@ def test_can_connect_with_oidc_using_token(): redirect_uri = "https://www.example.com/login/" authority_url = "https://www.example.com/authority/" client_id = "b4e44bfa-6b73-4d6a-9df6-8055216a5836" + refresh_token = "RrRNWQCQok6sXRn8eAGY4QXus1zq8fk9ZfDN-BeWEmUes" authenticate_header = f'Bearer redirecturi="{redirect_uri}", authority="{authority_url}", clientid="{client_id}"' well_known_response = json.dumps( { @@ -237,12 +239,98 @@ def test_can_connect_with_oidc_using_token(): "authorization_endpoint": f"{authority_url}authorization", } ) + token_response = json.dumps( + { + "access_token": ACCESS_TOKEN, + "expires_in": 3600, + "refresh_token": refresh_token, + } + ) + + def match_token_request(request): + if request.text is None: + return False + data = parse_qs(request.text) + return ( + data.get("client_id", "") == [client_id] + and data.get("grant_type", "") == ["refresh_token"] + and data.get("refresh_token", "") == [refresh_token] + ) + + with requests_mock.Mocker() as m: + m.get( + f"{authority_url}.well-known/openid-configuration", + status_code=200, + text=well_known_response, + ) + m.post( + f"{authority_url}token", + status_code=200, + additional_matcher=match_token_request, + text=token_response, + ) + m.get( + SECURE_SERVICELAYER_URL, + status_code=401, + headers={"WWW-Authenticate": authenticate_header}, + ) + m.get( + SECURE_SERVICELAYER_URL, + status_code=200, + request_headers={"Authorization": f"Bearer {ACCESS_TOKEN}"}, + ) + session = ( + ApiClientFactory(SECURE_SERVICELAYER_URL) + .with_oidc() + .with_token(refresh_token=refresh_token) + .connect() + ) + resp = session.rest_client.get(SECURE_SERVICELAYER_URL) + assert resp.status_code == 200 + + +def test_can_connect_with_oidc_using_token(): + redirect_uri = "https://www.example.com/login/" + authority_url = "https://www.example.com/authority/" + client_id = "b4e44bfa-6b73-4d6a-9df6-8055216a5836" + refresh_token = "RrRNWQCQok6sXRn8eAGY4QXus1zq8fk9ZfDN-BeWEmUes" + authenticate_header = f'Bearer redirecturi="{redirect_uri}", authority="{authority_url}", clientid="{client_id}"' + well_known_response = json.dumps( + { + "token_endpoint": f"{authority_url}token", + "authorization_endpoint": f"{authority_url}authorization", + } + ) + token_response = json.dumps( + { + "access_token": ACCESS_TOKEN, + "expires_in": 3600, + "refresh_token": refresh_token, + } + ) + + def match_token_request(request): + if request.text is None: + return False + data = parse_qs(request.text) + return ( + data.get("client_id", "") == [client_id] + and data.get("grant_type", "") == ["refresh_token"] + and data.get("refresh_token", "") == [refresh_token] + ) + with requests_mock.Mocker() as m: m.get( f"{authority_url}.well-known/openid-configuration", status_code=200, text=well_known_response, ) + m.post( + f"{authority_url}token", + status_code=200, + additional_matcher=match_token_request, + text=token_response, + ) m.get( SECURE_SERVICELAYER_URL, status_code=401, @@ -256,7 +344,7 @@ def test_can_connect_with_oidc_using_token(): session = ( ApiClientFactory(SECURE_SERVICELAYER_URL) .with_oidc() - .with_token(access_token=ACCESS_TOKEN, refresh_token="") + .with_token(refresh_token=refresh_token) .connect() ) resp = session.rest_client.get(SECURE_SERVICELAYER_URL) diff --git a/tests/test_utils_misc.py b/tests/test_utils_misc.py index ebe02ad5..57885548 100644 --- a/tests/test_utils_misc.py +++ b/tests/test_utils_misc.py @@ -1,16 +1,7 @@ -import secrets -import threading -import psutil -import time -from multiprocessing import Process -import os - import pytest -import requests from ansys.openapi.common._util import ( CaseInsensitiveOrderedDict, - OIDCCallbackHTTPServer, ) @@ -89,82 +80,3 @@ def test_repr(self): repr = self.example_dict.__repr__() dict_from_repr = eval(repr) assert dict_from_repr == self.example_dict - - -def run_server(): - callback_server = OIDCCallbackHTTPServer() - callback_server.handle_request() - - -def check_port_binding(pid): - # Wait for the process to start up and bind to the port before returning - port_bound = False - attempts = 0 - while not port_bound: - time.sleep(5) - proc = psutil.Process(pid) - port_bound = any([conn.laddr.port == 32284 for conn in proc.connections()]) - attempts = attempts + 1 - if attempts == 5: - pid.terminate() - # Additional debugging - netstat_output = os.popen("netstat -p -at").readlines() - print("".join(netstat_output)) - raise RuntimeError("OIDCCallbackHTTPServer failed to bind to port 32284") - - -@pytest.fixture(scope="function") -def oidc_callback_server_process(): - # Run the OpenID Connect callback server in a process and return the process - # Doesn't perform any cleanup, so p.terminate() must be called by the test - p = Process(target=run_server, daemon=True) - p.start() - check_port_binding(p.pid) - return p - - -@pytest.fixture(scope="function") -def oidc_callback_server(): - # Run the OpenID Connect callback server in a thread and return the server - # Clean up when finished - callback_server = OIDCCallbackHTTPServer() - thread = threading.Thread(target=callback_server.handle_request) - thread.daemon = True - thread.start() - check_port_binding(os.getpid()) - yield callback_server - del callback_server - del thread - - -class TestOIDCHTTPServer: - def test_authorize_returns_200(self, oidc_callback_server_process): - resp = requests.get("http://localhost:32284") - oidc_callback_server_process.terminate() - - assert resp.status_code == 200 - assert "Login successful" in resp.text - assert "Content-Type" in resp.headers - assert "text/html" in resp.headers["Content-Type"] - - def test_authorize_with_code_parses_code(self, oidc_callback_server): - test_code = secrets.token_hex(32) - resp = requests.get(f"http://localhost:32284?code={test_code}") - assert resp.status_code == 200 - assert test_code in oidc_callback_server.auth_code - - -def test_oidc_callback_server_port_acquisition_and_release( - oidc_callback_server_process, -): - # Send a request that will cause the server to close - requests.get("http://localhost:32284?code=1234567890") - - # Check that the process is no longer bound to the OpenID Connect callback port - proc = psutil.Process(oidc_callback_server_process.pid) - connections = proc.connections(kind="tcp") - for conn in connections: - print(conn.laddr) - assert all([conn.laddr.port != 32284 for conn in connections]) - - oidc_callback_server_process.terminate()