diff --git a/CHANGELOG.md b/CHANGELOG.md index b437448..d3df921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0] - 2020-08-19 +### Added +- Allow to provide an `httpx.Client` instance for `*AuthorizationCode` flows (even `PKCE`), `*ClientCredentials` and `*ResourceOwnerPasswordCredentials` flows. + ## [0.4.0] - 2020-08-07 ### Changed - Mock an access token by default in `httpx_auth.testing.token_cache_mock`. Getting rid of `pyjwt` default dependency for testing. @@ -43,7 +47,8 @@ Note that a few changes were made: ### Added - Placeholder for port of requests_auth to httpx -[Unreleased]: https://github.com/Colin-b/httpx_auth/compare/v0.4.0...HEAD +[Unreleased]: https://github.com/Colin-b/httpx_auth/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/Colin-b/httpx_auth/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/Colin-b/httpx_auth/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/Colin-b/httpx_auth/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/Colin-b/httpx_auth/compare/v0.1.0...v0.2.0 diff --git a/README.md b/README.md index f12bdb6..f7f2958 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ with httpx.Client() as client: | `code_field_name` | Field name containing the code. | Optional | code | | `username` | User name in case basic authentication should be used to retrieve token. | Optional | | | `password` | User password in case basic authentication should be used to retrieve token. | Optional | | +| `client` | `httpx.Client` instance that will be used to request the token. Use it to provide a custom proxying rule for instance. | Optional | | Any other parameter will be put as query parameter in the authorization URL and as body parameters in the token URL. @@ -133,6 +134,7 @@ with httpx.Client() as client: | `failure_display_time` | In case received token is not valid, this is the maximum amount of milliseconds the failure page will be displayed in your browser. | Optional | 5000 | | `header_name` | Name of the header field used to send token. | Optional | Authorization | | `header_value` | Format used to send the token value. "{token}" must be present as it will be replaced by the actual token. | Optional | Bearer {token} | +| `client` | `httpx.Client` instance that will be used to request the token. Use it to provide a custom proxying rule for instance. | Optional | | Any other parameter will be put as query parameter in the authorization URL. @@ -172,6 +174,7 @@ with httpx.Client() as client: | `response_type` | Value of the response_type query parameter if not already provided in authorization URL. | Optional | code | | `token_field_name` | Field name containing the token. | Optional | access_token | | `code_field_name` | Field name containing the code. | Optional | code | +| `client` | `httpx.Client` instance that will be used to request the token. Use it to provide a custom proxying rule for instance. | Optional | | Any other parameter will be put as query parameter in the authorization URL and as body parameters in the token URL. @@ -224,6 +227,7 @@ with httpx.Client() as client: | `failure_display_time` | In case received token is not valid, this is the maximum amount of milliseconds the failure page will be displayed in your browser. | Optional | 5000 | | `header_name` | Name of the header field used to send token. | Optional | Authorization | | `header_value` | Format used to send the token value. "{token}" must be present as it will be replaced by the actual token. | Optional | Bearer {token} | +| `client` | `httpx.Client` instance that will be used to request the token. Use it to provide a custom proxying rule for instance. | Optional | | Any other parameter will be put as query parameter in the authorization URL and as body parameters in the token URL. @@ -260,6 +264,7 @@ with httpx.Client() as client: | `header_value` | Format used to send the token value. "{token}" must be present as it will be replaced by the actual token. | Optional | Bearer {token} | | `scope` | Scope parameter sent to token URL as body. Can also be a list of scopes. | Optional | | | `token_field_name` | Field name containing the token. | Optional | access_token | +| `client` | `httpx.Client` instance that will be used to request the token. Use it to provide a custom proxying rule for instance. | Optional | | Any other parameter will be put as body parameter in the token URL. @@ -289,6 +294,7 @@ with httpx.Client() as client: | `header_value` | Format used to send the token value. "{token}" must be present as it will be replaced by the actual token. | Optional | Bearer {token} | | `scope` | Scope parameter sent to token URL as body. Can also be a list of scopes. | Optional | | | `token_field_name` | Field name containing the token. | Optional | access_token | +| `client` | `httpx.Client` instance that will be used to request the token. Use it to provide a custom proxying rule for instance. | Optional | | Any other parameter will be put as body parameter in the token URL. @@ -327,6 +333,7 @@ with httpx.Client() as client: | `header_value` | Format used to send the token value. "{token}" must be present as it will be replaced by the actual token. | Optional | Bearer {token} | | `scope` | Scope parameter sent in query. Can also be a list of scopes. | Optional | openid | | `token_field_name` | Field name containing the token. | Optional | access_token | +| `client` | `httpx.Client` instance that will be used to request the token. Use it to provide a custom proxying rule for instance. | Optional | | Any other parameter will be put as query parameter in the token URL. diff --git a/httpx_auth/authentication.py b/httpx_auth/authentication.py index 247cd8b..4eda275 100644 --- a/httpx_auth/authentication.py +++ b/httpx_auth/authentication.py @@ -60,14 +60,17 @@ def _get_query_parameter(url: str, param_name: str) -> Optional[str]: def request_new_grant_with_post( - url: str, data, grant_name: str, timeout: float, auth=None + url: str, data, grant_name: str, client: httpx.Client ) -> (str, int): - response = httpx.post(url, data=data, timeout=timeout, auth=auth) - if response.is_error: - # As described in https://tools.ietf.org/html/rfc6749#section-5.2 - raise InvalidGrantRequest(response) + with client: + response = client.post(url, data=data) + + if response.is_error: + # As described in https://tools.ietf.org/html/rfc6749#section-5.2 + raise InvalidGrantRequest(response) + + content = response.json() - content = response.json() token = content.get(grant_name) if not token: raise GrantNotProvided(grant_name, content) @@ -146,6 +149,8 @@ def __init__(self, token_url: str, username: str, password: str, **kwargs): Token will be sent as "Bearer {token}" by default. :param scope: Scope parameter sent to token URL as body. Can also be a list of scopes. Not sent by default. :param token_field_name: Field name containing the token. access_token by default. + :param client: httpx.Client instance that will be used to request the token. + Use it to provide a custom proxying rule for instance. :param kwargs: all additional authorization parameters that should be put as body parameters in the token URL. """ self.token_url = token_url @@ -157,22 +162,19 @@ def __init__(self, token_url: str, username: str, password: str, **kwargs): self.password = password if not self.password: raise Exception("Password is mandatory.") - self.kwargs = kwargs - extra_parameters = dict(kwargs) - self.header_name = extra_parameters.pop("header_name", None) or "Authorization" - self.header_value = ( - extra_parameters.pop("header_value", None) or "Bearer {token}" - ) + self.header_name = kwargs.pop("header_name", None) or "Authorization" + self.header_value = kwargs.pop("header_value", None) or "Bearer {token}" if "{token}" not in self.header_value: raise Exception("header_value parameter must contains {token}.") - self.token_field_name = ( - extra_parameters.pop("token_field_name", None) or "access_token" - ) + self.token_field_name = kwargs.pop("token_field_name", None) or "access_token" # Time is expressed in seconds - self.timeout = int(extra_parameters.pop("timeout", None) or 60) + self.timeout = int(kwargs.pop("timeout", None) or 60) + self.client = kwargs.pop("client", None) or httpx.Client() + self.client.auth = (self.username, self.password) + self.client.timeout = self.timeout # As described in https://tools.ietf.org/html/rfc6749#section-4.3.2 self.data = { @@ -180,10 +182,10 @@ def __init__(self, token_url: str, username: str, password: str, **kwargs): "username": self.username, "password": self.password, } - scope = extra_parameters.pop("scope", None) + scope = kwargs.pop("scope", None) if scope: self.data["scope"] = " ".join(scope) if isinstance(scope, list) else scope - self.data.update(extra_parameters) + self.data.update(kwargs) all_parameters_in_url = _add_parameters(self.token_url, self.data) self.state = sha512(all_parameters_in_url.encode("unicode_escape")).hexdigest() @@ -198,11 +200,7 @@ def auth_flow( def request_new_token(self): # As described in https://tools.ietf.org/html/rfc6749#section-4.3.3 token, expires_in = request_new_grant_with_post( - self.token_url, - self.data, - self.token_field_name, - self.timeout, - auth=(self.username, self.password), + self.token_url, self.data, self.token_field_name, self.client ) # Handle both Access and Bearer tokens return (self.state, token, expires_in) if expires_in else (self.state, token) @@ -230,6 +228,8 @@ def __init__(self, token_url: str, client_id: str, client_secret: str, **kwargs) Token will be sent as "Bearer {token}" by default. :param scope: Scope parameter sent to token URL as body. Can also be a list of scopes. Not sent by default. :param token_field_name: Field name containing the token. access_token by default. + :param client: httpx.Client instance that will be used to request the token. + Use it to provide a custom proxying rule for instance. :param kwargs: all additional authorization parameters that should be put as query parameter in the token URL. """ self.token_url = token_url @@ -241,29 +241,27 @@ def __init__(self, token_url: str, client_id: str, client_secret: str, **kwargs) self.client_secret = client_secret if not self.client_secret: raise Exception("client_secret is mandatory.") - self.kwargs = kwargs - extra_parameters = dict(kwargs) - self.header_name = extra_parameters.pop("header_name", None) or "Authorization" - self.header_value = ( - extra_parameters.pop("header_value", None) or "Bearer {token}" - ) + self.header_name = kwargs.pop("header_name", None) or "Authorization" + self.header_value = kwargs.pop("header_value", None) or "Bearer {token}" if "{token}" not in self.header_value: raise Exception("header_value parameter must contains {token}.") - self.token_field_name = ( - extra_parameters.pop("token_field_name", None) or "access_token" - ) + self.token_field_name = kwargs.pop("token_field_name", None) or "access_token" # Time is expressed in seconds - self.timeout = int(extra_parameters.pop("timeout", None) or 60) + self.timeout = int(kwargs.pop("timeout", None) or 60) + + self.client = kwargs.pop("client", None) or httpx.Client() + self.client.auth = (self.client_id, self.client_secret) + self.client.timeout = self.timeout # As described in https://tools.ietf.org/html/rfc6749#section-4.4.2 self.data = {"grant_type": "client_credentials"} - scope = extra_parameters.pop("scope", None) + scope = kwargs.pop("scope", None) if scope: self.data["scope"] = " ".join(scope) if isinstance(scope, list) else scope - self.data.update(extra_parameters) + self.data.update(kwargs) all_parameters_in_url = _add_parameters(self.token_url, self.data) self.state = sha512(all_parameters_in_url.encode("unicode_escape")).hexdigest() @@ -278,11 +276,7 @@ def auth_flow( def request_new_token(self) -> tuple: # As described in https://tools.ietf.org/html/rfc6749#section-4.4.3 token, expires_in = request_new_grant_with_post( - self.token_url, - self.data, - self.token_field_name, - self.timeout, - auth=(self.client_id, self.client_secret), + self.token_url, self.data, self.token_field_name, self.client ) # Handle both Access and Bearer tokens return (self.state, token, expires_in) if expires_in else (self.state, token) @@ -327,6 +321,8 @@ def __init__(self, authorization_url: str, token_url: str, **kwargs): :param code_field_name: Field name containing the code. code by default. :param username: User name in case basic authentication should be used to retrieve token. :param password: User password in case basic authentication should be used to retrieve token. + :param client: httpx.Client instance that will be used to request the token. + Use it to provide a custom proxying rule for instance. :param kwargs: all additional authorization parameters that should be put as query parameter in the authorization URL and as body parameters in the token URL. Usual parameters are: @@ -354,6 +350,9 @@ def __init__(self, authorization_url: str, token_url: str, **kwargs): username = kwargs.pop("username", None) password = kwargs.pop("password", None) self.auth = (username, password) if username and password else None + self.client = kwargs.pop("client", None) or httpx.Client() + self.client.auth = self.auth + self.client.timeout = self.timeout # As described in https://tools.ietf.org/html/rfc6749#section-4.1.2 code_field_name = kwargs.pop("code_field_name", "code") @@ -415,11 +414,7 @@ def request_new_token(self): self.token_data["code"] = code # As described in https://tools.ietf.org/html/rfc6749#section-4.1.4 token, expires_in = request_new_grant_with_post( - self.token_url, - self.token_data, - self.token_field_name, - self.timeout, - auth=self.auth, + self.token_url, self.token_data, self.token_field_name, self.client ) # Handle both Access and Bearer tokens return (self.state, token, expires_in) if expires_in else (self.state, token) @@ -462,6 +457,8 @@ def __init__(self, authorization_url: str, token_url: str, **kwargs): code by default. :param token_field_name: Field name containing the token. access_token by default. :param code_field_name: Field name containing the code. code by default. + :param client: httpx.Client instance that will be used to request the token. + Use it to provide a custom proxying rule for instance. :param kwargs: all additional authorization parameters that should be put as query parameter in the authorization URL and as body parameters in the token URL. Usual parameters are: @@ -479,6 +476,9 @@ def __init__(self, authorization_url: str, token_url: str, **kwargs): BrowserAuth.__init__(self, kwargs) + self.client = kwargs.pop("client", None) or httpx.Client() + self.client.timeout = self.timeout + self.header_name = kwargs.pop("header_name", None) or "Authorization" self.header_value = kwargs.pop("header_value", None) or "Bearer {token}" if "{token}" not in self.header_value: @@ -560,7 +560,7 @@ def request_new_token(self) -> tuple: self.token_data["code"] = code # As described in https://tools.ietf.org/html/rfc6749#section-4.1.4 token, expires_in = request_new_grant_with_post( - self.token_url, self.token_data, self.token_field_name, self.timeout + self.token_url, self.token_data, self.token_field_name, self.client ) # Handle both Access and Bearer tokens return (self.state, token, expires_in) if expires_in else (self.state, token) @@ -943,6 +943,8 @@ def __init__(self, instance: str, client_id: str, **kwargs): :param header_value: Format used to send the token value. "{token}" must be present as it will be replaced by the actual token. Token will be sent as "Bearer {token}" by default. + :param client: httpx.Client instance that will be used to request the token. + Use it to provide a custom proxying rule for instance. :param kwargs: all additional authorization parameters that should be put as query parameter in the authorization URL. Usual parameters are: @@ -997,6 +999,8 @@ def __init__(self, instance: str, client_id: str, **kwargs): :param header_value: Format used to send the token value. "{token}" must be present as it will be replaced by the actual token. Token will be sent as "Bearer {token}" by default. + :param client: httpx.Client instance that will be used to request the token. + Use it to provide a custom proxying rule for instance. :param kwargs: all additional authorization parameters that should be put as query parameter in the authorization URL and as body parameters in the token URL. Usual parameters are: @@ -1037,6 +1041,8 @@ def __init__(self, instance: str, client_id: str, client_secret: str, **kwargs): :param scope: Scope parameter sent to token URL as body. Can also be a list of scopes. Request 'openid' by default. :param token_field_name: Field name containing the token. access_token by default. + :param client: httpx.Client instance that will be used to request the token. + Use it to provide a custom proxying rule for instance. :param kwargs: all additional authorization parameters that should be put as query parameter in the token URL. """ authorization_server = kwargs.pop("authorization_server", None) or "default" diff --git a/httpx_auth/version.py b/httpx_auth/version.py index 151e68f..c4b9ff7 100644 --- a/httpx_auth/version.py +++ b/httpx_auth/version.py @@ -3,4 +3,4 @@ # Major should be incremented in case there is a breaking change. (eg: 2.5.8 -> 3.0.0) # Minor should be incremented in case there is an enhancement. (eg: 2.5.8 -> 2.6.0) # Patch should be incremented in case there is a bug fix. (eg: 2.5.8 -> 2.5.9) -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/setup.py b/setup.py index 3ee544f..d27afc4 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ # Used to generate test tokens "pyjwt==1.*", # Used to mock httpx - "pytest_httpx==0.6.*", + "pytest_httpx==0.7.*", # Used to check coverage "pytest-cov==2.*", ] diff --git a/tests/auth_helper.py b/tests/auth_helper.py index f301ea0..c82c349 100644 --- a/tests/auth_helper.py +++ b/tests/auth_helper.py @@ -5,7 +5,7 @@ # TODO Remove def get_header(httpx_mock: HTTPXMock, auth: httpx.Auth) -> dict: # Mock a dummy response - httpx_mock.add_response() + httpx_mock.add_response(url="http://authorized_only", method="GET") # Send a request to this dummy URL with authentication response = httpx.get("http://authorized_only", auth=auth) # Return headers received on this dummy URL diff --git a/tests/test_oauth2_authorization_code.py b/tests/test_oauth2_authorization_code.py index e8d9d81..49e1363 100644 --- a/tests/test_oauth2_authorization_code.py +++ b/tests/test_oauth2_authorization_code.py @@ -6,6 +6,40 @@ from httpx_auth.testing import BrowserMock, browser_mock, token_cache +def test_oauth2_authorization_code_flow_uses_provided_client( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + client = httpx.Client(headers={"x-test": "Test value"}) + auth = httpx_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token", client=client + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_content=b"grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA", + match_headers={"x-test": "Test value"}, + ) + httpx_mock.add_response( + match_headers={"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"} + ) + # Send a request to this dummy URL with authentication + httpx.get("http://authorized_only", auth=auth) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + def test_oauth2_authorization_code_flow_get_code_is_sent_in_authorization_header_by_default( token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock ): diff --git a/tests/test_oauth2_authorization_code_okta.py b/tests/test_oauth2_authorization_code_okta.py index b1c55a2..c3dfd6b 100644 --- a/tests/test_oauth2_authorization_code_okta.py +++ b/tests/test_oauth2_authorization_code_okta.py @@ -7,6 +7,41 @@ from tests.auth_helper import get_header +def test_oauth2_authorization_code_flow_uses_provided_client( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + client = httpx.Client(headers={"x-test": "Test value"}) + auth = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", + "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + client=client, + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_content=b"grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA", + match_headers={"x-test": "Test value"}, + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + tab.assert_success( + "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + def test_oauth2_authorization_code_flow_get_code_is_sent_in_authorization_header_by_default( token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock ): diff --git a/tests/test_oauth2_authorization_code_pkce.py b/tests/test_oauth2_authorization_code_pkce.py index 8270d48..32cf16d 100644 --- a/tests/test_oauth2_authorization_code_pkce.py +++ b/tests/test_oauth2_authorization_code_pkce.py @@ -7,6 +7,40 @@ from httpx_auth.testing import BrowserMock, browser_mock, token_cache +def test_oauth2_pkce_flow_uses_provided_client( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + client = httpx.Client(headers={"x-test": "Test value"}) + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code", "http://provide_access_token", client=client + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_content=b"code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA", + match_headers={"x-test": "Test value"}, + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + def test_oauth2_pkce_flow_get_code_is_sent_in_authorization_header_by_default( token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock ): diff --git a/tests/test_oauth2_authorization_code_pkce_okta.py b/tests/test_oauth2_authorization_code_pkce_okta.py index 3e661f9..b4e2c06 100644 --- a/tests/test_oauth2_authorization_code_pkce_okta.py +++ b/tests/test_oauth2_authorization_code_pkce_okta.py @@ -7,6 +7,42 @@ from httpx_auth.testing import BrowserMock, browser_mock, token_cache +def test_oauth2_pkce_flow_uses_provided_client( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + client = httpx.Client(headers={"x-test": "Test value"}) + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", + "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + client=client, + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_content=b"code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA", + match_headers={"x-test": "Test value"}, + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + tab.assert_success( + "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + def test_oauth2_pkce_flow_get_code_is_sent_in_authorization_header_by_default( token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock ): diff --git a/tests/test_oauth2_client_credential.py b/tests/test_oauth2_client_credential.py index 70be4b9..a1d6d6d 100644 --- a/tests/test_oauth2_client_credential.py +++ b/tests/test_oauth2_client_credential.py @@ -7,6 +7,34 @@ from httpx_auth.testing import token_cache +def test_oauth2_client_credentials_flow_uses_provided_client( + token_cache, httpx_mock: HTTPXMock +): + client = httpx.Client(headers={"x-test": "Test value"}) + auth = httpx_auth.OAuth2ClientCredentials( + "http://provide_access_token", + client_id="test_user", + client_secret="test_pwd", + client=client, + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_headers={"x-test": "Test value"}, + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + + def test_oauth2_client_credentials_flow_token_is_sent_in_authorization_header_by_default( token_cache, httpx_mock: HTTPXMock ): diff --git a/tests/test_oauth2_client_credential_okta.py b/tests/test_oauth2_client_credential_okta.py index 94ffed0..98623af 100644 --- a/tests/test_oauth2_client_credential_okta.py +++ b/tests/test_oauth2_client_credential_okta.py @@ -1,10 +1,36 @@ from pytest_httpx import HTTPXMock +import httpx import httpx_auth from tests.auth_helper import get_header from httpx_auth.testing import token_cache +def test_okta_client_credentials_flow_uses_provided_client( + token_cache, httpx_mock: HTTPXMock +): + client = httpx.Client(headers={"x-test": "Test value"}) + auth = httpx_auth.OktaClientCredentials( + "test_okta", client_id="test_user", client_secret="test_pwd", client=client + ) + httpx_mock.add_response( + method="POST", + url="https://test_okta/oauth2/default/v1/token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_headers={"x-test": "Test value"}, + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + + def test_okta_client_credentials_flow_token_is_sent_in_authorization_header_by_default( token_cache, httpx_mock: HTTPXMock ): diff --git a/tests/test_oauth2_resource_owner_password.py b/tests/test_oauth2_resource_owner_password.py index 5fcd869..8bebe72 100644 --- a/tests/test_oauth2_resource_owner_password.py +++ b/tests/test_oauth2_resource_owner_password.py @@ -7,6 +7,35 @@ from httpx_auth.testing import token_cache +def test_oauth2_password_credentials_flow_uses_provided_client( + token_cache, httpx_mock: HTTPXMock +): + client = httpx.Client(headers={"x-test": "Test value"}) + auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", + username="test_user", + password="test_pwd", + client=client, + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_content=b"grant_type=password&username=test_user&password=test_pwd", + match_headers={"x-test": "Test value"}, + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + + def test_oauth2_password_credentials_flow_token_is_sent_in_authorization_header_by_default( token_cache, httpx_mock: HTTPXMock ):