Skip to content

Commit

Permalink
Merge pull request #63 from Colin-b/develop
Browse files Browse the repository at this point in the history
Release 5.2.0
  • Loading branch information
Colin-b authored Oct 14, 2020
2 parents 42cc20e + 5cbdd32 commit 1db3c45
Show file tree
Hide file tree
Showing 14 changed files with 354 additions and 68 deletions.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
repos:
- repo: https://github.com/psf/black
rev: master
rev: 20.8b1
hooks:
- id: black
1 change: 1 addition & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ python:
- "3.6"
- "3.7"
- "3.8"
- "3.9-dev"
install:
- pip install .[testing]
script:
Expand Down
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [5.2.0] - 2020-10-14
### Added
- Allow to provide a `requests.Session` instance for `*AuthorizationCode` flows (even `PKCE`), `*ClientCredentials` and `*ResourceOwnerPasswordCredentials` flows.
- Explicit support for Python 3.9

### Changed
- Code now follow `black==20.8b1` formatting instead of the git master version.

## [5.1.0] - 2020-03-04
### Added
- [`pytest`](https://docs.pytest.org/en/latest/) fixtures in `requests_auth.testing`. Refer to documentation for more details.
Expand Down Expand Up @@ -122,7 +130,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Public release

[Unreleased]: https://github.com/Colin-b/requests_auth/compare/v5.1.0...HEAD
[Unreleased]: https://github.com/Colin-b/requests_auth/compare/v5.2.0...HEAD
[5.2.0]: https://github.com/Colin-b/requests_auth/compare/v5.1.0...v5.2.0
[5.1.0]: https://github.com/Colin-b/requests_auth/compare/v5.0.2...v5.1.0
[5.0.2]: https://github.com/Colin-b/requests_auth/compare/v5.0.1...v5.0.2
[5.0.1]: https://github.com/Colin-b/requests_auth/compare/v5.0.0...v5.0.1
Expand Down
65 changes: 36 additions & 29 deletions README.md

Large diffs are not rendered by default.

85 changes: 51 additions & 34 deletions requests_auth/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,15 @@ 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, timeout: float, session: requests.Session
) -> (str, int):
response = requests.post(url, data=data, timeout=timeout, auth=auth)
if not response:
# As described in https://tools.ietf.org/html/rfc6749#section-5.2
raise InvalidGrantRequest(response)
with session:
response = session.post(url, data=data, timeout=timeout)
if not response:
# 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)
Expand Down Expand Up @@ -148,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 session: requests.Session 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
Expand All @@ -159,33 +162,29 @@ 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.session = kwargs.pop("session", None) or requests.Session()
self.session.auth = (self.username, self.password)

# As described in https://tools.ietf.org/html/rfc6749#section-4.3.2
self.data = {
"grant_type": "password",
"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()
Expand All @@ -202,7 +201,7 @@ def request_new_token(self):
self.data,
self.token_field_name,
self.timeout,
auth=(self.username, self.password),
self.session,
)
# Handle both Access and Bearer tokens
return (self.state, token, expires_in) if expires_in else (self.state, token)
Expand Down Expand Up @@ -230,6 +229,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 session: requests.Session 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
Expand All @@ -241,29 +242,26 @@ 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.session = kwargs.pop("session", None) or requests.Session()
self.session.auth = (self.client_id, self.client_secret)

# 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()
Expand All @@ -280,7 +278,7 @@ def request_new_token(self) -> tuple:
self.data,
self.token_field_name,
self.timeout,
auth=(self.client_id, self.client_secret),
self.session,
)
# Handle both Access and Bearer tokens
return (self.state, token, expires_in) if expires_in else (self.state, token)
Expand Down Expand Up @@ -325,6 +323,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 session: requests.Session 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:
Expand Down Expand Up @@ -352,6 +352,8 @@ 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.session = kwargs.pop("session", None) or requests.Session()
self.session.auth = self.auth

# As described in https://tools.ietf.org/html/rfc6749#section-4.1.2
code_field_name = kwargs.pop("code_field_name", "code")
Expand Down Expand Up @@ -415,7 +417,7 @@ def request_new_token(self):
self.token_data,
self.token_field_name,
self.timeout,
auth=self.auth,
self.session,
)
# Handle both Access and Bearer tokens
return (self.state, token, expires_in) if expires_in else (self.state, token)
Expand Down Expand Up @@ -460,6 +462,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 session: requests.Session 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:
Expand All @@ -477,6 +481,9 @@ def __init__(self, authorization_url: str, token_url: str, **kwargs):

BrowserAuth.__init__(self, kwargs)

self.session = kwargs.pop("session", None) or requests.Session()
self.session.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:
Expand Down Expand Up @@ -556,7 +563,11 @@ 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.timeout,
self.session,
)
# Handle both Access and Bearer tokens
return (self.state, token, expires_in) if expires_in else (self.state, token)
Expand Down Expand Up @@ -937,6 +948,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 session: requests.Session 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:
Expand Down Expand Up @@ -991,6 +1004,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 session: requests.Session 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:
Expand Down Expand Up @@ -1031,6 +1046,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 session: requests.Session 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"
Expand Down
2 changes: 1 addition & 1 deletion requests_auth/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = "5.1.0"
__version__ = "5.2.0"
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Topic :: Software Development :: Build Tools",
],
keywords=[
Expand Down
38 changes: 38 additions & 0 deletions tests/test_oauth2_authorization_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,44 @@
from tests.auth_helper import get_header, get_request


def test_oauth2_authorization_code_flow_uses_provided_session(
token_cache, responses: RequestsMock, browser_mock: BrowserMock
):
session = requests.Session()
session.headers.update({"x-test": "Test value"})
auth = requests_auth.OAuth2AuthorizationCode(
"http://provide_code", "http://provide_access_token", session=session
)
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",
)
responses.add(
responses.POST,
"http://provide_access_token",
json={
"access_token": "2YotnFZFEjr1zCsicMWpAA",
"token_type": "example",
"expires_in": 3600,
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
"example_parameter": "example_value",
},
)
assert (
get_header(responses, auth).get("Authorization")
== "Bearer 2YotnFZFEjr1zCsicMWpAA"
)
request = get_request(responses, "http://provide_access_token/")
assert (
request.body
== "grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA"
)
assert request.headers["x-test"] == "Test value"
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, responses: RequestsMock, browser_mock: BrowserMock
):
Expand Down
42 changes: 42 additions & 0 deletions tests/test_oauth2_authorization_code_okta.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,48 @@
from tests.auth_helper import get_header, get_request


def test_oauth2_authorization_code_flow_uses_provided_session(
token_cache, responses: RequestsMock, browser_mock: BrowserMock
):
session = requests.Session()
session.headers.update({"x-test": "Test value"})
auth = requests_auth.OktaAuthorizationCode(
"testserver.okta-emea.com",
"54239d18-c68c-4c47-8bdd-ce71ea1d50cd",
session=session,
)
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",
)
responses.add(
responses.POST,
"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",
},
)
assert (
get_header(responses, auth).get("Authorization")
== "Bearer 2YotnFZFEjr1zCsicMWpAA"
)
request = get_request(
responses, "https://testserver.okta-emea.com/oauth2/default/v1/token"
)
assert (
request.body
== "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"
)
assert request.headers["x-test"] == "Test value"
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, responses: RequestsMock, browser_mock: BrowserMock
):
Expand Down
Loading

0 comments on commit 1db3c45

Please sign in to comment.