diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d53f3c..25a73e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.0.0] - 2019-11-21 +### Changed +- OAuth2ClientCredentials username parameter is now client_id +- OAuth2ClientCredentials password parameter is now client_secret +- requests_auth.InvalidGrantRequest is now raised instead of requests.HTTPError in case a grant request was invalid. +- requests_auth.InvalidGrantRequest is now raised instead of requests_auth.GrantNotProvided in case a browser grant request was invalid. +- There is no info logging anymore. If you want to have those information (browser opening on a specific URL, requests received by the OAUth2 server), you will have to put requests_auth logger to DEBUG. + +### Removed +- Support for Python < 3.6 +- requests_auth.OAuth2Flow enum, use the proper auth class instead. +- requests_auth.okta function, use the proper auth class instead. +- requests_auth.aad function, use the proper auth class instead. +- requests_auth.oauth2 function, use the proper auth class instead. +- str representation of auth classes. + +### Fixed +- timeout parameter can now be a floating point value. (was only integer previously) + ## [4.1.0] - 2019-11-13 ### Added - module version is now publicly available. @@ -87,7 +106,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/v4.1.0...HEAD +[Unreleased]: https://github.com/Colin-b/requests_auth/compare/v5.0.0...HEAD +[5.0.0]: https://github.com/Colin-b/requests_auth/compare/v4.1.0...v5.0.0 [4.1.0]: https://github.com/Colin-b/requests_auth/compare/v4.0.1...v4.1.0 [4.0.1]: https://github.com/Colin-b/requests_auth/compare/v4.0.0...v4.0.1 [4.0.0]: https://github.com/Colin-b/requests_auth/compare/v3.0.0...v4.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 61a5725..8bcbc6a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,14 +38,17 @@ Before creating an issue please make sure that it was not already reported. #### Code -1) Create a new branch based on *develop* branch. -2) Add your changes. -3) Follow [Black](https://black.readthedocs.io/en/stable/) code formatting. - * Install pre-commit python module using pip: **python -m pip install pre-commit** - * To add the pre-commit hook, after the installation run: **pre-commit install** -4) Add at least one test case. +1) Create a new branch based on `develop` branch. +2) Fetch all dev dependencies. + * Install required python modules using `pip`: **python -m pip install .[testing]** +3) Ensure tests are ok by running them using [`pytest`](http://doc.pytest.org/en/latest/index.html). +4) Add your changes. +5) Follow [Black](https://black.readthedocs.io/en/stable/) code formatting. + * Install [pre-commit](https://pre-commit.com) python module using `pip`: **python -m pip install pre-commit** + * To add the [pre-commit](https://pre-commit.com) hook, after the installation run: **pre-commit install** +6) Add at least one [`pytest`](http://doc.pytest.org/en/latest/index.html) test case. * Unless it is an internal refactoring request or a documentation update. -5) Increment [version number](https://semver.org) and add related [changelog entry](https://keepachangelog.com/en/1.0.0/). +7) Increment [version number](https://semver.org) and add related [changelog entry](https://keepachangelog.com/en/1.0.0/). * Unless it is a documentation update. ##### Changelog entry @@ -55,7 +58,7 @@ Once the changelog entry is added, please don't forget to also add the link to t #### Enter pull request 1) Go to the *Pull requests* tab and click on the *New pull request* button. -2) *base* should always be set to development and it should be compared to your branch. +2) *base* should always be set to `develop` and it should be compared to your branch. 3) Title should be a small sentence describing the request. 3) The comment should contains as much information as possible * Actual behavior (before the new code) diff --git a/LICENSE b/LICENSE index f913fd2..17be259 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018 Colin Bounouar +Copyright (c) 2020 Colin Bounouar Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 4bf4483..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include README.md \ No newline at end of file diff --git a/README.md b/README.md index 4d475f9..176f01d 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,10 @@ <p align="center"> <a href="https://pypi.org/project/requests-auth/"><img alt="pypi version" src="https://img.shields.io/pypi/v/requests_auth"></a> <img alt="Build status" src="https://img.shields.io/badge/build-passing-brightgreen"> -<img alt="Coverage" src="https://img.shields.io/badge/coverage-94%25-brightgreen"> +<img alt="Coverage" src="https://img.shields.io/badge/coverage-100%25-brightgreen"> <a href="https://github.com/psf/black"><img alt="Code style: black" src="https://img.shields.io/badge/code%20style-black-000000.svg"></a> -<img alt="Number of tests" src="https://img.shields.io/badge/tests-95 passed-blue"> +<img alt="Number of tests" src="https://img.shields.io/badge/tests-167 passed-blue"> +<a href="https://pypi.org/project/requests-auth/"><img alt="Number of downloads" src="https://img.shields.io/pypi/dm/requests_auth"></a> </p> Provides authentication classes to be used with [`requests`][1] [authentication parameter][2]. @@ -264,7 +265,7 @@ Use `requests_auth.OAuth2ClientCredentials` to configure this kind of authentica import requests from requests_auth import OAuth2ClientCredentials -requests.get('http://www.example.com', auth=OAuth2ClientCredentials('https://www.token.url', 'user name', 'user password')) +requests.get('http://www.example.com', auth=OAuth2ClientCredentials('https://www.token.url', client_id='id', client_secret='secret')) ``` #### Parameters @@ -272,8 +273,8 @@ requests.get('http://www.example.com', auth=OAuth2ClientCredentials('https://www | Name | Description | Mandatory | Default value | |:-------------------|:---------------------------------------------|:----------|:--------------| | `token_url` | OAuth 2 token URL. | Mandatory | | -| `username` | Resource owner user name. | Mandatory | | -| `password` | Resource owner password. | Mandatory | | +| `client_id` | Resource owner user name. | Mandatory | | +| `client_secret` | Resource owner password. | Mandatory | | | `timeout` | Maximum amount of seconds to wait for a token to be received once requested. | Optional | 60 | | `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} | @@ -378,6 +379,8 @@ aad = AzureActiveDirectoryImplicit(tenant_id='45239d18-c68c-4c47-8bdd-ce71ea1d50 requests.get('http://www.example.com', auth=aad) ``` +You can retrieve Microsoft Azure Active Directory application information thanks to the [application list on Azure portal](https://portal.azure.com/#blade/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/AllApps/menuId/). + ###### Parameters | Name | Description | Mandatory | Default value | @@ -418,6 +421,8 @@ aad = AzureActiveDirectoryImplicitIdToken(tenant_id='45239d18-c68c-4c47-8bdd-ce7 requests.get('http://www.example.com', auth=aad) ``` +You can retrieve Microsoft Azure Active Directory application information thanks to the [application list on Azure portal](https://portal.azure.com/#blade/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/AllApps/menuId/). + ###### Parameters | Name | Description | Mandatory | Default value | diff --git a/requests_auth/__init__.py b/requests_auth/__init__.py index 21bfe45..2c2fb11 100644 --- a/requests_auth/__init__.py +++ b/requests_auth/__init__.py @@ -1,6 +1,3 @@ -from enum import Enum, auto -import warnings - from requests_auth.authentication import ( Basic, HeaderApiKey, @@ -29,80 +26,6 @@ StateNotProvided, InvalidToken, TokenExpiryNotProvided, + InvalidGrantRequest, ) from requests_auth.version import __version__ - - -class OAuth2Flow(Enum): - Implicit = (auto(),) - PasswordCredentials = (auto(),) # Also called Resource Owner Password Credentials - ClientCredentials = (auto(),) # Also called Application - AuthorizationCode = (auto(),) # Also called AccessCode - PKCE = (auto(),) - - -def oauth2(flow, *args, **kwargs): - """ - Create a new generic OAuth2 authentication class. - - :param flow: OAuth2 flow - :param args: all mandatory parameters that should be provided for this flow. - :param kwargs: optional parameters that can be provided for this flow. - :return: The newly created OAuth2 authentication class. - """ - warnings.warn( - "oauth2 function will be removed in the future. Use Oauth2* class instead.", - DeprecationWarning, - ) - if OAuth2Flow.Implicit == flow: - return OAuth2Implicit(*args, **kwargs) - if OAuth2Flow.AuthorizationCode == flow: - return OAuth2AuthorizationCode(*args, **kwargs) - if OAuth2Flow.PKCE == flow: - return OAuth2AuthorizationCodePKCE(*args, **kwargs) - if OAuth2Flow.ClientCredentials == flow: - return OAuth2ClientCredentials(*args, **kwargs) - if OAuth2Flow.PasswordCredentials == flow: - return OAuth2ResourceOwnerPasswordCredentials(*args, **kwargs) - - -def okta(flow, *args, **kwargs): - """ - Create a new OKTA authentication class. - - :param flow: OAuth2 flow - :param args: all mandatory parameters that should be provided for this flow. - :param kwargs: optional parameters that can be provided for this flow. - :return: The newly created OKTA authentication class. - """ - warnings.warn( - "okta function will be removed in the future. Use Okta* class instead.", - DeprecationWarning, - ) - if OAuth2Flow.Implicit == flow: - return OktaImplicit(*args, **kwargs) - if OAuth2Flow.AuthorizationCode == flow: - return OktaAuthorizationCode(*args, **kwargs) - if OAuth2Flow.ClientCredentials == flow: - return OktaClientCredentials(*args, **kwargs) - raise Exception("{0} flow is not handled yet in OKTA.".format(flow)) - - -def aad(flow, *args, **kwargs): - """ - Create a new Azure Active Directory authentication class. - - :param flow: OAuth2 flow - :param args: all mandatory parameters that should be provided for this flow. - :param kwargs: optional parameters that can be provided for this flow. - :return: The newly created Azure Active Directory authentication class. - """ - warnings.warn( - "aad function will be removed in the future. Use AzureActiveDirectory* class instead.", - DeprecationWarning, - ) - if OAuth2Flow.Implicit == flow: - return AzureActiveDirectoryImplicit(*args, **kwargs) - raise Exception( - "{0} flow is not handled yet in Azure Active Directory.".format(flow) - ) diff --git a/requests_auth/authentication.py b/requests_auth/authentication.py index be62c23..27aac84 100644 --- a/requests_auth/authentication.py +++ b/requests_auth/authentication.py @@ -1,26 +1,19 @@ import base64 import os -import sys import uuid from hashlib import sha256, sha512 +from urllib.parse import parse_qs, urlsplit, urlunsplit, urlencode +from typing import Optional import requests import requests.auth import warnings from requests_auth import oauth2_authentication_responses_server, oauth2_tokens -from requests_auth.errors import * +from requests_auth.errors import InvalidGrantRequest, GrantNotProvided -if sys.version_info.major > 2: - # Python 3 - from urllib.parse import parse_qs, urlsplit, urlunsplit, urlencode -else: - # Python 2 - from urllib import urlencode - from urlparse import parse_qs, urlsplit, urlunsplit - -def _add_parameters(initial_url, extra_parameters): +def _add_parameters(initial_url: str, extra_parameters: dict) -> str: """ Add parameters to an URL and return the new URL. @@ -40,7 +33,7 @@ def _add_parameters(initial_url, extra_parameters): return urlunsplit((scheme, netloc, path, new_query_string, fragment)) -def _pop_parameter(url, query_parameter_name): +def _pop_parameter(url: str, query_parameter_name: str) -> (str, Optional[str]): """ Remove and return parameter of an URL. @@ -59,16 +52,20 @@ def _pop_parameter(url, query_parameter_name): ) -def _get_query_parameter(url, param_name): +def _get_query_parameter(url: str, param_name: str) -> Optional[str]: scheme, netloc, path, query_string, fragment = urlsplit(url) query_params = parse_qs(query_string) all_values = query_params.get(param_name) return all_values[0] if all_values else None -def request_new_grant_with_post(url, data, grant_name, timeout, auth=None): +def request_new_grant_with_post( + url: str, data, grant_name: str, timeout: float, auth=None +) -> (str, int): response = requests.post(url, data=data, timeout=timeout, auth=auth) - response.raise_for_status() + if not response: + # As described in https://tools.ietf.org/html/rfc6749#section-5.2 + raise InvalidGrantRequest(response) content = response.json() token = content.get(grant_name) @@ -81,7 +78,48 @@ class OAuth2: token_cache = oauth2_tokens.TokenMemoryCache() -class OAuth2ResourceOwnerPasswordCredentials(requests.auth.AuthBase): +class SupportMultiAuth: + """Inherit from this class to be able to use your class with requests_auth provided authentication classes.""" + + def __add__(self, other): + if isinstance(other, Auths): + return Auths(self, *other.authentication_modes) + return Auths(self, other) + + +class BrowserAuth: + def __init__(self, kwargs): + """ + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://localhost:<redirect_uri_port>/<redirect_uri_endpoint>. Default value is to redirect on / (root). + :param redirect_uri_port: The port on which the server listening for the OAuth 2 code will be started. + Listen on port 5000 by default. + :param timeout: Maximum amount of seconds to wait for a code or a token to be received once requested. + Wait for 1 minute (60 seconds) by default. + :param success_display_time: In case a code is successfully received, + this is the maximum amount of milliseconds the success page will be displayed in your browser. + Display the page for 1 millisecond by default. + :param failure_display_time: In case received code is not valid, + this is the maximum amount of milliseconds the failure page will be displayed in your browser. + Display the page for 5 seconds by default. + """ + redirect_uri_endpoint = kwargs.pop("redirect_uri_endpoint", None) or "" + self.redirect_uri_port = int(kwargs.pop("redirect_uri_port", None) or 5000) + self.redirect_uri = ( + f"http://localhost:{self.redirect_uri_port}/{redirect_uri_endpoint}" + ) + + # Time is expressed in seconds + self.timeout = float(kwargs.pop("timeout", None) or 60) + # Time is expressed in milliseconds + self.success_display_time = int(kwargs.pop("success_display_time", None) or 1) + # Time is expressed in milliseconds + self.failure_display_time = int( + kwargs.pop("failure_display_time", None) or 5000 + ) + + +class OAuth2ResourceOwnerPasswordCredentials(requests.auth.AuthBase, SupportMultiAuth): """ Resource Owner Password Credentials Grant @@ -89,7 +127,7 @@ class OAuth2ResourceOwnerPasswordCredentials(requests.auth.AuthBase): More details can be found in https://tools.ietf.org/html/rfc6749#section-4.3 """ - def __init__(self, token_url, username, password, **kwargs): + def __init__(self, token_url: str, username: str, password: str, **kwargs): """ :param token_url: OAuth 2 token URL. :param username: Resource owner user name. @@ -162,21 +200,8 @@ def request_new_token(self): # Handle both Access and Bearer tokens return (self.state, token, expires_in) if expires_in else (self.state, token) - def __add__(self, other): - if isinstance(other, Auths): - return Auths(self, *other.authentication_modes) - return Auths(self, other) - - def __str__(self): - addition_args_str = ", ".join( - ["{0}='{1}'".format(key, value) for key, value in self.kwargs.items()] - ) - return "OAuth2ResourceOwnerPasswordCredentials('{0}', '{1}', '{2}', {3})".format( - self.token_url, self.username, self.password, addition_args_str - ) - -class OAuth2ClientCredentials(requests.auth.AuthBase): +class OAuth2ClientCredentials(requests.auth.AuthBase, SupportMultiAuth): """ Client Credentials Grant @@ -184,11 +209,11 @@ class OAuth2ClientCredentials(requests.auth.AuthBase): More details can be found in https://tools.ietf.org/html/rfc6749#section-4.4 """ - def __init__(self, token_url, username, password, **kwargs): + def __init__(self, token_url: str, client_id: str, client_secret: str, **kwargs): """ :param token_url: OAuth 2 token URL. - :param username: Resource owner user name. - :param password: Resource owner password. + :param client_id: Resource owner user name. + :param client_secret: Resource owner password. :param timeout: Maximum amount of seconds to wait for a token to be received once requested. Wait for 1 minute by default. :param header_name: Name of the header field used to send token. @@ -203,12 +228,12 @@ def __init__(self, token_url, username, password, **kwargs): self.token_url = token_url if not self.token_url: raise Exception("Token URL is mandatory.") - self.username = username - if not self.username: - raise Exception("User name is mandatory.") - self.password = password - if not self.password: - raise Exception("Password is mandatory.") + self.client_id = client_id + if not self.client_id: + raise Exception("client_id is mandatory.") + self.client_secret = client_secret + if not self.client_secret: + raise Exception("client_secret is mandatory.") self.kwargs = kwargs extra_parameters = dict(kwargs) @@ -241,33 +266,20 @@ def __call__(self, r): r.headers[self.header_name] = self.header_value.format(token=token) return r - def request_new_token(self): + 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.username, self.password), + auth=(self.client_id, self.client_secret), ) # Handle both Access and Bearer tokens return (self.state, token, expires_in) if expires_in else (self.state, token) - def __add__(self, other): - if isinstance(other, Auths): - return Auths(self, *other.authentication_modes) - return Auths(self, other) - def __str__(self): - addition_args_str = ", ".join( - ["{0}='{1}'".format(key, value) for key, value in self.kwargs.items()] - ) - return "OAuth2ClientCredentials('{0}', '{1}', '{2}', {3})".format( - self.token_url, self.username, self.password, addition_args_str - ) - - -class OAuth2AuthorizationCode(requests.auth.AuthBase): +class OAuth2AuthorizationCode(requests.auth.AuthBase, SupportMultiAuth, BrowserAuth): """ Authorization Code Grant @@ -279,7 +291,7 @@ class OAuth2AuthorizationCode(requests.auth.AuthBase): More details can be found in https://tools.ietf.org/html/rfc6749#section-4.1 """ - def __init__(self, authorization_url, token_url, **kwargs): + def __init__(self, authorization_url: str, token_url: str, **kwargs): """ :param authorization_url: OAuth 2 authorization URL. :param token_url: OAuth 2 token URL. @@ -320,55 +332,31 @@ def __init__(self, authorization_url, token_url, **kwargs): self.token_url = token_url if not self.token_url: raise Exception("Token URL 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}" - ) + BrowserAuth.__init__(self, kwargs) + + 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}.") - redirect_uri_endpoint = ( - extra_parameters.pop("redirect_uri_endpoint", None) or "" - ) - redirect_uri_port = int(extra_parameters.pop("redirect_uri_port", None) or 5000) - - 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) - # Time is expressed in milliseconds - success_display_time = int( - extra_parameters.pop("success_display_time", None) or 1 - ) - # Time is expressed in milliseconds - failure_display_time = int( - extra_parameters.pop("failure_display_time", None) or 5000 - ) - - username = extra_parameters.pop("username", None) - password = extra_parameters.pop("password", None) + username = kwargs.pop("username", None) + password = kwargs.pop("password", None) self.auth = (username, password) if username and password else None # As described in https://tools.ietf.org/html/rfc6749#section-4.1.2 - code_field_name = extra_parameters.pop("code_field_name", "code") + code_field_name = kwargs.pop("code_field_name", "code") if _get_query_parameter(self.authorization_url, "response_type"): - extra_parameters.pop( - "response_type", None - ) # Ensure provided value will not be overridden + # Ensure provided value will not be overridden + kwargs.pop("response_type", None) else: # As described in https://tools.ietf.org/html/rfc6749#section-4.1.1 - extra_parameters.setdefault("response_type", "code") + kwargs.setdefault("response_type", "code") - redirect_uri = "http://localhost:{0}/{1}".format( - redirect_uri_port, redirect_uri_endpoint - ) authorization_url_without_nonce = _add_parameters( - self.authorization_url, extra_parameters + self.authorization_url, kwargs ) authorization_url_without_nonce, nonce = _pop_parameter( authorization_url_without_nonce, "nonce" @@ -376,7 +364,10 @@ def __init__(self, authorization_url, token_url, **kwargs): self.state = sha512( authorization_url_without_nonce.encode("unicode_escape") ).hexdigest() - custom_code_parameters = {"state": self.state, "redirect_uri": redirect_uri} + custom_code_parameters = { + "state": self.state, + "redirect_uri": self.redirect_uri, + } if nonce: custom_code_parameters["nonce"] = nonce code_grant_url = _add_parameters( @@ -386,17 +377,17 @@ def __init__(self, authorization_url, token_url, **kwargs): code_grant_url, code_field_name, self.timeout, - success_display_time, - failure_display_time, - redirect_uri_port, + self.success_display_time, + self.failure_display_time, + self.redirect_uri_port, ) # As described in https://tools.ietf.org/html/rfc6749#section-4.1.3 self.token_data = { "grant_type": "authorization_code", - "redirect_uri": redirect_uri, + "redirect_uri": self.redirect_uri, } - self.token_data.update(extra_parameters) + self.token_data.update(kwargs) def __call__(self, r): token = OAuth2.token_cache.get_token(self.state, self.request_new_token) @@ -422,21 +413,10 @@ def request_new_token(self): # Handle both Access and Bearer tokens return (self.state, token, expires_in) if expires_in else (self.state, token) - def __add__(self, other): - if isinstance(other, Auths): - return Auths(self, *other.authentication_modes) - return Auths(self, other) - def __str__(self): - addition_args_str = ", ".join( - ["{0}='{1}'".format(key, value) for key, value in self.kwargs.items()] - ) - return "OAuth2AuthorizationCode('{0}', '{1}', {2})".format( - self.authorization_url, self.token_url, addition_args_str - ) - - -class OAuth2AuthorizationCodePKCE(requests.auth.AuthBase): +class OAuth2AuthorizationCodePKCE( + requests.auth.AuthBase, SupportMultiAuth, BrowserAuth +): """ Proof Key for Code Exchange @@ -448,7 +428,7 @@ class OAuth2AuthorizationCodePKCE(requests.auth.AuthBase): More details can be found in https://tools.ietf.org/html/rfc7636 """ - def __init__(self, authorization_url, token_url, **kwargs): + def __init__(self, authorization_url: str, token_url: str, **kwargs): """ :param authorization_url: OAuth 2 authorization URL. :param token_url: OAuth 2 token URL. @@ -487,53 +467,30 @@ def __init__(self, authorization_url, token_url, **kwargs): self.token_url = token_url if not self.token_url: raise Exception("Token URL 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}" - ) + BrowserAuth.__init__(self, kwargs) + + 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}.") - redirect_uri_endpoint = ( - extra_parameters.pop("redirect_uri_endpoint", None) or "" - ) - redirect_uri_port = int(extra_parameters.pop("redirect_uri_port", None) or 5000) - - self.token_field_name = ( - extra_parameters.pop("token_field_name", None) or "access_token" - ) - - # Time is expressed in seconds - self.timeout = int(extra_parameters.pop("timeout", None) or 60) - # Time is expressed in milliseconds - success_display_time = int( - extra_parameters.pop("success_display_time", None) or 1 - ) - # Time is expressed in milliseconds - failure_display_time = int( - extra_parameters.pop("failure_display_time", None) or 5000 - ) + self.token_field_name = kwargs.pop("token_field_name", None) or "access_token" # As described in https://tools.ietf.org/html/rfc6749#section-4.1.2 - code_field_name = extra_parameters.pop("code_field_name", "code") + code_field_name = kwargs.pop("code_field_name", "code") authorization_url_without_response_type, response_type = _pop_parameter( self.authorization_url, "response_type" ) if response_type: # Ensure provided value will not be overridden - extra_parameters["response_type"] = response_type + kwargs["response_type"] = response_type else: # As described in https://tools.ietf.org/html/rfc6749#section-4.1.1 - extra_parameters.setdefault("response_type", "code") + kwargs.setdefault("response_type", "code") - redirect_uri = extra_parameters.pop( - "redirect_uri", None - ) or "http://localhost:{0}/{1}".format(redirect_uri_port, redirect_uri_endpoint) authorization_url_without_nonce = _add_parameters( - authorization_url_without_response_type, extra_parameters + authorization_url_without_response_type, kwargs ) authorization_url_without_nonce, nonce = _pop_parameter( authorization_url_without_nonce, "nonce" @@ -541,7 +498,10 @@ def __init__(self, authorization_url, token_url, **kwargs): self.state = sha512( authorization_url_without_nonce.encode("unicode_escape") ).hexdigest() - custom_code_parameters = {"state": self.state, "redirect_uri": redirect_uri} + custom_code_parameters = { + "state": self.state, + "redirect_uri": self.redirect_uri, + } if nonce: custom_code_parameters["nonce"] = nonce @@ -560,9 +520,9 @@ def __init__(self, authorization_url, token_url, **kwargs): code_grant_url, code_field_name, self.timeout, - success_display_time, - failure_display_time, - redirect_uri_port, + self.success_display_time, + self.failure_display_time, + self.redirect_uri_port, ) # As described in https://tools.ietf.org/html/rfc6749#section-4.1.3 @@ -570,16 +530,16 @@ def __init__(self, authorization_url, token_url, **kwargs): self.token_data = { "code_verifier": code_verifier, "grant_type": "authorization_code", - "redirect_uri": redirect_uri, + "redirect_uri": self.redirect_uri, } - self.token_data.update(extra_parameters) + self.token_data.update(kwargs) def __call__(self, r): token = OAuth2.token_cache.get_token(self.state, self.request_new_token) r.headers[self.header_name] = self.header_value.format(token=token) return r - def request_new_token(self): + def request_new_token(self) -> tuple: # Request code state, code = oauth2_authentication_responses_server.request_new_grant( self.code_grant_details @@ -626,21 +586,8 @@ def generate_code_challenge(verifier: bytes) -> bytes: digest = sha256(verifier).digest() return base64.urlsafe_b64encode(digest).rstrip(b"=") - def __add__(self, other): - if isinstance(other, Auths): - return Auths(self, *other.authentication_modes) - return Auths(self, other) - def __str__(self): - addition_args_str = ", ".join( - ["{0}='{1}'".format(key, value) for key, value in self.kwargs.items()] - ) - return "OAuth2PKCE('{0}', '{1}', {2})".format( - self.authorization_url, self.token_url, addition_args_str - ) - - -class OAuth2Implicit(requests.auth.AuthBase): +class OAuth2Implicit(requests.auth.AuthBase, SupportMultiAuth, BrowserAuth): """ Implicit Grant @@ -652,7 +599,7 @@ class OAuth2Implicit(requests.auth.AuthBase): More details can be found in https://tools.ietf.org/html/rfc6749#section-4.2 """ - def __init__(self, authorization_url, **kwargs): + def __init__(self, authorization_url: str, **kwargs): """ :param authorization_url: OAuth 2 authorization URL. :param response_type: Value of the response_type query parameter if not already provided in authorization URL. @@ -686,52 +633,31 @@ def __init__(self, authorization_url, **kwargs): self.authorization_url = authorization_url if not self.authorization_url: raise Exception("Authorization URL 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}" - ) + BrowserAuth.__init__(self, kwargs) + + 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}.") - redirect_uri_endpoint = ( - extra_parameters.pop("redirect_uri_endpoint", None) or "" - ) - redirect_uri_port = int(extra_parameters.pop("redirect_uri_port", None) or 5000) - # Time is expressed in seconds - timeout = int(extra_parameters.pop("timeout", None) or 60) - # Time is expressed in milliseconds - success_display_time = int( - extra_parameters.pop("success_display_time", None) or 1 - ) - # Time is expressed in milliseconds - failure_display_time = int( - extra_parameters.pop("failure_display_time", None) or 5000 - ) - response_type = _get_query_parameter(self.authorization_url, "response_type") if response_type: - extra_parameters.pop( - "response_type", None - ) # Ensure provided value will not be overridden + # Ensure provided value will not be overridden + kwargs.pop("response_type", None) else: # As described in https://tools.ietf.org/html/rfc6749#section-4.2.1 - response_type = extra_parameters.setdefault("response_type", "token") + response_type = kwargs.setdefault("response_type", "token") # As described in https://tools.ietf.org/html/rfc6749#section-4.2.2 - token_field_name = extra_parameters.pop("token_field_name", None) + token_field_name = kwargs.pop("token_field_name", None) if not token_field_name: token_field_name = ( "id_token" if "id_token" == response_type else "access_token" ) - redirect_uri = "http://localhost:{0}/{1}".format( - redirect_uri_port, redirect_uri_endpoint - ) authorization_url_without_nonce = _add_parameters( - self.authorization_url, extra_parameters + self.authorization_url, kwargs ) authorization_url_without_nonce, nonce = _pop_parameter( authorization_url_without_nonce, "nonce" @@ -739,17 +665,17 @@ def __init__(self, authorization_url, **kwargs): self.state = sha512( authorization_url_without_nonce.encode("unicode_escape") ).hexdigest() - custom_parameters = {"state": self.state, "redirect_uri": redirect_uri} + custom_parameters = {"state": self.state, "redirect_uri": self.redirect_uri} if nonce: custom_parameters["nonce"] = nonce grant_url = _add_parameters(authorization_url_without_nonce, custom_parameters) self.grant_details = oauth2_authentication_responses_server.GrantDetails( grant_url, token_field_name, - timeout, - success_display_time, - failure_display_time, - redirect_uri_port, + self.timeout, + self.success_display_time, + self.failure_display_time, + self.redirect_uri_port, ) def __call__(self, r): @@ -761,19 +687,6 @@ def __call__(self, r): r.headers[self.header_name] = self.header_value.format(token=token) return r - def __add__(self, other): - if isinstance(other, Auths): - return Auths(self, *other.authentication_modes) - return Auths(self, other) - - def __str__(self): - addition_args_str = ", ".join( - ["{0}='{1}'".format(key, value) for key, value in self.kwargs.items()] - ) - return "OAuth2Implicit('{0}', {1})".format( - self.authorization_url, addition_args_str - ) - class AzureActiveDirectoryImplicit(OAuth2Implicit): """ @@ -781,7 +694,7 @@ class AzureActiveDirectoryImplicit(OAuth2Implicit): https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens """ - def __init__(self, tenant_id, client_id, **kwargs): + def __init__(self, tenant_id: str, client_id: str, **kwargs): """ :param tenant_id: Microsoft Tenant Identifier (formatted as an Universal Unique Identifier) :param client_id: Microsoft Application Identifier (formatted as an Universal Unique Identifier) @@ -815,10 +728,10 @@ def __init__(self, tenant_id, client_id, **kwargs): """ OAuth2Implicit.__init__( self, - "https://login.microsoftonline.com/{0}/oauth2/authorize".format(tenant_id), + f"https://login.microsoftonline.com/{tenant_id}/oauth2/authorize", client_id=client_id, nonce=kwargs.pop("nonce", None) or str(uuid.uuid4()), - **kwargs + **kwargs, ) @@ -828,7 +741,7 @@ class AzureActiveDirectoryImplicitIdToken(OAuth2Implicit): https://docs.microsoft.com/en-us/azure/active-directory/develop/id-tokens """ - def __init__(self, tenant_id, client_id, **kwargs): + def __init__(self, tenant_id: str, client_id: str, **kwargs): """ :param tenant_id: Microsoft Tenant Identifier (formatted as an Universal Unique Identifier) :param client_id: Microsoft Application Identifier (formatted as an Universal Unique Identifier) @@ -862,12 +775,12 @@ def __init__(self, tenant_id, client_id, **kwargs): """ OAuth2Implicit.__init__( self, - "https://login.microsoftonline.com/{0}/oauth2/authorize".format(tenant_id), + f"https://login.microsoftonline.com/{tenant_id}/oauth2/authorize", client_id=client_id, response_type=kwargs.pop("response_type", "id_token"), token_field_name=kwargs.pop("token_field_name", "id_token"), nonce=kwargs.pop("nonce", None) or str(uuid.uuid4()), - **kwargs + **kwargs, ) @@ -878,7 +791,7 @@ class OktaImplicit(OAuth2Implicit): https://developer.okta.com/docs/guides/implement-implicit/overview/ """ - def __init__(self, instance, client_id, **kwargs): + def __init__(self, instance: str, client_id: str, **kwargs): """ :param instance: OKTA instance (like "testserver.okta-emea.com") :param client_id: OKTA Application Identifier (formatted as an Universal Unique Identifier) @@ -919,12 +832,10 @@ def __init__(self, instance, client_id, **kwargs): kwargs["scope"] = " ".join(scopes) if isinstance(scopes, list) else scopes OAuth2Implicit.__init__( self, - "https://{okta_instance}/oauth2/{okta_auth_server}/v1/authorize".format( - okta_instance=instance, okta_auth_server=authorization_server - ), + f"https://{instance}/oauth2/{authorization_server}/v1/authorize", client_id=client_id, nonce=kwargs.pop("nonce", None) or str(uuid.uuid4()), - **kwargs + **kwargs, ) @@ -933,7 +844,7 @@ class OktaImplicitIdToken(OAuth2Implicit): Describes an OKTA (OpenID Connect) "ID Token" implicit flow requests authentication. """ - def __init__(self, instance, client_id, **kwargs): + def __init__(self, instance: str, client_id: str, **kwargs): """ :param instance: OKTA instance (like "testserver.okta-emea.com") :param client_id: OKTA Application Identifier (formatted as an Universal Unique Identifier) @@ -974,14 +885,12 @@ def __init__(self, instance, client_id, **kwargs): kwargs["scope"] = " ".join(scopes) if isinstance(scopes, list) else scopes OAuth2Implicit.__init__( self, - "https://{okta_instance}/oauth2/{okta_auth_server}/v1/authorize".format( - okta_instance=instance, okta_auth_server=authorization_server - ), + f"https://{instance}/oauth2/{authorization_server}/v1/authorize", client_id=client_id, response_type=kwargs.pop("response_type", "id_token"), token_field_name=kwargs.pop("token_field_name", "id_token"), nonce=kwargs.pop("nonce", None) or str(uuid.uuid4()), - **kwargs + **kwargs, ) @@ -990,7 +899,7 @@ class OktaAuthorizationCode(OAuth2AuthorizationCode): Describes an OKTA (OAuth 2) "Access Token" authorization code flow requests authentication. """ - def __init__(self, instance, client_id, **kwargs): + def __init__(self, instance: str, client_id: str, **kwargs): """ :param instance: OKTA instance (like "testserver.okta-emea.com") :param client_id: OKTA Application Identifier (formatted as an Universal Unique Identifier) @@ -1031,14 +940,10 @@ def __init__(self, instance, client_id, **kwargs): kwargs["scope"] = " ".join(scopes) if isinstance(scopes, list) else scopes OAuth2AuthorizationCode.__init__( self, - "https://{okta_instance}/oauth2/{okta_auth_server}/v1/authorize".format( - okta_instance=instance, okta_auth_server=authorization_server - ), - "https://{okta_instance}/oauth2/{okta_auth_server}/v1/token".format( - okta_instance=instance, okta_auth_server=authorization_server - ), + f"https://{instance}/oauth2/{authorization_server}/v1/authorize", + f"https://{instance}/oauth2/{authorization_server}/v1/token", client_id=client_id, - **kwargs + **kwargs, ) @@ -1047,7 +952,7 @@ class OktaAuthorizationCodePKCE(OAuth2AuthorizationCodePKCE): Describes an OKTA (OAuth 2) "Access Token" Proof Key for Code Exchange (PKCE) flow requests authentication. """ - def __init__(self, instance, client_id, **kwargs): + def __init__(self, instance: str, client_id: str, **kwargs): """ :param instance: OKTA instance (like "testserver.okta-emea.com") :param client_id: OKTA Application Identifier (formatted as an Universal Unique Identifier) @@ -1090,14 +995,10 @@ def __init__(self, instance, client_id, **kwargs): kwargs["scope"] = " ".join(scopes) if isinstance(scopes, list) else scopes OAuth2AuthorizationCodePKCE.__init__( self, - "https://{okta_instance}/oauth2/{okta_auth_server}/v1/authorize".format( - okta_instance=instance, okta_auth_server=authorization_server - ), - "https://{okta_instance}/oauth2/{okta_auth_server}/v1/token".format( - okta_instance=instance, okta_auth_server=authorization_server - ), + f"https://{instance}/oauth2/{authorization_server}/v1/authorize", + f"https://{instance}/oauth2/{authorization_server}/v1/token", client_id=client_id, - **kwargs + **kwargs, ) @@ -1106,7 +1007,7 @@ class OktaClientCredentials(OAuth2ClientCredentials): Describes an OKTA (OAuth 2) client credentials (also called application) flow requests authentication. """ - def __init__(self, instance, client_id, client_secret, **kwargs): + def __init__(self, instance: str, client_id: str, client_secret: str, **kwargs): """ :param instance: OKTA instance (like "testserver.okta-emea.com") :param client_id: OKTA Application Identifier (formatted as an Universal Unique Identifier) @@ -1130,19 +1031,17 @@ def __init__(self, instance, client_id, client_secret, **kwargs): kwargs["scope"] = " ".join(scopes) if isinstance(scopes, list) else scopes OAuth2ClientCredentials.__init__( self, - "https://{okta_instance}/oauth2/{okta_auth_server}/v1/token".format( - okta_instance=instance, okta_auth_server=authorization_server - ), - username=client_id, - password=client_secret, - **kwargs + f"https://{instance}/oauth2/{authorization_server}/v1/token", + client_id=client_id, + client_secret=client_secret, + **kwargs, ) -class HeaderApiKey(requests.auth.AuthBase): +class HeaderApiKey(requests.auth.AuthBase, SupportMultiAuth): """Describes an API Key requests authentication.""" - def __init__(self, api_key, header_name=None): + def __init__(self, api_key: str, header_name: str = None): """ :param api_key: The API key that will be sent. :param header_name: Name of the header field. "X-API-Key" by default. @@ -1156,19 +1055,11 @@ def __call__(self, r): r.headers[self.header_name] = self.api_key return r - def __add__(self, other): - if isinstance(other, Auths): - return Auths(self, *other.authentication_modes) - return Auths(self, other) - - def __str__(self): - return "HeaderApiKey('{0}', '{1}')".format(self.api_key, self.header_name) - -class QueryApiKey(requests.auth.AuthBase): +class QueryApiKey(requests.auth.AuthBase, SupportMultiAuth): """Describes an API Key requests authentication.""" - def __init__(self, api_key, query_parameter_name=None): + def __init__(self, api_key: str, query_parameter_name: str = None): """ :param api_key: The API key that will be sent. :param query_parameter_name: Name of the query parameter. "api_key" by default. @@ -1182,36 +1073,18 @@ def __call__(self, r): r.url = _add_parameters(r.url, {self.query_parameter_name: self.api_key}) return r - def __add__(self, other): - if isinstance(other, Auths): - return Auths(self, *other.authentication_modes) - return Auths(self, other) - - def __str__(self): - return "QueryApiKey('{0}', '{1}')".format( - self.api_key, self.query_parameter_name - ) - -class Basic(requests.auth.HTTPBasicAuth): +class Basic(requests.auth.HTTPBasicAuth, SupportMultiAuth): """Describes a basic requests authentication.""" - def __init__(self, username, password): + def __init__(self, username: str, password: str): requests.auth.HTTPBasicAuth.__init__(self, username, password) - def __add__(self, other): - if isinstance(other, Auths): - return Auths(self, *other.authentication_modes) - return Auths(self, other) - - def __str__(self): - return "Basic('{0}', '{1}')".format(self.username, self.password) - -class NTLM: +class NTLM(requests.auth.AuthBase, SupportMultiAuth): """Describes a NTLM requests authentication.""" - def __init__(self, username=None, password=None): + def __init__(self, username: str = None, password: str = None): """ :param username: Mandatory if requests_negotiate_sspi module is not installed. :param password: Mandatory if requests_negotiate_sspi module is not installed. @@ -1247,16 +1120,6 @@ def __call__(self, r): self.auth.__call__(r) return r - def __add__(self, other): - if isinstance(other, Auths): - return Auths(self, *other.authentication_modes) - return Auths(self, other) - - def __str__(self): - if self.username and self.password: - return "NTLM('{0}', '{1}')".format(self.username, self.password) - return "NTLM()" - class Auths(requests.auth.AuthBase): """Authentication using multiple authentication methods.""" @@ -1277,6 +1140,3 @@ def __add__(self, other): if isinstance(other, Auths): return Auths(*self.authentication_modes, *other.authentication_modes) return Auths(*self.authentication_modes, other) - - def __str__(self): - return "Auths(" + ", ".join(map(str, self.authentication_modes)) + ")" diff --git a/requests_auth/errors.py b/requests_auth/errors.py index fc88dd9..c2e30ce 100644 --- a/requests_auth/errors.py +++ b/requests_auth/errors.py @@ -1,3 +1,9 @@ +from json import JSONDecodeError +from typing import Union + +from requests import Response + + class AuthenticationFailed(Exception): """ User was not authenticated. """ @@ -8,43 +14,117 @@ def __init__(self): class TimeoutOccurred(Exception): """ No response within timeout interval. """ - def __init__(self, timeout): + def __init__(self, timeout: float): Exception.__init__( - self, - "User authentication was not received within {0} seconds.".format(timeout), + self, f"User authentication was not received within {timeout} seconds." ) class InvalidToken(Exception): """ Token is invalid. """ - def __init__(self, token_name): - Exception.__init__(self, "{0} is invalid.".format(token_name)) + def __init__(self, token_name: str): + Exception.__init__(self, f"{token_name} is invalid.") class GrantNotProvided(Exception): """ Grant was not provided. """ - def __init__(self, grant_name, dictionary_without_grant): + def __init__(self, grant_name: str, dictionary_without_grant: dict): Exception.__init__( - self, - "{0} not provided within {1}.".format(grant_name, dictionary_without_grant), + self, f"{grant_name} not provided within {dictionary_without_grant}." ) +class InvalidGrantRequest(Exception): + """ + If the request failed client authentication or is invalid, the authorization server returns an error response as described in https://tools.ietf.org/html/rfc6749#section-5.2 + """ + + # https://tools.ietf.org/html/rfc6749#section-5.2 + request_errors = { + "invalid_request": "The request is missing a required parameter, includes an unsupported parameter value (other than grant type), repeats a parameter, includes multiple credentials, utilizes more than one mechanism for authenticating the client, or is otherwise malformed.", + "invalid_client": 'Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method). The authorization server MAY return an HTTP 401 (Unauthorized) status code to indicate which HTTP authentication schemes are supported. If the client attempted to authenticate via the "Authorization" request header field, the authorization server MUST respond with an HTTP 401 (Unauthorized) status code and include the "WWW-Authenticate" response header field matching the authentication scheme used by the client.', + "invalid_grant": "The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.", + "unauthorized_client": "The authenticated client is not authorized to use this authorization grant type.", + "unsupported_grant_type": "The authorization grant type is not supported by the authorization server.", + "invalid_scope": "The requested scope is invalid, unknown, malformed, or exceeds the scope granted by the resource owner.", + } + + # https://tools.ietf.org/html/rfc6749#section-4.2.2.1 + # https://tools.ietf.org/html/rfc6749#section-4.1.2.1 + browser_errors = { + "invalid_request": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed.", + "unauthorized_client": "The client is not authorized to request an authorization code or an access token using this method.", + "access_denied": "The resource owner or authorization server denied the request.", + "unsupported_response_type": "The authorization server does not support obtaining an authorization code or an access token using this method.", + "invalid_scope": "The requested scope is invalid, unknown, or malformed.", + "server_error": "The authorization server encountered an unexpected condition that prevented it from fulfilling the request. (This error code is needed because a 500 Internal Server Error HTTP status code cannot be returned to the client via an HTTP redirect.)", + "temporarily_unavailable": "The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server. (This error code is needed because a 503 Service Unavailable HTTP status code cannot be returned to the client via an HTTP redirect.)", + } + + def __init__(self, response: Union[Response, dict]): + Exception.__init__(self, InvalidGrantRequest.to_message(response)) + + @staticmethod + def to_message(response: Union[Response, dict]) -> str: + """ + Handle response as described in: + * https://tools.ietf.org/html/rfc6749#section-5.2 + * https://tools.ietf.org/html/rfc6749#section-4.1.2.1 + * https://tools.ietf.org/html/rfc6749#section-4.2.2.1 + """ + if isinstance(response, dict): + return InvalidGrantRequest.to_oauth2_message( + response, InvalidGrantRequest.browser_errors + ) + + try: + return InvalidGrantRequest.to_oauth2_message( + response.json(), InvalidGrantRequest.request_errors + ) + except JSONDecodeError: + return response.text + + @staticmethod + def to_oauth2_message(content: dict, errors: dict) -> str: + """ + Handle content as described in: + * https://tools.ietf.org/html/rfc6749#section-5.2 + * https://tools.ietf.org/html/rfc6749#section-4.1.2.1 + * https://tools.ietf.org/html/rfc6749#section-4.2.2.1 + """ + + def _pop(key: str) -> str: + value = content.pop(key, None) + if value and isinstance(value, list): + value = value[0] + return value + + if "error" in content: + error = _pop("error") + error_description = _pop("error_description") or errors.get(error) + message = f"{error}: {error_description}" + if "error_uri" in content: + message += f"\nMore information can be found on {_pop('error_uri')}" + if content: + message += f"\nAdditional information: {content}" + else: + message = f"{content}" + return message + + class StateNotProvided(Exception): """ State was not provided. """ - def __init__(self, dictionary_without_state): + def __init__(self, dictionary_without_state: dict): Exception.__init__( - self, "state not provided within {0}.".format(dictionary_without_state) + self, f"state not provided within {dictionary_without_state}." ) class TokenExpiryNotProvided(Exception): """ Token expiry was not provided. """ - def __init__(self, token_body): - Exception.__init__( - self, "Expiry (exp) is not provided in {0}.".format(token_body) - ) + def __init__(self, token_body: dict): + Exception.__init__(self, f"Expiry (exp) is not provided in {token_body}.") diff --git a/requests_auth/oauth2_authentication_responses_server.py b/requests_auth/oauth2_authentication_responses_server.py index 9da8122..86db57a 100644 --- a/requests_auth/oauth2_authentication_responses_server.py +++ b/requests_auth/oauth2_authentication_responses_server.py @@ -2,13 +2,8 @@ import logging import requests from http.server import HTTPServer, BaseHTTPRequestHandler - -try: - # Python 3 - from urllib.parse import parse_qs, urlparse -except ImportError: - # Python 2 - from urlparse import parse_qs, urlparse +from urllib.parse import parse_qs, urlparse +from socket import socket from requests_auth.errors import * @@ -24,7 +19,7 @@ def do_GET(self): ) return self.send_html("Favicon is not provided.") - logger.debug("GET received on {0}".format(self.path)) + logger.debug(f"GET received on {self.path}") try: args = self._get_params() if self.server.grant_details.name in args or args.pop( @@ -38,13 +33,11 @@ def do_GET(self): self.server.request_error = e logger.exception("Unable to properly perform authentication.") self.send_html( - self.error_page( - "Unable to properly perform authentication: {0}".format(e) - ) + self.error_page(f"Unable to properly perform authentication: {e}") ) def do_POST(self): - logger.debug("POST received on {0}".format(self.path)) + logger.debug(f"POST received on {self.path}") try: form_dict = self._get_form() self._parse_grant(form_dict) @@ -52,29 +45,27 @@ def do_POST(self): self.server.request_error = e logger.exception("Unable to properly perform authentication.") self.send_html( - self.error_page( - "Unable to properly perform authentication: {0}".format(e) - ) + self.error_page(f"Unable to properly perform authentication: {e}") ) - def _parse_grant(self, arguments): + def _parse_grant(self, arguments: dict): grants = arguments.get(self.server.grant_details.name) if not grants or len(grants) > 1: + if "error" in arguments: + raise InvalidGrantRequest(arguments) raise GrantNotProvided(self.server.grant_details.name, arguments) - logger.debug("Received grants: {0}".format(grants)) + logger.debug(f"Received grants: {grants}") grant = grants[0] states = arguments.get("state") if not states or len(states) > 1: raise StateNotProvided(arguments) - logger.debug("Received states: {0}".format(states)) + logger.debug(f"Received states: {states}") state = states[0] self.server.grant = state, grant self.send_html( self.success_page( - "You are now authenticated on {0}. You may close this tab.".format( - state - ) + f"You are now authenticated on {state}. You may close this tab." ) ) @@ -86,38 +77,34 @@ def _get_form(self): def _get_params(self): return parse_qs(urlparse(self.path).query) - def send_html(self, html_content): + def send_html(self, html_content: str): self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() self.wfile.write(str.encode(html_content)) logger.debug("HTML content sent to client.") - def success_page(self, text): - return """<body onload="window.open('', '_self', ''); window.setTimeout(close, {0})" style=" + def success_page(self, text: str): + return f"""<body onload="window.open('', '_self', ''); window.setTimeout(close, {self.server.grant_details.reception_success_display_time})" style=" color: #4F8A10; background-color: #DFF2BF; font-size: xx-large; display: flex; align-items: center; justify-content: center;"> - <div style="border: 1px solid;">{1}</div> - </body>""".format( - self.server.grant_details.reception_success_display_time, text - ) + <div style="border: 1px solid;">{text}</div> + </body>""" - def error_page(self, text): - return """<body onload="window.open('', '_self', ''); window.setTimeout(close, {0})" style=" + def error_page(self, text: str): + return f"""<body onload="window.open('', '_self', ''); window.setTimeout(close, {self.server.grant_details.reception_failure_display_time})" style=" color: #D8000C; background-color: #FFBABA; font-size: xx-large; display: flex; align-items: center; justify-content: center;"> - <div style="border: 1px solid;">{1}</div> - </body>""".format( - self.server.grant_details.reception_failure_display_time, text - ) + <div style="border: 1px solid;">{text}</div> + </body>""" def fragment_redirect_page(self): """Return a page with JS that calls back the server on the url @@ -138,99 +125,72 @@ def fragment_redirect_page(self): window.location.replace(new_url) </script></body></html>""" - def log_message(self, format, *args): + def log_message(self, format: str, *args): """Make sure that messages are logged even with pythonw (seems like a bug in BaseHTTPRequestHandler).""" - logger.info(format, *args) + logger.debug(format, *args) -class FixedHttpServer(HTTPServer): - def __init__(self, grant_details): - """ +class GrantDetails: + def __init__( + self, + url: str, + name: str, + reception_timeout: float, + reception_success_display_time: int, + reception_failure_display_time: int, + redirect_uri_port: int, + ): + self.url = url + self.name = name + self.reception_timeout = reception_timeout + self.reception_success_display_time = reception_success_display_time + self.reception_failure_display_time = reception_failure_display_time + self.redirect_uri_port = redirect_uri_port - :param grant_details: Must be a class providing the following attributes: - * name - * reception_success_display_time - * reception_failure_display_time - * redirect_uri_port - * reception_timeout - """ + +class FixedHttpServer(HTTPServer): + def __init__(self, grant_details: GrantDetails): HTTPServer.__init__( self, ("", grant_details.redirect_uri_port), OAuth2ResponseHandler ) self.timeout = grant_details.reception_timeout - logger.debug("Timeout is set to {0} seconds.".format(self.timeout)) + logger.debug(f"Timeout is set to {self.timeout} seconds.") self.grant_details = grant_details self.request_error = None self.grant = False - def finish_request(self, request, client_address): + def finish_request(self, request: socket, client_address): """Make sure that timeout is used by the request (seems like a bug in HTTPServer).""" request.settimeout(self.timeout) HTTPServer.finish_request(self, request, client_address) def ensure_no_error_occurred(self): - if ( - self.request_error - ): # Raise error encountered while processing a request if any + if self.request_error: + # Raise error encountered while processing a request if any raise self.request_error return not self.grant def handle_timeout(self): raise TimeoutOccurred(self.timeout) - def __enter__(self): - """ - Support for context manager use with Python < 3.6 - TODO Remove once dropping support for those python version - """ - return self - - def __exit__(self, *args): - """ - Support for context manager use with Python < 3.6 - TODO Remove once dropping support for those python version - """ - self.server_close() - -class GrantDetails: - def __init__( - self, - url, - name, - reception_timeout, - reception_success_display_time, - reception_failure_display_time, - redirect_uri_port, - ): - self.url = url - self.name = name - self.reception_timeout = reception_timeout - self.reception_success_display_time = reception_success_display_time - self.reception_failure_display_time = reception_failure_display_time - self.redirect_uri_port = redirect_uri_port - - -def request_new_grant(grant_details): +def request_new_grant(grant_details: GrantDetails) -> (str, str): """ Ask for a new OAuth2 grant. - :param grant_details: Must be a class providing the following attributes: - * url - * name - * reception_timeout - * reception_success_display_time - * reception_failure_display_time - * redirect_uri_port - :return:A tuple (state, grant) or an Exception if not retrieved within timeout. + :return: A tuple (state, grant) + :raises InvalidGrantRequest: If the request was invalid. + :raises TimeoutOccurred: If not retrieved within timeout. + :raises GrantNotProvided: If grant is not provided in response (but no error occurred). + :raises StateNotProvided: If state if not provided in addition to the grant. """ - logger.debug("Requesting new {0}...".format(grant_details.name)) + logger.debug(f"Requesting new {grant_details.name}...") with FixedHttpServer(grant_details) as server: _open_url(grant_details.url) return _wait_for_grant(server) -def _open_url(url): +def _open_url(url: str): # Default to Microsoft Internet Explorer to be able to open a new window # otherwise this parameter is not taken into account by most browsers # Opening a new window allows to focus back once authenticated (JavaScript is closing the only tab) @@ -240,7 +200,7 @@ def _open_url(url): if hasattr(webbrowser, "iexplore") else webbrowser.get() ) - logger.info("Opening browser on {0}".format(url)) + logger.debug(f"Opening browser on {url}") if not browser.open(url, new=1): logger.warning("Unable to open URL, try with a GET request.") requests.get(url) @@ -249,7 +209,14 @@ def _open_url(url): requests.get(url) -def _wait_for_grant(server): +def _wait_for_grant(server: FixedHttpServer) -> (str, str): + """ + :return: A tuple (state, grant) + :raises InvalidGrantRequest: If the request was invalid. + :raises TimeoutOccurred: If not retrieved within timeout. + :raises GrantNotProvided: If grant is not provided in response (but no error occurred). + :raises StateNotProvided: If state if not provided in addition to the grant. + """ logger.debug("Waiting for user authentication...") while not server.grant: server.handle_request() diff --git a/requests_auth/oauth2_tokens.py b/requests_auth/oauth2_tokens.py index 8b6baf5..fc14c16 100644 --- a/requests_auth/oauth2_tokens.py +++ b/requests_auth/oauth2_tokens.py @@ -9,7 +9,7 @@ logger = logging.getLogger(__name__) -def decode_base64(base64_encoded_string): +def decode_base64(base64_encoded_string: str) -> str: """ Decode base64, padding being optional. @@ -22,7 +22,7 @@ def decode_base64(base64_encoded_string): return base64.b64decode(base64_encoded_string).decode("unicode_escape") -def is_expired(expiry): +def is_expired(expiry: float) -> bool: return datetime.datetime.utcfromtimestamp(expiry) < datetime.datetime.utcnow() @@ -36,7 +36,7 @@ def __init__(self): self.forbid_concurrent_cache_access = threading.Lock() self.forbid_concurrent_missing_token_function_call = threading.Lock() - def add_bearer_token(self, key, token): + def add_bearer_token(self, key: str, token: str): """ Set the bearer token and save it :param key: key identifier of the token @@ -55,7 +55,7 @@ def add_bearer_token(self, key, token): self._add_token(key, token, expiry) - def add_access_token(self, key, token, expires_in): + def add_access_token(self, key: str, token: str, expires_in: int): """ Set the bearer token and save it :param key: key identifier of the token @@ -68,7 +68,7 @@ def add_access_token(self, key, token, expires_in): ) + datetime.timedelta(seconds=expires_in) self._add_token(key, token, expiry.timestamp()) - def _add_token(self, key, token, expiry): + def _add_token(self, key: str, token: str, expiry: float): """ Set the bearer token and save it :param key: key identifier of the token @@ -79,12 +79,10 @@ def _add_token(self, key, token, expiry): self.tokens[key] = token, expiry self._save_tokens() logger.debug( - 'Inserting token expiring on {0} (UTC) with "{1}" key: {2}'.format( - datetime.datetime.utcfromtimestamp(expiry), key, token - ) + f'Inserting token expiring on {datetime.datetime.utcfromtimestamp(expiry)} (UTC) with "{key}" key: {token}' ) - def get_token(self, key, on_missing_token=None, *on_missing_token_args): + def get_token(self, key: str, on_missing_token=None, *on_missing_token_args) -> str: """ Return the bearer token. :param key: key identifier of the token @@ -93,21 +91,17 @@ def get_token(self, key, on_missing_token=None, *on_missing_token_args): :return: the token :raise AuthenticationFailed: in case token cannot be retrieved. """ - logger.debug('Retrieving token with "{0}" key.'.format(key)) + logger.debug(f'Retrieving token with "{key}" key.') with self.forbid_concurrent_cache_access: self._load_tokens() if key in self.tokens: bearer, expiry = self.tokens[key] if is_expired(expiry): - logger.debug( - 'Authentication token with "{0}" key is expired.'.format(key) - ) + logger.debug(f'Authentication token with "{key}" key is expired.') del self.tokens[key] else: logger.debug( - "Using already received authentication, will expire on {0} (UTC).".format( - datetime.datetime.utcfromtimestamp(expiry) - ) + f"Using already received authentication, will expire on {datetime.datetime.utcfromtimestamp(expiry)} (UTC)." ) return bearer @@ -123,24 +117,18 @@ def get_token(self, key, on_missing_token=None, *on_missing_token_args): self.add_access_token(state, token, expires_in) if key != state: logger.warning( - "Using a token received on another key than expected. Expecting {0} but was {1}.".format( - key, state - ) + f"Using a token received on another key than expected. Expecting {key} but was {state}." ) with self.forbid_concurrent_cache_access: if state in self.tokens: bearer, expiry = self.tokens[state] logger.debug( - "Using newly received authentication, expiring on {0} (UTC).".format( - datetime.datetime.utcfromtimestamp(expiry) - ) + f"Using newly received authentication, expiring on {datetime.datetime.utcfromtimestamp(expiry)} (UTC)." ) return bearer logger.debug( - "User was not authenticated: key {0} cannot be found in {1}.".format( - key, self.tokens - ) + f"User was not authenticated: key {key} cannot be found in {self.tokens}." ) raise AuthenticationFailed() @@ -165,7 +153,7 @@ class JsonTokenFileCache(TokenMemoryCache): Class to manage tokens using a cache file. """ - def __init__(self, tokens_path): + def __init__(self, tokens_path: str): TokenMemoryCache.__init__(self) self.tokens_path = tokens_path self.last_save_time = 0 diff --git a/requests_auth/version.py b/requests_auth/version.py index 6d2ed03..ffe1d01 100644 --- a/requests_auth/version.py +++ b/requests_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__ = "4.1.0" +__version__ = "5.0.0" diff --git a/setup.py b/setup.py index b7440d7..f1d298b 100644 --- a/setup.py +++ b/setup.py @@ -2,68 +2,62 @@ from setuptools import setup, find_packages this_dir = os.path.abspath(os.path.dirname(__file__)) -with open(os.path.join(this_dir, 'README.md'), 'r') as f: +with open(os.path.join(this_dir, "README.md"), "r") as f: long_description = f.read() # More information on properties: https://packaging.python.org/distributing setup( - name='requests_auth', + name="requests_auth", version=open("requests_auth/version.py").readlines()[-1].split()[-1].strip("\"'"), - author='Colin Bounouar', - author_email='colin.bounouar.dev@gmail.com', - maintainer='Colin Bounouar', - maintainer_email='colin.bounouar.dev@gmail.com', + author="Colin Bounouar", + author_email="colin.bounouar.dev@gmail.com", + maintainer="Colin Bounouar", + maintainer_email="colin.bounouar.dev@gmail.com", url="https://github.com/Colin-b/requests_auth", - description="Easy Authentication for Requests", + description="Authentication for Requests", long_description=long_description, - long_description_content_type='text/markdown', - download_url='https://pypi.org/project/requests-auth/', - license='MIT', + long_description_content_type="text/markdown", + download_url="https://pypi.org/project/requests-auth/", + license="MIT", classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Programming Language :: Python", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Topic :: Software Development :: Build Tools", ], keywords=[ - 'authentication', - 'ntlm', - 'oauth2', - 'azure-active-directory', - 'azure-ad', - 'okta', - 'apikey', - 'multiple', - ], - packages=find_packages(exclude=['tests']), - tests_require=[ - # Used to generate a JWT token - 'pyjwt==1.7.1', - # Used to run test services - 'flask==1.1.1', - # Used to mock responses to requests - 'pytest-responses==0.4.0', + "authentication", + "ntlm", + "oauth2", + "azure-active-directory", + "azure-ad", + "okta", + "apikey", + "multiple", ], + packages=find_packages(exclude=["tests"]), install_requires=[ - # Used for Base Authentication and to communicate with OAuth2 servers (also used in test cases) - 'requests==2.*', + # Used for Base Authentication and to communicate with OAuth2 servers + "requests==2.*" ], - python_requires=">=2.7", + extras_require={ + "testing": [ + # Used to generate test tokens + "pyjwt==1.*", + # Used to mock responses to requests + "pytest-responses==0.4.*", + ] + }, + python_requires=">=3.6", project_urls={ "Changelog": "https://github.com/Colin-b/requests_auth/blob/master/CHANGELOG.md", "Issues": "https://github.com/Colin-b/requests_auth/issues", }, - platforms=[ - 'Windows', - 'Linux', - ], + platforms=["Windows", "Linux"], ) diff --git a/tests/auth_helper.py b/tests/auth_helper.py index 432329d..6535970 100644 --- a/tests/auth_helper.py +++ b/tests/auth_helper.py @@ -1,8 +1,9 @@ import requests +import requests.auth from responses import RequestsMock, Response -def get_header(responses: RequestsMock, auth): +def get_header(responses: RequestsMock, auth: requests.auth.AuthBase) -> dict: # Mock a dummy response responses.add(responses.GET, "http://authorized_only") # Send a request to this dummy URL with authentication @@ -11,7 +12,7 @@ def get_header(responses: RequestsMock, auth): return response.request.headers -def get_query_args(responses: RequestsMock, auth): +def get_query_args(responses: RequestsMock, auth: requests.auth.AuthBase) -> str: # Mock a dummy response responses.add(responses.GET, "http://authorized_only") # Send a request to this dummy URL with authentication diff --git a/tests/authenticated_test_service.py b/tests/authenticated_test_service.py deleted file mode 100644 index aec9ee8..0000000 --- a/tests/authenticated_test_service.py +++ /dev/null @@ -1,231 +0,0 @@ -import datetime -import logging -import sys - -from flask import Flask, jsonify, request, redirect -import jwt - -logging.basicConfig( - handlers=[logging.StreamHandler(sys.stdout)], - level=logging.DEBUG, - format="%(asctime)s [%(threadName)s] [%(levelname)s] %(message)s", -) - -app = Flask(__name__) - -logger = logging.getLogger(__name__) - - -already_asked_for_quick_expiry = [False] - - -@app.route("/status") -def get_status(): - return "OK" - - -@app.route("/provide_token_as_custom_token") -def post_token_as_my_custom_token(): - response_type = request.args.get("response_type") - if "custom_token" != response_type: - raise Exception( - "custom_token was expected to be received as response_type. Got {0} instead.".format( - response_type - ) - ) - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) - return submit_a_form_with_a_token(create_token(expiry_in_1_hour), "custom_token") - - -@app.route("/provide_token_as_access_token") -def post_token_as_access_token(): - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) - return submit_a_form_with_a_token(create_token(expiry_in_1_hour), "access_token") - - -@app.route("/provide_token_as_access_token_with_another_state") -def post_token_as_access_token_with_another_state(): - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) - return submit_a_form_with_a_token( - create_token(expiry_in_1_hour), "access_token", state="123456" - ) - - -@app.route("/provide_empty_token_as_access_token") -def post_empty_token_as_access_token(): - return submit_a_form_with_a_token("", "access_token") - - -@app.route("/provide_token_without_exp_as_access_token") -def post_token_without_exp_as_access_token(): - return submit_a_form_with_a_token(create_token(None), "access_token") - - -@app.route("/provide_token_as_id_token") -def post_token_as_id_token(): - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) - return submit_a_form_with_a_token(create_token(expiry_in_1_hour), "id_token") - - -@app.route("/provide_token_as_anchor_access_token") -def get_token_as_anchor_token(): - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) - return redirect_with_a_token(expiry_in_1_hour, "access_token") - - -@app.route("/provide_code_as_anchor_code") -def get_code_as_anchor_code(): - return redirect_with_a_code("code", "SplxlOBeZQQYbYS6WxSbIA") - - -@app.route("/provide_token_as_access_token_but_without_providing_state") -def post_without_state(): - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) - return submit_a_form_without_state(expiry_in_1_hour, "access_token") - - -@app.route("/provide_token_as_anchor_access_token_but_without_providing_state") -def get_token_as_anchor_token_without_state(): - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) - return redirect_with_a_token_without_state(expiry_in_1_hour, "access_token") - - -@app.route("/do_not_provide_token") -def post_without_token(): - return submit_an_empty_form() - - -@app.route("/do_not_provide_token_as_anchor_token") -def get_without_token(): - return redirect_without_a_token() - - -@app.route("/provide_a_token_expiring_in_1_second") -def post_token_quick_expiry(): - if already_asked_for_quick_expiry[0]: - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) - return submit_a_form_with_a_token( - create_token(expiry_in_1_hour), "access_token" - ) - else: - already_asked_for_quick_expiry[0] = True - expiry_in_1_second = datetime.datetime.utcnow() + datetime.timedelta(seconds=1) - return submit_a_form_with_a_token( - create_token(expiry_in_1_second), "access_token" - ) - - -@app.route("/do_not_redirect") -def close_page_so_that_client_timeout_waiting_for_token(): - return close_page() - - -def submit_a_form_with_a_token(token, token_field_name, state=None): - redirect_uri = request.args.get("redirect_uri") - state = state or request.args.get("state") - return """ -<html> - <body> - <form method="POST" name="hiddenform" action="{0}"> - <input type="hidden" name="{1}" value="{2}" /> - <input type="hidden" name="state" value="{3}" /> - <noscript> - <p>Script is disabled. Click Submit to continue.</p> - <input type="submit" value="Submit" /> - </noscript> - </form> - <script language="javascript">document.forms[0].submit();</script> - </body> -</html> - """.format( - redirect_uri, token_field_name, token, state - ) - - -def redirect_with_a_token(token_expiry, response_type): - redirect_uri = request.args.get("redirect_uri") - state = request.args.get("state") - token = create_token(token_expiry) - return redirect( - "{0}#{1}={2}&state={3}".format(redirect_uri, response_type, token, state) - ) - - -def redirect_with_a_code(code_field_name, code_value): - redirect_uri = request.args.get("redirect_uri") - state = request.args.get("state") - return redirect( - "{0}#{1}={2}&state={3}".format(redirect_uri, code_field_name, code_value, state) - ) - - -def submit_a_form_without_state(token_expiry, response_type): - redirect_uri = request.args.get("redirect_uri") - token = create_token(token_expiry) - return """ -<html> - <body> - <form method="POST" name="hiddenform" action="{0}"> - <input type="hidden" name="{1}" value="{2}" /> - <noscript> - <p>Script is disabled. Click Submit to continue.</p> - <input type="submit" value="Submit" /> - </noscript> - </form> - <script language="javascript">document.forms[0].submit();</script> - </body> -</html> - """.format( - redirect_uri, response_type, token - ) - - -def redirect_with_a_token_without_state(token_expiry, response_type): - redirect_uri = request.args.get("redirect_uri") - token = create_token(token_expiry) - return redirect("{0}#{1}={2}".format(redirect_uri, response_type, token)) - - -def submit_an_empty_form(): - redirect_uri = request.args.get("redirect_uri") - return """ -<html> - <body> - <form method="POST" name="hiddenform" action="{0}"> - <noscript> - <p>Script is disabled. Click Submit to continue.</p> - <input type="submit" value="Submit" /> - </noscript> - </form> - <script language="javascript">document.forms[0].submit();</script> - </body> -</html> - """.format( - redirect_uri - ) - - -def redirect_without_a_token(): - redirect_uri = request.args.get("redirect_uri") - return redirect("{0}".format(redirect_uri)) - - -def close_page(): - return """ -<html> - <body onload="window.open('', '_self', ''); window.setTimeout(close, 1)"> - </body> -</html> - """ - - -def create_token(expiry): - token = ( - jwt.encode({"exp": expiry}, "secret") if expiry else jwt.encode({}, "secret") - ) - return token.decode("unicode_escape") - - -def start_server(port): - logger.info("Starting test server on port {0}.".format(port)) - app.run(port=port) diff --git a/tests/failing_ntlm/__init__.py b/tests/failing_ntlm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/failing_ntlm/requests_negotiate_sspi.py b/tests/failing_ntlm/requests_negotiate_sspi.py new file mode 100644 index 0000000..f36e4fe --- /dev/null +++ b/tests/failing_ntlm/requests_negotiate_sspi.py @@ -0,0 +1 @@ +raise ImportError() diff --git a/tests/failing_ntlm/requests_ntlm.py b/tests/failing_ntlm/requests_ntlm.py new file mode 100644 index 0000000..f36e4fe --- /dev/null +++ b/tests/failing_ntlm/requests_ntlm.py @@ -0,0 +1 @@ +raise ImportError() diff --git a/tests/oauth2_helper.py b/tests/oauth2_helper.py index bfc5391..b2a59cd 100644 --- a/tests/oauth2_helper.py +++ b/tests/oauth2_helper.py @@ -1,53 +1,124 @@ -import multiprocessing import logging import urllib.request +import threading +from urllib.parse import urlsplit +from typing import Dict, Optional +import datetime import pytest +import jwt -from tests import authenticated_test_service import requests_auth logger = logging.getLogger(__name__) -TEST_SERVICE_PORT = 5001 # TODO Should use a method to retrieve a free port instead -TEST_SERVICE_HOST = "http://localhost:{0}".format(TEST_SERVICE_PORT) -TIMEOUT = 10 +def create_token(expiry: Optional[datetime.datetime]): + token = ( + jwt.encode({"exp": expiry}, "secret") if expiry else jwt.encode({}, "secret") + ) + return token.decode("unicode_escape") + + +@pytest.fixture +def token_cache(): + yield requests_auth.OAuth2.token_cache + requests_auth.OAuth2.token_cache.clear() + + +class Tab(threading.Thread): + def __init__(self, reply_url: str, data: str): + self.reply_url = reply_url + self.data = data.encode() if data is not None else None + self.checked = False + super().__init__() + + def run(self) -> None: + if not self.reply_url: + self.checked = True + return + + self._request_favicon() + self.content = self._simulate_redirect().decode() + + def _request_favicon(self): + scheme, netloc, *_ = urlsplit(self.reply_url) + favicon_response = urllib.request.urlopen(f"{scheme}://{netloc}/favicon.ico") + assert favicon_response.read() == b"Favicon is not provided." + def _simulate_redirect(self) -> bytes: + content = urllib.request.urlopen(self.reply_url, data=self.data).read() + if ( + content + == b'<html><body><script>\n var new_url = window.location.href.replace("#","?");\n if (new_url.indexOf("?") !== -1) {\n new_url += "&requests_auth_redirect=1";\n } else {\n new_url += "?requests_auth_redirect=1";\n }\n window.location.replace(new_url)\n </script></body></html>' + ): + content = self._simulate_requests_auth_redirect() + return content -def can_connect_to_server(port: int): - try: - response = urllib.request.urlopen( - "http://localhost:{0}/status".format(port), timeout=0.5 + def _simulate_requests_auth_redirect(self) -> bytes: + reply_url = self.reply_url.replace("#", "?") + reply_url += ( + "&requests_auth_redirect=1" + if "?" in reply_url + else "?requests_auth_redirect=1" ) - return response.code == 200 - except: - return False + return urllib.request.urlopen(reply_url, data=self.data).read() + def assert_success(self, expected_message: str, timeout: int = 1): + self.join() + assert ( + self.content + == f"<body onload=\"window.open('', '_self', ''); window.setTimeout(close, {timeout})\" style=\"\n color: #4F8A10;\n background-color: #DFF2BF;\n font-size: xx-large;\n display: flex;\n align-items: center;\n justify-content: center;\">\n <div style=\"border: 1px solid;\">{expected_message}</div>\n </body>" + ) + self.checked = True -def _wait_for_server_to_be_started(port: int): - for attempt in range(10): - if can_connect_to_server(port): - logger.info("Test server is started") - break - logger.info("Test server still not started...") - else: - raise Exception("Test server was not able to start.") + def assert_failure(self, expected_message: str, timeout: int = 5000): + self.join() + assert ( + self.content + == f"<body onload=\"window.open('', '_self', ''); window.setTimeout(close, {timeout})\" style=\"\n color: #D8000C;\n background-color: #FFBABA;\n font-size: xx-large;\n display: flex;\n align-items: center;\n justify-content: center;\">\n <div style=\"border: 1px solid;\">{expected_message}</div>\n </body>" + ) + self.checked = True -@pytest.fixture(scope="module") -def authenticated_service(): - test_service_process = multiprocessing.Process( - target=authenticated_test_service.start_server, args=(TEST_SERVICE_PORT,) - ) - test_service_process.start() - _wait_for_server_to_be_started(TEST_SERVICE_PORT) - yield test_service_process - test_service_process.terminate() - test_service_process.join(timeout=0.5) +class BrowserMock: + def __init__(self): + self.tabs: Dict[str, Tab] = {} + + def open(self, url: str, new: int): + assert new == 1 + assert url in self.tabs, f"Browser call on {url} was not mocked." + # Simulate a browser by sending the response in another thread + self.tabs[url].start() + return True + + def add_response( + self, opened_url: str, reply_url: Optional[str], data: str = None + ) -> Tab: + """ + :param opened_url: URL opened by requests_auth + :param reply_url: The URL to send a response to, None to simulate the fact that there is no redirect. + :param data: Body of the POST response to be sent. None to send a GET request. + """ + tab = Tab(reply_url, data) + self.tabs[opened_url] = tab + return tab + + def assert_checked(self): + for url, tab in self.tabs.items(): + tab.join() + assert tab.checked, f"Response received on {url} was not checked properly." @pytest.fixture -def token_cache(): - yield requests_auth.OAuth2.token_cache - requests_auth.OAuth2.token_cache.clear() +def browser_mock(monkeypatch) -> BrowserMock: + mock = BrowserMock() + import requests_auth.oauth2_authentication_responses_server + + monkeypatch.setattr( + requests_auth.oauth2_authentication_responses_server.webbrowser, + "get", + lambda *args: mock, + ) + yield mock + mock.assert_checked() diff --git a/tests/success_ntlm/__init__.py b/tests/success_ntlm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/success_ntlm/requests_negotiate_sspi.py b/tests/success_ntlm/requests_negotiate_sspi.py new file mode 100644 index 0000000..f5bd2ad --- /dev/null +++ b/tests/success_ntlm/requests_negotiate_sspi.py @@ -0,0 +1,3 @@ +class HttpNegotiateAuth: + def __call__(self, r): + r.headers["Authorization"] = "HttpNegotiateAuth fake" diff --git a/tests/success_ntlm/requests_ntlm.py b/tests/success_ntlm/requests_ntlm.py new file mode 100644 index 0000000..8821f8d --- /dev/null +++ b/tests/success_ntlm/requests_ntlm.py @@ -0,0 +1,9 @@ +class HttpNtlmAuth: + def __init__(self, username, password): + self.username = username + self.password = password + + def __call__(self, r): + r.headers[ + "Authorization" + ] = f"HttpNtlmAuth fake {self.username} / {self.password}" diff --git a/tests/test_multiple_authentication.py b/tests/test_multiple_authentication.py index 0da0bdf..6956378 100644 --- a/tests/test_multiple_authentication.py +++ b/tests/test_multiple_authentication.py @@ -1,15 +1,10 @@ -import re +import datetime from responses import RequestsMock import requests import requests_auth -from tests.oauth2_helper import ( - authenticated_service, - token_cache, - TIMEOUT, - TEST_SERVICE_HOST, -) +from tests.oauth2_helper import token_cache, browser_mock, BrowserMock, create_token from tests.auth_helper import get_header @@ -32,7 +27,7 @@ def test_basic_and_api_key_authentication_can_be_combined_deprecated( def test_header_api_key_and_multiple_authentication_can_be_combined( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock ): api_key_auth = requests_auth.HeaderApiKey("my_provided_api_key") api_key_auth2 = requests_auth.HeaderApiKey( @@ -48,7 +43,7 @@ def test_header_api_key_and_multiple_authentication_can_be_combined( def test_multiple_auth_and_header_api_key_can_be_combined( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock ): api_key_auth = requests_auth.HeaderApiKey("my_provided_api_key") api_key_auth2 = requests_auth.HeaderApiKey( @@ -64,7 +59,7 @@ def test_multiple_auth_and_header_api_key_can_be_combined( def test_multiple_auth_and_multiple_auth_can_be_combined( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock ): api_key_auth = requests_auth.HeaderApiKey("my_provided_api_key") api_key_auth2 = requests_auth.HeaderApiKey( @@ -86,7 +81,7 @@ def test_multiple_auth_and_multiple_auth_can_be_combined( def test_basic_and_multiple_authentication_can_be_combined( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock ): basic_auth = requests_auth.Basic("test_user", "test_pwd") api_key_auth2 = requests_auth.HeaderApiKey( @@ -102,7 +97,7 @@ def test_basic_and_multiple_authentication_can_be_combined( def test_query_api_key_and_multiple_authentication_can_be_combined( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock ): api_key_auth = requests_auth.QueryApiKey("my_provided_api_key") api_key_auth2 = requests_auth.QueryApiKey( @@ -127,13 +122,10 @@ def test_query_api_key_and_multiple_authentication_can_be_combined( def test_oauth2_resource_owner_password_and_api_key_authentication_can_be_combined( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock ): resource_owner_password_auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( - "http://provide_access_token", - username="test_user", - password="test_pwd", - timeout=TIMEOUT, + "http://provide_access_token", username="test_user", password="test_pwd" ) responses.add( responses.POST, @@ -153,13 +145,10 @@ def test_oauth2_resource_owner_password_and_api_key_authentication_can_be_combin def test_oauth2_resource_owner_password_and_multiple_authentication_can_be_combined( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock ): resource_owner_password_auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( - "http://provide_access_token", - username="test_user", - password="test_pwd", - timeout=TIMEOUT, + "http://provide_access_token", username="test_user", password="test_pwd" ) responses.add( responses.POST, @@ -185,13 +174,10 @@ def test_oauth2_resource_owner_password_and_multiple_authentication_can_be_combi def test_oauth2_client_credential_and_api_key_authentication_can_be_combined( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock ): resource_owner_password_auth = requests_auth.OAuth2ClientCredentials( - "http://provide_access_token", - username="test_user", - password="test_pwd", - timeout=TIMEOUT, + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) responses.add( responses.POST, @@ -211,13 +197,10 @@ def test_oauth2_client_credential_and_api_key_authentication_can_be_combined( def test_oauth2_client_credential_and_multiple_authentication_can_be_combined( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock ): resource_owner_password_auth = requests_auth.OAuth2ClientCredentials( - "http://provide_access_token", - username="test_user", - password="test_pwd", - timeout=TIMEOUT, + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) responses.add( responses.POST, @@ -243,12 +226,14 @@ def test_oauth2_client_credential_and_multiple_authentication_can_be_combined( def test_oauth2_authorization_code_and_api_key_authentication_can_be_combined( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): authorization_code_auth = requests_auth.OAuth2AuthorizationCode( - TEST_SERVICE_HOST + "/provide_code_as_anchor_code", - "http://provide_access_token", - timeout=TIMEOUT, + "http://provide_code", "http://provide_access_token" + ) + 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, @@ -265,15 +250,20 @@ def test_oauth2_authorization_code_and_api_key_authentication_can_be_combined( header = get_header(responses, authorization_code_auth + api_key_auth) assert header.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" assert header.get("X-Api-Key") == "my_provided_api_key" + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) def test_oauth2_authorization_code_and_multiple_authentication_can_be_combined( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): authorization_code_auth = requests_auth.OAuth2AuthorizationCode( - TEST_SERVICE_HOST + "/provide_code_as_anchor_code", - "http://provide_access_token", - timeout=TIMEOUT, + "http://provide_code", "http://provide_access_token" + ) + 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, @@ -296,15 +286,21 @@ def test_oauth2_authorization_code_and_multiple_authentication_can_be_combined( assert header.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" assert header.get("X-Api-Key") == "my_provided_api_key" assert header.get("X-Api-Key2") == "my_provided_api_key2" + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) def test_oauth2_pkce_and_api_key_authentication_can_be_combined( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock, monkeypatch ): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) pkce_auth = requests_auth.OAuth2AuthorizationCodePKCE( - TEST_SERVICE_HOST + "/provide_code_as_anchor_code", - "http://provide_access_token", - timeout=TIMEOUT, + "http://provide_code", "http://provide_access_token" + ) + 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", ) responses.add( responses.POST, @@ -321,15 +317,21 @@ def test_oauth2_pkce_and_api_key_authentication_can_be_combined( header = get_header(responses, pkce_auth + api_key_auth) assert header.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" assert header.get("X-Api-Key") == "my_provided_api_key" + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) def test_oauth2_pkce_and_multiple_authentication_can_be_combined( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock, monkeypatch ): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) pkce_auth = requests_auth.OAuth2AuthorizationCodePKCE( - TEST_SERVICE_HOST + "/provide_code_as_anchor_code", - "http://provide_access_token", - timeout=TIMEOUT, + "http://provide_code", "http://provide_access_token" + ) + 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", ) responses.add( responses.POST, @@ -350,31 +352,50 @@ def test_oauth2_pkce_and_multiple_authentication_can_be_combined( assert header.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" assert header.get("X-Api-Key") == "my_provided_api_key" assert header.get("X-Api-Key2") == "my_provided_api_key2" + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) def test_oauth2_implicit_and_api_key_authentication_can_be_combined( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): - implicit_auth = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/provide_token_as_access_token", timeout=TIMEOUT + implicit_auth = requests_auth.OAuth2Implicit("http://provide_token") + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token = create_token(expiry_in_1_hour) + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000", + data=f"access_token={token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", ) api_key_auth = requests_auth.HeaderApiKey("my_provided_api_key") header = get_header(responses, implicit_auth + api_key_auth) - assert re.match("^Bearer .*", header.get("Authorization")) + assert header.get("Authorization") == f"Bearer {token}" assert header.get("X-Api-Key") == "my_provided_api_key" + tab.assert_success( + "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." + ) def test_oauth2_implicit_and_multiple_authentication_can_be_combined( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): - implicit_auth = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/provide_token_as_access_token", timeout=TIMEOUT + implicit_auth = requests_auth.OAuth2Implicit("http://provide_token") + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token = create_token(expiry_in_1_hour) + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000", + data=f"access_token={token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", ) api_key_auth = requests_auth.HeaderApiKey("my_provided_api_key") api_key_auth2 = requests_auth.HeaderApiKey( "my_provided_api_key2", header_name="X-Api-Key2" ) header = get_header(responses, implicit_auth + (api_key_auth + api_key_auth2)) - assert re.match("^Bearer .*", header.get("Authorization")) + assert header.get("Authorization") == f"Bearer {token}" assert header.get("X-Api-Key") == "my_provided_api_key" assert header.get("X-Api-Key2") == "my_provided_api_key2" + tab.assert_success( + "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." + ) diff --git a/tests/test_ntlm.py b/tests/test_ntlm.py index 6117c34..c710316 100644 --- a/tests/test_ntlm.py +++ b/tests/test_ntlm.py @@ -1,9 +1,18 @@ +import os + import pytest import requests_auth +from tests.auth_helper import get_header -def test_requests_negotiate_sspi_is_used_when_nothing_is_provided(): +def test_requests_negotiate_sspi_is_used_when_nothing_is_provided_but_without_installed( + monkeypatch, +): + # load requests_negociate_sspi from the file in tests/failing_ntlm folder + monkeypatch.syspath_prepend( + os.path.join(os.path.abspath(os.path.dirname(__file__)), "failing_ntlm") + ) with pytest.raises(Exception) as exception_info: requests_auth.NTLM() assert ( @@ -12,7 +21,26 @@ def test_requests_negotiate_sspi_is_used_when_nothing_is_provided(): ) -def test_requests_ntlm_is_used_when_user_and_pass_provided(): +def test_requests_negotiate_sspi_is_used_when_nothing_is_provided( + monkeypatch, responses +): + # load requests_negociate_sspi from the file in tests/success_ntlm folder + monkeypatch.syspath_prepend( + os.path.join(os.path.abspath(os.path.dirname(__file__)), "success_ntlm") + ) + assert ( + get_header(responses, requests_auth.NTLM()).get("Authorization") + == "HttpNegotiateAuth fake" + ) + + +def test_requests_ntlm_is_used_when_user_and_pass_provided_but_without_installed( + monkeypatch, +): + # load requests_ntlm from the file in tests/failing_ntlm folder + monkeypatch.syspath_prepend( + os.path.join(os.path.abspath(os.path.dirname(__file__)), "failing_ntlm") + ) with pytest.raises(Exception) as exception_info: requests_auth.NTLM("fake_user", "fake_pwd") assert ( @@ -21,6 +49,19 @@ def test_requests_ntlm_is_used_when_user_and_pass_provided(): ) +def test_requests_ntlm_is_used_when_user_and_pass_provided(monkeypatch, responses): + # load requests_ntlm from the file in tests/success_ntlm folder + monkeypatch.syspath_prepend( + os.path.join(os.path.abspath(os.path.dirname(__file__)), "success_ntlm") + ) + assert ( + get_header(responses, requests_auth.NTLM("fake_user", "fake_pwd")).get( + "Authorization" + ) + == "HttpNtlmAuth fake fake_user / fake_pwd" + ) + + def test_user_without_password_is_invalid(): with pytest.raises(Exception) as exception_info: requests_auth.NTLM("fake_user") diff --git a/tests/test_oauth2_authorization_code.py b/tests/test_oauth2_authorization_code.py index 878e037..82fc94f 100644 --- a/tests/test_oauth2_authorization_code.py +++ b/tests/test_oauth2_authorization_code.py @@ -3,22 +3,19 @@ import requests import requests_auth -from tests.oauth2_helper import ( - authenticated_service, - token_cache, - TIMEOUT, - TEST_SERVICE_HOST, -) +from tests.oauth2_helper import token_cache, browser_mock, BrowserMock from tests.auth_helper import get_header, get_request def test_oauth2_authorization_code_flow_get_code_is_sent_in_authorization_header_by_default( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): auth = requests_auth.OAuth2AuthorizationCode( - TEST_SERVICE_HOST + "/provide_code_as_anchor_code", - "http://provide_access_token", - timeout=TIMEOUT, + "http://provide_code", "http://provide_access_token" + ) + 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, @@ -39,15 +36,20 @@ def test_oauth2_authorization_code_flow_get_code_is_sent_in_authorization_header get_request(responses, "http://provide_access_token/").body == "grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) def test_empty_token_is_invalid( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): auth = requests_auth.OAuth2AuthorizationCode( - TEST_SERVICE_HOST + "/provide_code_as_anchor_code", - "http://provide_access_token", - timeout=TIMEOUT, + "http://provide_code", "http://provide_access_token" + ) + 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, @@ -66,15 +68,537 @@ def test_empty_token_is_invalid( str(exception_info.value) == "access_token not provided within {'access_token': '', 'token_type': 'example', 'expires_in': 3600, 'refresh_token': 'tGzv3JOkF0XG5Qx2TlKWIA', 'example_parameter': 'example_value'}." ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) -def test_nonce_is_sent_if_provided_in_authorization_url( - authenticated_service, token_cache, responses: RequestsMock +def test_with_invalid_grant_request_no_json( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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", body="failure", status=400 + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "failure" + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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={"error": "invalid_request"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_request: The request is missing a required parameter, includes an " + "unsupported parameter value (other than grant type), repeats a parameter, " + "includes multiple credentials, utilizes more than one mechanism for " + "authenticating the client, or is otherwise malformed." + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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={"error": "invalid_request", "error_description": "desc of the error"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "invalid_request: desc of the error" + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri( + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): auth = requests_auth.OAuth2AuthorizationCode( - TEST_SERVICE_HOST + "/provide_code_as_anchor_code?nonce=123456", + "http://provide_code", "http://provide_access_token" + ) + 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", - timeout=TIMEOUT, + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + }, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == f"invalid_request: desc of the error\nMore information can be found on http://test_url" + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri_and_other_fields( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + "other": "other info", + }, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_request: desc of the error\nMore information can be found on http://test_url\nAdditional information: {'other': 'other info'}" + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_without_error( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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={"other": "other info"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "{'other': 'other info'}" + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_client_error( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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={"error": "invalid_client"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_client: Client authentication failed (e.g., unknown client, no " + "client authentication included, or unsupported authentication method). The " + "authorization server MAY return an HTTP 401 (Unauthorized) status code to " + "indicate which HTTP authentication schemes are supported. If the client " + 'attempted to authenticate via the "Authorization" request header field, the ' + "authorization server MUST respond with an HTTP 401 (Unauthorized) status " + 'code and include the "WWW-Authenticate" response header field matching the ' + "authentication scheme used by the client." + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_grant_error( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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={"error": "invalid_grant"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_grant: The provided authorization grant (e.g., authorization code, " + "resource owner credentials) or refresh token is invalid, expired, revoked, " + "does not match the redirection URI used in the authorization request, or was " + "issued to another client." + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_unauthorized_client_error( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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={"error": "unauthorized_client"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "unauthorized_client: The authenticated client is not authorized to use this " + "authorization grant type." + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_unsupported_grant_type_error( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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={"error": "unsupported_grant_type"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "unsupported_grant_type: The authorization grant type is not supported by the " + "authorization server." + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_scope_error( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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={"error": "invalid_scope"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_scope: The requested scope is invalid, unknown, malformed, or " + "exceeds the scope granted by the resource owner." + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_token_request_invalid_request_error( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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#error=invalid_request", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_request: The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed." + ) + tab.assert_failure( + "Unable to properly perform authentication: invalid_request: The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed." + ) + + +def test_with_invalid_token_request_invalid_request_error_and_error_description( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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#error=invalid_request&error_description=desc", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "invalid_request: desc" + tab.assert_failure( + "Unable to properly perform authentication: invalid_request: desc" + ) + + +def test_with_invalid_token_request_invalid_request_error_and_error_description_and_uri( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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#error=invalid_request&error_description=desc&error_uri=http://test_url", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_request: desc\nMore information can be found on http://test_url" + ) + tab.assert_failure( + "Unable to properly perform authentication: invalid_request: desc\nMore information can be found on http://test_url" + ) + + +def test_with_invalid_token_request_invalid_request_error_and_error_description_and_uri_and_other_fields( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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#error=invalid_request&error_description=desc&error_uri=http://test_url&other=test", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_request: desc\nMore information can be found on http://test_url\nAdditional information: {'other': ['test']}" + ) + tab.assert_failure( + "Unable to properly perform authentication: invalid_request: desc\nMore information can be found on http://test_url\nAdditional information: {'other': ['test']}" + ) + + +def test_with_invalid_token_request_unauthorized_client_error( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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#error=unauthorized_client", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "unauthorized_client: The client is not authorized to request an authorization code or an access token using this method." + ) + tab.assert_failure( + "Unable to properly perform authentication: unauthorized_client: The client is not authorized to request an authorization code or an access token using this method." + ) + + +def test_with_invalid_token_request_access_denied_error( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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#error=access_denied", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "access_denied: The resource owner or authorization server denied the request." + ) + tab.assert_failure( + "Unable to properly perform authentication: access_denied: The resource owner or authorization server denied the request." + ) + + +def test_with_invalid_token_request_unsupported_response_type_error( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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#error=unsupported_response_type", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "unsupported_response_type: The authorization server does not support obtaining an authorization code or an access token using this method." + ) + tab.assert_failure( + "Unable to properly perform authentication: unsupported_response_type: The authorization server does not support obtaining an authorization code or an access token using this method." + ) + + +def test_with_invalid_token_request_invalid_scope_error( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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#error=invalid_scope", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_scope: The requested scope is invalid, unknown, or malformed." + ) + tab.assert_failure( + "Unable to properly perform authentication: invalid_scope: The requested scope is invalid, unknown, or malformed." + ) + + +def test_with_invalid_token_request_server_error_error( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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#error=server_error", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "server_error: The authorization server encountered an unexpected condition that prevented it from fulfilling the request. (This error code is needed because a 500 Internal Server Error HTTP status code cannot be returned to the client via an HTTP redirect.)" + ) + tab.assert_failure( + "Unable to properly perform authentication: server_error: The authorization server encountered an unexpected condition that prevented it from fulfilling the request. (This error code is needed because a 500 Internal Server Error HTTP status code cannot be returned to the client via an HTTP redirect.)" + ) + + +def test_with_invalid_token_request_temporarily_unavailable_error( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + 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#error=temporarily_unavailable", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "temporarily_unavailable: The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server. (This error code is needed because a 503 Service Unavailable HTTP status code cannot be returned to the client via an HTTP redirect.)" + ) + tab.assert_failure( + "Unable to properly perform authentication: temporarily_unavailable: The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server. (This error code is needed because a 503 Service Unavailable HTTP status code cannot be returned to the client via an HTTP redirect.)" + ) + + +def test_nonce_is_sent_if_provided_in_authorization_url( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code?nonce=123456", "http://provide_access_token" + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) responses.add( responses.POST, @@ -95,17 +619,23 @@ def test_nonce_is_sent_if_provided_in_authorization_url( get_request(responses, "http://provide_access_token/").body == "grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) def test_response_type_can_be_provided_in_url( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): auth = requests_auth.OAuth2AuthorizationCode( - TEST_SERVICE_HOST + "/provide_code_as_anchor_code?response_type=code", + "http://provide_code?response_type=my_code", "http://provide_access_token", - timeout=TIMEOUT, response_type="not_used", ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=my_code&state=49b67a19e70f692c3fc09dd124e5782b41a86f4f4931e1cc938ccbb466eecf1b730edb9eb01e42005de77ce3dd5a016418f8e780f30c4477d71102fe03e39e62&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=49b67a19e70f692c3fc09dd124e5782b41a86f4f4931e1cc938ccbb466eecf1b730edb9eb01e42005de77ce3dd5a016418f8e780f30c4477d71102fe03e39e62", + ) responses.add( responses.POST, "http://provide_access_token", @@ -121,6 +651,13 @@ def test_response_type_can_be_provided_in_url( get_header(responses, auth).get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" ) + assert ( + get_request(responses, "http://provide_access_token/").body + == "grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code=SplxlOBeZQQYbYS6WxSbIA" + ) + tab.assert_success( + "You are now authenticated on 49b67a19e70f692c3fc09dd124e5782b41a86f4f4931e1cc938ccbb466eecf1b730edb9eb01e42005de77ce3dd5a016418f8e780f30c4477d71102fe03e39e62. You may close this tab." + ) def test_authorization_url_is_mandatory(): diff --git a/tests/test_oauth2_authorization_code_okta.py b/tests/test_oauth2_authorization_code_okta.py index 32cad06..0952898 100644 --- a/tests/test_oauth2_authorization_code_okta.py +++ b/tests/test_oauth2_authorization_code_okta.py @@ -14,36 +14,15 @@ def test_corresponding_oauth2_authorization_code_flow_instance(): "&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b" "&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F" ) - - assert ( - str(okta) == "OAuth2AuthorizationCode(" - "'https://testserver.okta-emea.com/oauth2/default/v1/authorize', " - "'https://testserver.okta-emea.com/oauth2/default/v1/token', " - "client_id='54239d18-c68c-4c47-8bdd-ce71ea1d50cd', " - "scope='openid')" - ) - - -def test_corresponding_oauth2_authorization_code_flow_instance_using_helper(): - okta = requests_auth.okta( - requests_auth.OAuth2Flow.AuthorizationCode, - "testserver.okta-emea.com", - "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", - ) - assert ( - okta.code_grant_details.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" - ) - assert ( - str(okta) == "OAuth2AuthorizationCode(" - "'https://testserver.okta-emea.com/oauth2/default/v1/authorize', " - "'https://testserver.okta-emea.com/oauth2/default/v1/token', " - "client_id='54239d18-c68c-4c47-8bdd-ce71ea1d50cd', " - "scope='openid')" + okta.authorization_url + == "https://testserver.okta-emea.com/oauth2/default/v1/authorize" ) + assert okta.token_url == "https://testserver.okta-emea.com/oauth2/default/v1/token" + assert okta.token_data == { + "client_id": "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "response_type": "code", + "scope": "openid", + } diff --git a/tests/test_oauth2_authorization_code_pkce.py b/tests/test_oauth2_authorization_code_pkce.py new file mode 100644 index 0000000..0af7224 --- /dev/null +++ b/tests/test_oauth2_authorization_code_pkce.py @@ -0,0 +1,672 @@ +from responses import RequestsMock +import pytest +import requests + +import requests_auth +from tests.auth_helper import get_header, get_request +from tests.oauth2_helper import token_cache, browser_mock, BrowserMock + + +def test_oauth2_pkce_flow_get_code_is_sent_in_authorization_header_by_default( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code", "http://provide_access_token" + ) + 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", + ) + 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" + ) + assert ( + get_request(responses, "http://provide_access_token/").body + == "code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_nonce_is_sent_if_provided_in_authorization_url( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code?nonce=123456", "http://provide_access_token" + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + 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" + ) + assert ( + get_request(responses, "http://provide_access_token/").body + == "code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_no_json( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code?nonce=123456", "http://provide_access_token" + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", + ) + responses.add( + responses.POST, "http://provide_access_token", body="failure", status=400 + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "failure" + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code?nonce=123456", "http://provide_access_token" + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "invalid_request"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_request: The request is missing a required parameter, includes an " + "unsupported parameter value (other than grant type), repeats a parameter, " + "includes multiple credentials, utilizes more than one mechanism for " + "authenticating the client, or is otherwise malformed." + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code?nonce=123456", "http://provide_access_token" + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "invalid_request", "error_description": "desc of the error"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "invalid_request: desc of the error" + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code?nonce=123456", "http://provide_access_token" + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + }, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == f"invalid_request: desc of the error\nMore information can be found on http://test_url" + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri_and_other_fields( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code?nonce=123456", "http://provide_access_token" + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + "other": "other info", + }, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == f"invalid_request: desc of the error\nMore information can be found on http://test_url\nAdditional information: {{'other': 'other info'}}" + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_without_error( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code?nonce=123456", "http://provide_access_token" + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"other": "other info"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "{'other': 'other info'}" + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_client_error( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code?nonce=123456", "http://provide_access_token" + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "invalid_client"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_client: Client authentication failed (e.g., unknown client, no " + "client authentication included, or unsupported authentication method). The " + "authorization server MAY return an HTTP 401 (Unauthorized) status code to " + "indicate which HTTP authentication schemes are supported. If the client " + 'attempted to authenticate via the "Authorization" request header field, the ' + "authorization server MUST respond with an HTTP 401 (Unauthorized) status " + 'code and include the "WWW-Authenticate" response header field matching the ' + "authentication scheme used by the client." + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_grant_error( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code?nonce=123456", "http://provide_access_token" + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "invalid_grant"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_grant: The provided authorization grant (e.g., authorization code, " + "resource owner credentials) or refresh token is invalid, expired, revoked, " + "does not match the redirection URI used in the authorization request, or was " + "issued to another client." + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_unauthorized_client_error( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code?nonce=123456", "http://provide_access_token" + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "unauthorized_client"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "unauthorized_client: The authenticated client is not authorized to use this " + "authorization grant type." + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_unsupported_grant_type_error( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code?nonce=123456", "http://provide_access_token" + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "unsupported_grant_type"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "unsupported_grant_type: The authorization grant type is not supported by the " + "authorization server." + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_scope_error( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code?nonce=123456", "http://provide_access_token" + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "invalid_scope"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_scope: The requested scope is invalid, unknown, malformed, or " + "exceeds the scope granted by the resource owner." + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_token_request_invalid_request_error( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code", "http://provide_access_token" + ) + 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#error=invalid_request", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_request: The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed." + ) + tab.assert_failure( + "Unable to properly perform authentication: invalid_request: The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed." + ) + + +def test_with_invalid_token_request_invalid_request_error_and_error_description( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code", "http://provide_access_token" + ) + 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#error=invalid_request&error_description=desc", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "invalid_request: desc" + tab.assert_failure( + "Unable to properly perform authentication: invalid_request: desc" + ) + + +def test_with_invalid_token_request_invalid_request_error_and_error_description_and_uri( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code", "http://provide_access_token" + ) + 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#error=invalid_request&error_description=desc&error_uri=http://test_url", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_request: desc\nMore information can be found on http://test_url" + ) + tab.assert_failure( + "Unable to properly perform authentication: invalid_request: desc\nMore information can be found on http://test_url" + ) + + +def test_with_invalid_token_request_invalid_request_error_and_error_description_and_uri_and_other_fields( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code", "http://provide_access_token" + ) + 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#error=invalid_request&error_description=desc&error_uri=http://test_url&other=test", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_request: desc\nMore information can be found on http://test_url\nAdditional information: {'other': ['test']}" + ) + tab.assert_failure( + "Unable to properly perform authentication: invalid_request: desc\nMore information can be found on http://test_url\nAdditional information: {'other': ['test']}" + ) + + +def test_with_invalid_token_request_unauthorized_client_error( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code", "http://provide_access_token" + ) + 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#error=unauthorized_client", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "unauthorized_client: The client is not authorized to request an authorization code or an access token using this method." + ) + tab.assert_failure( + "Unable to properly perform authentication: unauthorized_client: The client is not authorized to request an authorization code or an access token using this method." + ) + + +def test_with_invalid_token_request_access_denied_error( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code", "http://provide_access_token" + ) + 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#error=access_denied", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "access_denied: The resource owner or authorization server denied the request." + ) + tab.assert_failure( + "Unable to properly perform authentication: access_denied: The resource owner or authorization server denied the request." + ) + + +def test_with_invalid_token_request_unsupported_response_type_error( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code", "http://provide_access_token" + ) + 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#error=unsupported_response_type", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "unsupported_response_type: The authorization server does not support obtaining an authorization code or an access token using this method." + ) + tab.assert_failure( + "Unable to properly perform authentication: unsupported_response_type: The authorization server does not support obtaining an authorization code or an access token using this method." + ) + + +def test_with_invalid_token_request_invalid_scope_error( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code", "http://provide_access_token" + ) + 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#error=invalid_scope", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_scope: The requested scope is invalid, unknown, or malformed." + ) + tab.assert_failure( + "Unable to properly perform authentication: invalid_scope: The requested scope is invalid, unknown, or malformed." + ) + + +def test_with_invalid_token_request_server_error_error( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code", "http://provide_access_token" + ) + 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#error=server_error", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "server_error: The authorization server encountered an unexpected condition that prevented it from fulfilling the request. (This error code is needed because a 500 Internal Server Error HTTP status code cannot be returned to the client via an HTTP redirect.)" + ) + tab.assert_failure( + "Unable to properly perform authentication: server_error: The authorization server encountered an unexpected condition that prevented it from fulfilling the request. (This error code is needed because a 500 Internal Server Error HTTP status code cannot be returned to the client via an HTTP redirect.)" + ) + + +def test_with_invalid_token_request_temporarily_unavailable_error( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code", "http://provide_access_token" + ) + 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#error=temporarily_unavailable", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "temporarily_unavailable: The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server. (This error code is needed because a 503 Service Unavailable HTTP status code cannot be returned to the client via an HTTP redirect.)" + ) + tab.assert_failure( + "Unable to properly perform authentication: temporarily_unavailable: The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server. (This error code is needed because a 503 Service Unavailable HTTP status code cannot be returned to the client via an HTTP redirect.)" + ) + + +def test_response_type_can_be_provided_in_url( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code?response_type=my_code", + "http://provide_access_token", + response_type="not_used", + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=%5B%27my_code%27%5D&state=b32e05720bd3722e0ac87bf72897a78b669a0810adf8da46b675793dcfe0f41a40f7d7fdda952bd73ea533a2462907d805adf8c1a162d51b99b2ddec0d411feb&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=b32e05720bd3722e0ac87bf72897a78b669a0810adf8da46b675793dcfe0f41a40f7d7fdda952bd73ea533a2462907d805adf8c1a162d51b99b2ddec0d411feb", + ) + 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" + ) + assert ( + get_request(responses, "http://provide_access_token/").body + == "code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=my_code&code=SplxlOBeZQQYbYS6WxSbIA" + ) + tab.assert_success( + "You are now authenticated on b32e05720bd3722e0ac87bf72897a78b669a0810adf8da46b675793dcfe0f41a40f7d7fdda952bd73ea533a2462907d805adf8c1a162d51b99b2ddec0d411feb. You may close this tab." + ) + + +def test_authorization_url_is_mandatory(): + with pytest.raises(Exception) as exception_info: + requests_auth.OAuth2AuthorizationCodePKCE("", "http://test_url") + assert str(exception_info.value) == "Authorization URL is mandatory." + + +def test_token_url_is_mandatory(): + with pytest.raises(Exception) as exception_info: + requests_auth.OAuth2AuthorizationCodePKCE("http://test_url", "") + assert str(exception_info.value) == "Token URL is mandatory." + + +def test_header_value_must_contains_token(): + with pytest.raises(Exception) as exception_info: + requests_auth.OAuth2AuthorizationCodePKCE( + "http://test_url", "http://test_url", header_value="Bearer token" + ) + assert str(exception_info.value) == "header_value parameter must contains {token}." diff --git a/tests/test_oauth2_pkce_okta.py b/tests/test_oauth2_authorization_code_pkce_okta.py similarity index 67% rename from tests/test_oauth2_pkce_okta.py rename to tests/test_oauth2_authorization_code_pkce_okta.py index b68ec1b..de9d519 100644 --- a/tests/test_oauth2_pkce_okta.py +++ b/tests/test_oauth2_authorization_code_pkce_okta.py @@ -21,12 +21,20 @@ def test_corresponding_oauth2_authorization_code_flow_instance(monkeypatch): ) assert ( - str(okta) == "OAuth2PKCE(" - "'https://testserver.okta-emea.com/oauth2/default/v1/authorize', " - "'https://testserver.okta-emea.com/oauth2/default/v1/token', " - "client_id='54239d18-c68c-4c47-8bdd-ce71ea1d50cd', " - "scope='openid')" + okta.authorization_url + == "https://testserver.okta-emea.com/oauth2/default/v1/authorize" ) + assert okta.token_url == "https://testserver.okta-emea.com/oauth2/default/v1/token" + assert okta.token_data == { + "client_id": "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + "code_verifier": b"MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx" + b"MTExMTExMTExMTExMTExMTExMTEx", + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "response_type": "code", + "scope": "openid", + } + assert okta.code_grant_details.name == "code" def test_header_value_must_contains_token(): diff --git a/tests/test_oauth2_client_credential.py b/tests/test_oauth2_client_credential.py index 13b2e0f..4d71fac 100644 --- a/tests/test_oauth2_client_credential.py +++ b/tests/test_oauth2_client_credential.py @@ -1,19 +1,17 @@ from responses import RequestsMock import pytest +import requests import requests_auth -from tests.oauth2_helper import authenticated_service, token_cache, TIMEOUT +from tests.oauth2_helper import token_cache from tests.auth_helper import get_header def test_oauth2_client_credentials_flow_token_is_sent_in_authorization_header_by_default( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock ): auth = requests_auth.OAuth2ClientCredentials( - "http://provide_access_token", - username="test_user", - password="test_pwd", - timeout=TIMEOUT, + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) responses.add( responses.POST, @@ -32,22 +30,251 @@ def test_oauth2_client_credentials_flow_token_is_sent_in_authorization_header_by ) +def test_with_invalid_grant_request_no_json(token_cache, responses: RequestsMock): + auth = requests_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + responses.add( + responses.POST, "http://provide_access_token", body="failure", status=400 + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "failure" + + +def test_with_invalid_grant_request_invalid_request_error( + token_cache, responses: RequestsMock +): + auth = requests_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "invalid_request"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_request: The request is missing a required parameter, includes an " + "unsupported parameter value (other than grant type), repeats a parameter, " + "includes multiple credentials, utilizes more than one mechanism for " + "authenticating the client, or is otherwise malformed." + ) + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description( + token_cache, responses: RequestsMock +): + auth = requests_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "invalid_request", "error_description": "desc of the error"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "invalid_request: desc of the error" + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri( + token_cache, responses: RequestsMock +): + auth = requests_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + }, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == f"invalid_request: desc of the error\nMore information can be found on http://test_url" + ) + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri_and_other_fields( + token_cache, responses: RequestsMock +): + auth = requests_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + "other": "other info", + }, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == f"invalid_request: desc of the error\nMore information can be found on http://test_url\nAdditional information: {{'other': 'other info'}}" + ) + + +def test_with_invalid_grant_request_without_error(token_cache, responses: RequestsMock): + auth = requests_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"other": "other info"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "{'other': 'other info'}" + + +def test_with_invalid_grant_request_invalid_client_error( + token_cache, responses: RequestsMock +): + auth = requests_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "invalid_client"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_client: Client authentication failed (e.g., unknown client, no " + "client authentication included, or unsupported authentication method). The " + "authorization server MAY return an HTTP 401 (Unauthorized) status code to " + "indicate which HTTP authentication schemes are supported. If the client " + 'attempted to authenticate via the "Authorization" request header field, the ' + "authorization server MUST respond with an HTTP 401 (Unauthorized) status " + 'code and include the "WWW-Authenticate" response header field matching the ' + "authentication scheme used by the client." + ) + + +def test_with_invalid_grant_request_invalid_grant_error( + token_cache, responses: RequestsMock +): + auth = requests_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "invalid_grant"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_grant: The provided authorization grant (e.g., authorization code, " + "resource owner credentials) or refresh token is invalid, expired, revoked, " + "does not match the redirection URI used in the authorization request, or was " + "issued to another client." + ) + + +def test_with_invalid_grant_request_unauthorized_client_error( + token_cache, responses: RequestsMock +): + auth = requests_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "unauthorized_client"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "unauthorized_client: The authenticated client is not authorized to use this " + "authorization grant type." + ) + + +def test_with_invalid_grant_request_unsupported_grant_type_error( + token_cache, responses: RequestsMock +): + auth = requests_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "unsupported_grant_type"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "unsupported_grant_type: The authorization grant type is not supported by the " + "authorization server." + ) + + +def test_with_invalid_grant_request_invalid_scope_error( + token_cache, responses: RequestsMock +): + auth = requests_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "invalid_scope"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_scope: The requested scope is invalid, unknown, malformed, or " + "exceeds the scope granted by the resource owner." + ) + + def test_token_url_is_mandatory(): with pytest.raises(Exception) as exception_info: requests_auth.OAuth2ClientCredentials("", "test_user", "test_pwd") assert str(exception_info.value) == "Token URL is mandatory." -def test_user_name_is_mandatory(): +def test_client_id_is_mandatory(): with pytest.raises(Exception) as exception_info: requests_auth.OAuth2ClientCredentials("http://test_url", "", "test_pwd") - assert str(exception_info.value) == "User name is mandatory." + assert str(exception_info.value) == "client_id is mandatory." -def test_password_is_mandatory(): +def test_client_secret_is_mandatory(): with pytest.raises(Exception) as exception_info: requests_auth.OAuth2ClientCredentials("http://test_url", "test_user", "") - assert str(exception_info.value) == "Password is mandatory." + assert str(exception_info.value) == "client_secret is mandatory." def test_header_value_must_contains_token(): diff --git a/tests/test_oauth2_client_credential_okta.py b/tests/test_oauth2_client_credential_okta.py index f19a074..c1a0101 100644 --- a/tests/test_oauth2_client_credential_okta.py +++ b/tests/test_oauth2_client_credential_okta.py @@ -1,42 +1,15 @@ from responses import RequestsMock import requests_auth -from tests.oauth2_helper import authenticated_service, token_cache, TIMEOUT +from tests.oauth2_helper import token_cache from tests.auth_helper import get_header def test_okta_client_credentials_flow_token_is_sent_in_authorization_header_by_default( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock ): auth = requests_auth.OktaClientCredentials( - "test_okta", client_id="test_user", client_secret="test_pwd", timeout=TIMEOUT - ) - responses.add( - responses.POST, - "https://test_okta/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" - ) - - -def test_okta_client_credentials_flow_token_is_sent_in_authorization_header_by_default_using_helper( - authenticated_service, token_cache, responses: RequestsMock -): - auth = requests_auth.okta( - requests_auth.OAuth2Flow.ClientCredentials, - "test_okta", - client_id="test_user", - client_secret="test_pwd", - timeout=TIMEOUT, + "test_okta", client_id="test_user", client_secret="test_pwd" ) responses.add( responses.POST, diff --git a/tests/test_oauth2_implicit.py b/tests/test_oauth2_implicit.py index 7c59ff3..b82e23b 100644 --- a/tests/test_oauth2_implicit.py +++ b/tests/test_oauth2_implicit.py @@ -1,16 +1,11 @@ import time -import re +import datetime import requests import pytest from responses import RequestsMock -from tests.oauth2_helper import ( - authenticated_service, - token_cache, - TEST_SERVICE_HOST, - TIMEOUT, -) +from tests.oauth2_helper import token_cache, browser_mock, BrowserMock, create_token from tests.auth_helper import get_header import requests_auth @@ -28,90 +23,118 @@ def test_header_value_must_contains_token(): def test_oauth2_implicit_flow_token_is_not_reused_if_a_url_parameter_is_changing( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): auth1 = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST - + "/provide_token_as_custom_token?response_type=custom_token&fake_param=1", - timeout=TIMEOUT, + "http://provide_token?response_type=custom_token&fake_param=1", token_field_name="custom_token", ) + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + first_token = create_token(expiry_in_1_hour) + tab1 = browser_mock.add_response( + opened_url="http://provide_token?response_type=custom_token&fake_param=1&state=5652a8138e3a99dab7b94532c73ed5b10f19405316035d1efdc8bf7e0713690485254c2eaff912040eac44031889ef0a5ed5730c8a111541120d64a898c31afe&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000", + data=f"custom_token={first_token}&state=5652a8138e3a99dab7b94532c73ed5b10f19405316035d1efdc8bf7e0713690485254c2eaff912040eac44031889ef0a5ed5730c8a111541120d64a898c31afe", + ) - token_on_auth1 = get_header(responses, auth1).get("Authorization") - assert re.match("^Bearer .*", token_on_auth1) + assert get_header(responses, auth1).get("Authorization") == f"Bearer {first_token}" - # Ensure that the new generated token will be different than previous one - time.sleep(1) + # Ensure that the new token is different than previous one + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta( + hours=1, seconds=1 + ) auth2 = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST - + "/provide_token_as_custom_token?response_type=custom_token&fake_param=2", - timeout=TIMEOUT, + "http://provide_token?response_type=custom_token&fake_param=2", token_field_name="custom_token", ) + second_token = create_token(expiry_in_1_hour) + tab2 = browser_mock.add_response( + opened_url="http://provide_token?response_type=custom_token&fake_param=2&state=5c3940ccf78ac6e7d6d8d06782d9fd95a533aa5425b616eaa38dc3ec9508fbd55152c58a0d8dd8a087e76b77902559285819a41cb78ce8713e5a3b974bf07ce9&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000", + data=f"custom_token={second_token}&state=5c3940ccf78ac6e7d6d8d06782d9fd95a533aa5425b616eaa38dc3ec9508fbd55152c58a0d8dd8a087e76b77902559285819a41cb78ce8713e5a3b974bf07ce9", + ) response = requests.get("http://authorized_only", auth=auth2) # Return headers received on this dummy URL - token_on_auth2 = response.request.headers.get("Authorization") - assert re.match("^Bearer .*", token_on_auth2) - - assert token_on_auth1 != token_on_auth2 + assert response.request.headers.get("Authorization") == f"Bearer {second_token}" + tab1.assert_success( + "You are now authenticated on 5652a8138e3a99dab7b94532c73ed5b10f19405316035d1efdc8bf7e0713690485254c2eaff912040eac44031889ef0a5ed5730c8a111541120d64a898c31afe. You may close this tab." + ) + tab2.assert_success( + "You are now authenticated on 5c3940ccf78ac6e7d6d8d06782d9fd95a533aa5425b616eaa38dc3ec9508fbd55152c58a0d8dd8a087e76b77902559285819a41cb78ce8713e5a3b974bf07ce9. You may close this tab." + ) def test_oauth2_implicit_flow_token_is_reused_if_only_nonce_differs( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): auth1 = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST - + "/provide_token_as_custom_token?response_type=custom_token&nonce=1", - timeout=TIMEOUT, + "http://provide_token?response_type=custom_token&nonce=1", token_field_name="custom_token", ) - token_on_auth1 = get_header(responses, auth1).get("Authorization") - assert re.match("^Bearer .*", token_on_auth1) + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token = create_token(expiry_in_1_hour) + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=custom_token&state=67b95d2c7555751d1d72c97c7cd9ad6630c8395e0eaa51ee86ac7e451211ded9cd98a7190848789fe93632d8960425710e93f1f5549c6c6bc328bf3865a85ff2&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%271%27%5D", + reply_url="http://localhost:5000", + data=f"custom_token={token}&state=67b95d2c7555751d1d72c97c7cd9ad6630c8395e0eaa51ee86ac7e451211ded9cd98a7190848789fe93632d8960425710e93f1f5549c6c6bc328bf3865a85ff2", + ) + assert get_header(responses, auth1).get("Authorization") == f"Bearer {token}" auth2 = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST - + "/provide_token_as_custom_token?response_type=custom_token&nonce=2", - timeout=TIMEOUT, + "http://provide_token?response_type=custom_token&nonce=2", token_field_name="custom_token", ) response = requests.get("http://authorized_only", auth=auth2) # Return headers received on this dummy URL - token_on_auth2 = response.request.headers.get("Authorization") - assert re.match("^Bearer .*", token_on_auth2) - - assert token_on_auth1 == token_on_auth2 + assert response.request.headers.get("Authorization") == f"Bearer {token}" + tab.assert_success( + "You are now authenticated on 67b95d2c7555751d1d72c97c7cd9ad6630c8395e0eaa51ee86ac7e451211ded9cd98a7190848789fe93632d8960425710e93f1f5549c6c6bc328bf3865a85ff2. You may close this tab." + ) def test_oauth2_implicit_flow_token_can_be_requested_on_a_custom_server_port( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): + # TODO Should use a method to retrieve a free port instead + available_port = 5002 auth = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/provide_token_as_access_token", - # TODO Should use a method to retrieve a free port instead - redirect_uri_port=5002, - timeout=TIMEOUT, + "http://provide_token", redirect_uri_port=available_port + ) + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token = create_token(expiry_in_1_hour) + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5002%2F", + reply_url="http://localhost:5002", + data=f"access_token={token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", + ) + assert get_header(responses, auth).get("Authorization") == f"Bearer {token}" + tab.assert_success( + "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." ) - assert re.match("^Bearer .*", get_header(responses, auth).get("Authorization")) def test_oauth2_implicit_flow_post_token_is_sent_in_authorization_header_by_default( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): - auth = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/provide_token_as_access_token", timeout=TIMEOUT + auth = requests_auth.OAuth2Implicit("http://provide_token") + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token = create_token(expiry_in_1_hour) + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000", + data=f"access_token={token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", + ) + assert get_header(responses, auth).get("Authorization") == f"Bearer {token}" + tab.assert_success( + "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." ) - assert re.match("^Bearer .*", get_header(responses, auth).get("Authorization")) -def test_browser_opening_failure( - authenticated_service, token_cache, responses: RequestsMock, monkeypatch -): +def test_browser_opening_failure(token_cache, responses: RequestsMock, monkeypatch): import requests_auth.oauth2_authentication_responses_server - auth = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/provide_token_as_access_token", timeout=TIMEOUT - ) + auth = requests_auth.OAuth2Implicit("http://provide_token", timeout=0.1) class FakeBrowser: def open(self, url, new): @@ -125,23 +148,20 @@ def open(self, url, new): responses.add( responses.GET, - "http://localhost:5001/provide_token_as_access_token?response_type=token&state=cff2b2458bda8efd4978b2896ca43c754655fb625dee68359621cd34bca9280ae83b5b854afd01e24094c1bdb15286dd765c7c172a00d7f983137ea6c8b97c04&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + "http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", ) with pytest.raises(requests_auth.TimeoutOccurred) as exception_info: requests.get("http://authorized_only", auth=auth) - assert str( - exception_info.value - ) == "User authentication was not received within {} seconds.".format(TIMEOUT) + assert ( + str(exception_info.value) + == "User authentication was not received within 0.1 seconds." + ) -def test_browser_error( - authenticated_service, token_cache, responses: RequestsMock, monkeypatch -): +def test_browser_error(token_cache, responses: RequestsMock, monkeypatch): import requests_auth.oauth2_authentication_responses_server - auth = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/provide_token_as_access_token", timeout=TIMEOUT - ) + auth = requests_auth.OAuth2Implicit("http://provide_token", timeout=0.1) class FakeBrowser: def open(self, url, new): @@ -157,235 +177,537 @@ def open(self, url, new): responses.add( responses.GET, - "http://localhost:5001/provide_token_as_access_token?response_type=token&state=cff2b2458bda8efd4978b2896ca43c754655fb625dee68359621cd34bca9280ae83b5b854afd01e24094c1bdb15286dd765c7c172a00d7f983137ea6c8b97c04&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + "http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", ) with pytest.raises(requests_auth.TimeoutOccurred) as exception_info: requests.get("http://authorized_only", auth=auth) - assert str( - exception_info.value - ) == "User authentication was not received within {} seconds.".format(TIMEOUT) + assert ( + str(exception_info.value) + == "User authentication was not received within 0.1 seconds." + ) -def test_state_change(authenticated_service, token_cache, responses: RequestsMock): - auth = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/provide_token_as_access_token_with_another_state", - timeout=TIMEOUT, +def test_state_change(token_cache, responses: RequestsMock, browser_mock: BrowserMock): + auth = requests_auth.OAuth2Implicit("http://provide_token") + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token = create_token(expiry_in_1_hour) + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000", + data=f"access_token={token}&state=123456", ) - assert re.match("^Bearer .*", get_header(responses, auth).get("Authorization")) + assert get_header(responses, auth).get("Authorization") == f"Bearer {token}" + tab.assert_success("You are now authenticated on 123456. You may close this tab.") -def test_empty_token_is_invalid( - authenticated_service, token_cache, responses: RequestsMock -): +def test_empty_token_is_invalid(token_cache, browser_mock: BrowserMock): + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000", + data=f"access_token=&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", + ) with pytest.raises(requests_auth.InvalidToken) as exception_info: requests.get( "http://authorized_only", - auth=requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/provide_empty_token_as_access_token", - timeout=TIMEOUT, - ), + auth=requests_auth.OAuth2Implicit("http://provide_token"), ) assert str(exception_info.value) == " is invalid." + tab.assert_success( + "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." + ) -def test_token_without_expiry_is_invalid( - authenticated_service, token_cache, responses: RequestsMock -): +def test_token_without_expiry_is_invalid(token_cache, browser_mock: BrowserMock): + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000", + data=f"access_token={create_token(None)}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", + ) with pytest.raises(requests_auth.TokenExpiryNotProvided) as exception_info: requests.get( "http://authorized_only", - auth=requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/provide_token_without_exp_as_access_token", - timeout=TIMEOUT, - ), + auth=requests_auth.OAuth2Implicit("http://provide_token"), ) assert str(exception_info.value) == "Expiry (exp) is not provided in None." + tab.assert_success( + "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." + ) def test_oauth2_implicit_flow_get_token_is_sent_in_authorization_header_by_default( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): - auth = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/provide_token_as_anchor_access_token", timeout=TIMEOUT + auth = requests_auth.OAuth2Implicit("http://provide_token") + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token = create_token(expiry_in_1_hour) + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url=f"http://localhost:5000#access_token={token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", + ) + assert get_header(responses, auth).get("Authorization") == f"Bearer {token}" + tab.assert_success( + "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." ) - assert re.match("^Bearer .*", get_header(responses, auth).get("Authorization")) def test_oauth2_implicit_flow_token_is_sent_in_requested_field( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): auth = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/provide_token_as_access_token", - timeout=TIMEOUT, - header_name="Bearer", - header_value="{token}", + "http://provide_token", header_name="Bearer", header_value="{token}" + ) + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token = create_token(expiry_in_1_hour) + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000", + data=f"access_token={token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", + ) + assert get_header(responses, auth).get("Bearer") == token + tab.assert_success( + "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." ) - assert get_header(responses, auth).get("Bearer") def test_oauth2_implicit_flow_can_send_a_custom_response_type_and_expects_token_to_be_received_with_this_name( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): auth = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/provide_token_as_custom_token", - timeout=TIMEOUT, + "http://provide_token", response_type="custom_token", token_field_name="custom_token", ) - assert re.match("^Bearer .*", get_header(responses, auth).get("Authorization")) + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token = create_token(expiry_in_1_hour) + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=custom_token&state=67b95d2c7555751d1d72c97c7cd9ad6630c8395e0eaa51ee86ac7e451211ded9cd98a7190848789fe93632d8960425710e93f1f5549c6c6bc328bf3865a85ff2&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000", + data=f"custom_token={token}&state=67b95d2c7555751d1d72c97c7cd9ad6630c8395e0eaa51ee86ac7e451211ded9cd98a7190848789fe93632d8960425710e93f1f5549c6c6bc328bf3865a85ff2", + ) + assert get_header(responses, auth).get("Authorization") == f"Bearer {token}" + tab.assert_success( + "You are now authenticated on 67b95d2c7555751d1d72c97c7cd9ad6630c8395e0eaa51ee86ac7e451211ded9cd98a7190848789fe93632d8960425710e93f1f5549c6c6bc328bf3865a85ff2. You may close this tab." + ) def test_oauth2_implicit_flow_expects_token_in_id_token_if_response_type_is_id_token( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): auth = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/provide_token_as_id_token", - timeout=TIMEOUT, - response_type="id_token", + "http://provide_token", response_type="id_token" + ) + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token = create_token(expiry_in_1_hour) + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=id_token&state=87c4108ec0eb03599335333a40434a36674269690b6957fef684bfb6c5a849ce660ef7031aa874c44d67cd3eada8febdfce41efb1ed3bc53a0a7e716cbba025a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000", + data=f"id_token={token}&state=87c4108ec0eb03599335333a40434a36674269690b6957fef684bfb6c5a849ce660ef7031aa874c44d67cd3eada8febdfce41efb1ed3bc53a0a7e716cbba025a", + ) + assert get_header(responses, auth).get("Authorization") == f"Bearer {token}" + tab.assert_success( + "You are now authenticated on 87c4108ec0eb03599335333a40434a36674269690b6957fef684bfb6c5a849ce660ef7031aa874c44d67cd3eada8febdfce41efb1ed3bc53a0a7e716cbba025a. You may close this tab." ) - assert re.match("^Bearer .*", get_header(responses, auth).get("Authorization")) def test_oauth2_implicit_flow_expects_token_in_id_token_if_response_type_in_url_is_id_token( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): - auth = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/provide_token_as_id_token?response_type=id_token", - timeout=TIMEOUT, + auth = requests_auth.OAuth2Implicit("http://provide_token?response_type=id_token") + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token = create_token(expiry_in_1_hour) + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=id_token&state=87c4108ec0eb03599335333a40434a36674269690b6957fef684bfb6c5a849ce660ef7031aa874c44d67cd3eada8febdfce41efb1ed3bc53a0a7e716cbba025a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000", + data=f"id_token={token}&state=87c4108ec0eb03599335333a40434a36674269690b6957fef684bfb6c5a849ce660ef7031aa874c44d67cd3eada8febdfce41efb1ed3bc53a0a7e716cbba025a", + ) + assert get_header(responses, auth).get("Authorization") == f"Bearer {token}" + tab.assert_success( + "You are now authenticated on 87c4108ec0eb03599335333a40434a36674269690b6957fef684bfb6c5a849ce660ef7031aa874c44d67cd3eada8febdfce41efb1ed3bc53a0a7e716cbba025a. You may close this tab." ) - assert re.match("^Bearer .*", get_header(responses, auth).get("Authorization")) def test_oauth2_implicit_flow_expects_token_to_be_stored_in_access_token_by_default( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): - auth = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/provide_token_as_access_token", timeout=TIMEOUT + auth = requests_auth.OAuth2Implicit("http://provide_token") + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token = create_token(expiry_in_1_hour) + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000", + data=f"access_token={token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", + ) + assert get_header(responses, auth).get("Authorization") == f"Bearer {token}" + tab.assert_success( + "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." ) - assert re.match("^Bearer .*", get_header(responses, auth).get("Authorization")) def test_oauth2_implicit_flow_token_is_reused_if_not_expired( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): - auth1 = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/provide_token_as_access_token", timeout=TIMEOUT + auth1 = requests_auth.OAuth2Implicit("http://provide_token") + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token = create_token(expiry_in_1_hour) + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000", + data=f"access_token={token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", ) - token1 = get_header(responses, auth1).get("Authorization") - assert re.match("^Bearer .*", token1) + assert get_header(responses, auth1).get("Authorization") == f"Bearer {token}" - oauth2 = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/provide_token_as_access_token", timeout=TIMEOUT - ) + oauth2 = requests_auth.OAuth2Implicit("http://provide_token") response = requests.get("http://authorized_only", auth=oauth2) # Return headers received on this dummy URL - token2 = response.request.headers.get("Authorization") - assert re.match("^Bearer .*", token2) - - # As the token should not be expired, this call should use the same token - assert token1 == token2 + assert response.request.headers.get("Authorization") == f"Bearer {token}" + tab.assert_success( + "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." + ) def test_oauth2_implicit_flow_post_failure_if_token_is_not_provided( - authenticated_service, token_cache + token_cache, browser_mock: BrowserMock ): + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000", + data="", + ) with pytest.raises(Exception) as exception_info: requests.get( "http://authorized_only", - auth=requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/do_not_provide_token", timeout=TIMEOUT - ), + auth=requests_auth.OAuth2Implicit("http://provide_token"), ) assert str(exception_info.value) == "access_token not provided within {}." + tab.assert_failure( + "Unable to properly perform authentication: access_token not provided within {}." + ) def test_oauth2_implicit_flow_get_failure_if_token_is_not_provided( - authenticated_service, token_cache + token_cache, browser_mock: BrowserMock ): + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000", + ) with pytest.raises(Exception) as exception_info: requests.get( "http://authorized_only", - auth=requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/do_not_provide_token_as_anchor_token", - timeout=TIMEOUT, - ), + auth=requests_auth.OAuth2Implicit("http://provide_token"), ) assert str(exception_info.value) == "access_token not provided within {}." + tab.assert_failure( + "Unable to properly perform authentication: access_token not provided within {}." + ) def test_oauth2_implicit_flow_post_failure_if_state_is_not_provided( - authenticated_service, token_cache + token_cache, browser_mock: BrowserMock ): + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token = create_token(expiry_in_1_hour) + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000", + data=f"access_token={token}", + ) with pytest.raises(requests_auth.StateNotProvided) as exception_info: requests.get( "http://authorized_only", - auth=requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST - + "/provide_token_as_access_token_but_without_providing_state", - timeout=TIMEOUT, - ), + auth=requests_auth.OAuth2Implicit("http://provide_token"), ) - assert re.match( - "state not provided within {'access_token': \['.*'\]}.", - str(exception_info.value), + assert ( + str(exception_info.value) + == f"state not provided within {{'access_token': ['{token}']}}." + ) + tab.assert_failure( + f"Unable to properly perform authentication: state not provided within {{'access_token': ['{token}']}}." ) def test_oauth2_implicit_flow_get_failure_if_state_is_not_provided( - authenticated_service, token_cache + token_cache, browser_mock: BrowserMock ): + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token = create_token(expiry_in_1_hour) + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url=f"http://localhost:5000#access_token={token}", + ) with pytest.raises(requests_auth.StateNotProvided) as exception_info: requests.get( "http://authorized_only", - auth=requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST - + "/provide_token_as_anchor_access_token_but_without_providing_state", - timeout=TIMEOUT, - ), + auth=requests_auth.OAuth2Implicit("http://provide_token"), ) - assert re.match( - "state not provided within {'access_token': \['.*'\]}.", - str(exception_info.value), + assert ( + str(exception_info.value) + == f"state not provided within {{'access_token': ['{token}'], 'requests_auth_redirect': ['1']}}." + ) + tab.assert_failure( + f"Unable to properly perform authentication: state not provided within {{'access_token': ['{token}'], 'requests_auth_redirect': ['1']}}." + ) + + +def test_with_invalid_token_request_invalid_request_error( + token_cache, browser_mock: BrowserMock +): + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=invalid_request", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get( + "http://authorized_only", + auth=requests_auth.OAuth2Implicit("http://provide_token"), + ) + assert ( + str(exception_info.value) + == "invalid_request: The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed." + ) + tab.assert_failure( + "Unable to properly perform authentication: invalid_request: The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed." + ) + + +def test_with_invalid_token_request_invalid_request_error_and_error_description( + token_cache, browser_mock: BrowserMock +): + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=invalid_request&error_description=desc", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get( + "http://authorized_only", + auth=requests_auth.OAuth2Implicit("http://provide_token"), + ) + assert str(exception_info.value) == "invalid_request: desc" + tab.assert_failure( + "Unable to properly perform authentication: invalid_request: desc" + ) + + +def test_with_invalid_token_request_invalid_request_error_and_error_description_and_uri( + token_cache, browser_mock: BrowserMock +): + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=invalid_request&error_description=desc&error_uri=http://test_url", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get( + "http://authorized_only", + auth=requests_auth.OAuth2Implicit("http://provide_token"), + ) + assert ( + str(exception_info.value) + == "invalid_request: desc\nMore information can be found on http://test_url" + ) + tab.assert_failure( + "Unable to properly perform authentication: invalid_request: desc\nMore information can be found on http://test_url" + ) + + +def test_with_invalid_token_request_invalid_request_error_and_error_description_and_uri_and_other_fields( + token_cache, browser_mock: BrowserMock +): + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=invalid_request&error_description=desc&error_uri=http://test_url&other=test", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get( + "http://authorized_only", + auth=requests_auth.OAuth2Implicit("http://provide_token"), + ) + assert ( + str(exception_info.value) + == "invalid_request: desc\nMore information can be found on http://test_url\nAdditional information: {'other': ['test']}" + ) + tab.assert_failure( + "Unable to properly perform authentication: invalid_request: desc\nMore information can be found on http://test_url\nAdditional information: {'other': ['test']}" + ) + + +def test_with_invalid_token_request_unauthorized_client_error( + token_cache, browser_mock: BrowserMock +): + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=unauthorized_client", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get( + "http://authorized_only", + auth=requests_auth.OAuth2Implicit("http://provide_token"), + ) + assert ( + str(exception_info.value) + == "unauthorized_client: The client is not authorized to request an authorization code or an access token using this method." + ) + tab.assert_failure( + "Unable to properly perform authentication: unauthorized_client: The client is not authorized to request an authorization code or an access token using this method." + ) + + +def test_with_invalid_token_request_access_denied_error( + token_cache, browser_mock: BrowserMock +): + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=access_denied", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get( + "http://authorized_only", + auth=requests_auth.OAuth2Implicit("http://provide_token"), + ) + assert ( + str(exception_info.value) + == "access_denied: The resource owner or authorization server denied the request." + ) + tab.assert_failure( + "Unable to properly perform authentication: access_denied: The resource owner or authorization server denied the request." + ) + + +def test_with_invalid_token_request_unsupported_response_type_error( + token_cache, browser_mock: BrowserMock +): + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=unsupported_response_type", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get( + "http://authorized_only", + auth=requests_auth.OAuth2Implicit("http://provide_token"), + ) + assert ( + str(exception_info.value) + == "unsupported_response_type: The authorization server does not support obtaining an authorization code or an access token using this method." + ) + tab.assert_failure( + "Unable to properly perform authentication: unsupported_response_type: The authorization server does not support obtaining an authorization code or an access token using this method." + ) + + +def test_with_invalid_token_request_invalid_scope_error( + token_cache, browser_mock: BrowserMock +): + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=invalid_scope", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get( + "http://authorized_only", + auth=requests_auth.OAuth2Implicit("http://provide_token"), + ) + assert ( + str(exception_info.value) + == "invalid_scope: The requested scope is invalid, unknown, or malformed." + ) + tab.assert_failure( + "Unable to properly perform authentication: invalid_scope: The requested scope is invalid, unknown, or malformed." + ) + + +def test_with_invalid_token_request_server_error_error( + token_cache, browser_mock: BrowserMock +): + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=server_error", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get( + "http://authorized_only", + auth=requests_auth.OAuth2Implicit("http://provide_token"), + ) + assert ( + str(exception_info.value) + == "server_error: The authorization server encountered an unexpected condition that prevented it from fulfilling the request. (This error code is needed because a 500 Internal Server Error HTTP status code cannot be returned to the client via an HTTP redirect.)" + ) + tab.assert_failure( + "Unable to properly perform authentication: server_error: The authorization server encountered an unexpected condition that prevented it from fulfilling the request. (This error code is needed because a 500 Internal Server Error HTTP status code cannot be returned to the client via an HTTP redirect.)" + ) + + +def test_with_invalid_token_request_temporarily_unavailable_error( + token_cache, browser_mock: BrowserMock +): + tab = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=temporarily_unavailable", + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get( + "http://authorized_only", + auth=requests_auth.OAuth2Implicit("http://provide_token"), + ) + assert ( + str(exception_info.value) + == "temporarily_unavailable: The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server. (This error code is needed because a 503 Service Unavailable HTTP status code cannot be returned to the client via an HTTP redirect.)" + ) + tab.assert_failure( + "Unable to properly perform authentication: temporarily_unavailable: The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server. (This error code is needed because a 503 Service Unavailable HTTP status code cannot be returned to the client via an HTTP redirect.)" ) def test_oauth2_implicit_flow_failure_if_token_is_not_received_within_the_timeout_interval( - authenticated_service, token_cache + token_cache, browser_mock: BrowserMock ): + browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + # Simulate no redirect + reply_url=None, + ) with pytest.raises(requests_auth.TimeoutOccurred) as exception_info: requests.get( "http://authorized_only", - auth=requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/do_not_redirect", timeout=TIMEOUT - ), + auth=requests_auth.OAuth2Implicit("http://provide_token", timeout=0.1), ) - assert str( - exception_info.value - ) == "User authentication was not received within {timeout} seconds.".format( - timeout=TIMEOUT + assert ( + str(exception_info.value) + == "User authentication was not received within 0.1 seconds." ) def test_oauth2_implicit_flow_token_is_requested_again_if_expired( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock, browser_mock: BrowserMock ): - # This token will expires in 1 seconds - auth1 = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/provide_a_token_expiring_in_1_second", timeout=TIMEOUT + auth = requests_auth.OAuth2Implicit("http://provide_token") + # This token will expires in 100 milliseconds + expiry_in_1_second = datetime.datetime.utcnow() + datetime.timedelta( + milliseconds=100 ) - token1 = get_header(responses, auth1).get("Authorization") - assert re.match("^Bearer .*", token1) + first_token = create_token(expiry_in_1_second) + tab1 = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000", + data=f"access_token={first_token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", + ) + assert get_header(responses, auth).get("Authorization") == f"Bearer {first_token}" - # Wait for 2 seconds to ensure that the token expiring in 1 seconds will be considered as expired - time.sleep(2) + # Wait to ensure that the token will be considered as expired + time.sleep(0.2) # Token should now be expired, a new one should be requested - auth2 = requests_auth.OAuth2Implicit( - TEST_SERVICE_HOST + "/provide_a_token_expiring_in_1_second", timeout=TIMEOUT + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + second_token = create_token(expiry_in_1_hour) + tab2 = browser_mock.add_response( + opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000", + data=f"access_token={second_token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", ) - response = requests.get("http://authorized_only", auth=auth2) + response = requests.get("http://authorized_only", auth=auth) # Return headers received on this dummy URL - token2 = response.request.headers.get("Authorization") - assert re.match("^Bearer .*", token2) - - assert token1 != token2 + assert response.request.headers.get("Authorization") == f"Bearer {second_token}" + tab1.assert_success( + "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." + ) + tab2.assert_success( + "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." + ) diff --git a/tests/test_oauth2_implicit_azure_active_directory.py b/tests/test_oauth2_implicit_azure_active_directory.py index b6987e4..73210c7 100644 --- a/tests/test_oauth2_implicit_azure_active_directory.py +++ b/tests/test_oauth2_implicit_azure_active_directory.py @@ -20,39 +20,10 @@ def test_corresponding_oauth2_implicit_flow_instance(monkeypatch): "&nonce=%5B%2727ddfeed4e-854b-4361-8e7a-eab371c9bc91%27%5D" ) assert ( - str(aad) == "OAuth2Implicit(" - "'https://login.microsoftonline.com/45239d18-c68c-4c47-8bdd-ce71ea1d50cd/oauth2/authorize', " - "client_id='54239d18-c68c-4c47-8bdd-ce71ea1d50cd', " - "nonce='27ddfeed4e-854b-4361-8e7a-eab371c9bc91')" - ) - - -def test_corresponding_oauth2_implicit_flow_instance_using_helper(monkeypatch): - monkeypatch.setattr( - requests_auth.authentication.uuid, - "uuid4", - lambda *args: "27ddfeed4e-854b-4361-8e7a-eab371c9bc91", - ) - aad = requests_auth.aad( - requests_auth.OAuth2Flow.Implicit, - "45239d18-c68c-4c47-8bdd-ce71ea1d50cd", - "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", - ) - assert ( - aad.grant_details.url - == "https://login.microsoftonline.com/45239d18-c68c-4c47-8bdd-ce71ea1d50cd/oauth2/authorize?" - "client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd" - "&response_type=token" - "&state=900fe3bb417d9c729361548bc6d3f83ad881e0b030ac27b2b563ee44ddf563c368612e8ee5b483f43667e897c96551388f6dfbdef83558ba2d6367d3b40d0496" - "&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F" - "&nonce=%5B%2727ddfeed4e-854b-4361-8e7a-eab371c9bc91%27%5D" - ) - assert ( - str(aad) == "OAuth2Implicit(" - "'https://login.microsoftonline.com/45239d18-c68c-4c47-8bdd-ce71ea1d50cd/oauth2/authorize', " - "client_id='54239d18-c68c-4c47-8bdd-ce71ea1d50cd', " - "nonce='27ddfeed4e-854b-4361-8e7a-eab371c9bc91')" + aad.authorization_url + == "https://login.microsoftonline.com/45239d18-c68c-4c47-8bdd-ce71ea1d50cd/oauth2/authorize" ) + assert aad.grant_details.name == "access_token" def test_corresponding_oauth2_implicit_flow_id_token_instance(monkeypatch): @@ -74,10 +45,7 @@ def test_corresponding_oauth2_implicit_flow_id_token_instance(monkeypatch): "&nonce=%5B%2727ddfeed4e-854b-4361-8e7a-eab371c9bc91%27%5D" ) assert ( - str(aad) == "OAuth2Implicit(" - "'https://login.microsoftonline.com/45239d18-c68c-4c47-8bdd-ce71ea1d50cd/oauth2/authorize', " - "client_id='54239d18-c68c-4c47-8bdd-ce71ea1d50cd', " - "response_type='id_token', " - "token_field_name='id_token', " - "nonce='27ddfeed4e-854b-4361-8e7a-eab371c9bc91')" + aad.authorization_url + == "https://login.microsoftonline.com/45239d18-c68c-4c47-8bdd-ce71ea1d50cd/oauth2/authorize" ) + assert aad.grant_details.name == "id_token" diff --git a/tests/test_oauth2_implicit_okta.py b/tests/test_oauth2_implicit_okta.py index aa20013..b6fdf55 100644 --- a/tests/test_oauth2_implicit_okta.py +++ b/tests/test_oauth2_implicit_okta.py @@ -21,42 +21,10 @@ def test_corresponding_oauth2_implicit_flow_instance(monkeypatch): "&nonce=%5B%2727ddfeed4e-854b-4361-8e7a-eab371c9bc91%27%5D" ) assert ( - str(okta) == "OAuth2Implicit(" - "'https://testserver.okta-emea.com/oauth2/default/v1/authorize', " - "client_id='54239d18-c68c-4c47-8bdd-ce71ea1d50cd', " - "nonce='27ddfeed4e-854b-4361-8e7a-eab371c9bc91', " - "scope='openid profile email')" - ) - - -def test_corresponding_oauth2_implicit_flow_instance_using_helper(monkeypatch): - monkeypatch.setattr( - requests_auth.authentication.uuid, - "uuid4", - lambda *args: "27ddfeed4e-854b-4361-8e7a-eab371c9bc91", - ) - okta = requests_auth.okta( - requests_auth.OAuth2Flow.Implicit, - "testserver.okta-emea.com", - "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", - ) - assert ( - okta.grant_details.url - == "https://testserver.okta-emea.com/oauth2/default/v1/authorize?" - "client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd" - "&scope=openid+profile+email" - "&response_type=token" - "&state=edef4c2a7e792f4ea6b33ae81a05d4a100aace3d21cdeba3066438d53e82fe867ca34b63b0f78623cc33d5631b2f8de086f63eb3a41d60b2e1b16f8bb697deae" - "&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F" - "&nonce=%5B%2727ddfeed4e-854b-4361-8e7a-eab371c9bc91%27%5D" - ) - assert ( - str(okta) == "OAuth2Implicit(" - "'https://testserver.okta-emea.com/oauth2/default/v1/authorize', " - "client_id='54239d18-c68c-4c47-8bdd-ce71ea1d50cd', " - "nonce='27ddfeed4e-854b-4361-8e7a-eab371c9bc91', " - "scope='openid profile email')" + okta.authorization_url + == "https://testserver.okta-emea.com/oauth2/default/v1/authorize" ) + assert okta.grant_details.name == "access_token" def test_corresponding_oauth2_implicit_flow_id_token_instance(monkeypatch): @@ -79,11 +47,7 @@ def test_corresponding_oauth2_implicit_flow_id_token_instance(monkeypatch): "&nonce=%5B%2727ddfeed4e-854b-4361-8e7a-eab371c9bc91%27%5D" ) assert ( - str(okta) == "OAuth2Implicit(" - "'https://testserver.okta-emea.com/oauth2/default/v1/authorize', " - "client_id='54239d18-c68c-4c47-8bdd-ce71ea1d50cd', " - "response_type='id_token', " - "token_field_name='id_token', " - "nonce='27ddfeed4e-854b-4361-8e7a-eab371c9bc91', " - "scope='openid profile email')" + okta.authorization_url + == "https://testserver.okta-emea.com/oauth2/default/v1/authorize" ) + assert okta.grant_details.name == "id_token" diff --git a/tests/test_oauth2_pkce.py b/tests/test_oauth2_pkce.py deleted file mode 100644 index be5c926..0000000 --- a/tests/test_oauth2_pkce.py +++ /dev/null @@ -1,125 +0,0 @@ -from responses import RequestsMock -import pytest - -import requests_auth -from tests.auth_helper import get_header, get_request -from tests.oauth2_helper import ( - authenticated_service, - token_cache, - TIMEOUT, - TEST_SERVICE_HOST, -) - - -def test_oauth2_pkce_flow_get_code_is_sent_in_authorization_header_by_default( - authenticated_service, token_cache, responses: RequestsMock, monkeypatch -): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) - auth = requests_auth.OAuth2AuthorizationCodePKCE( - TEST_SERVICE_HOST + "/provide_code_as_anchor_code", - "http://provide_access_token", - timeout=TIMEOUT, - ) - - 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" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" - ) - - -def test_nonce_is_sent_if_provided_in_authorization_url( - authenticated_service, token_cache, responses: RequestsMock, monkeypatch -): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) - auth = requests_auth.OAuth2AuthorizationCodePKCE( - TEST_SERVICE_HOST + "/provide_code_as_anchor_code?nonce=123456", - "http://provide_access_token", - timeout=TIMEOUT, - ) - - 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" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" - ) - - -def test_response_type_can_be_provided_in_url( - authenticated_service, token_cache, responses: RequestsMock, monkeypatch -): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) - auth = requests_auth.OAuth2AuthorizationCodePKCE( - TEST_SERVICE_HOST + "/provide_code_as_anchor_code?response_type=code", - "http://provide_access_token", - timeout=TIMEOUT, - response_type="not_used", - ) - - 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" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" - ) - - -def test_authorization_url_is_mandatory(): - with pytest.raises(Exception) as exception_info: - requests_auth.OAuth2AuthorizationCodePKCE("", "http://test_url") - assert str(exception_info.value) == "Authorization URL is mandatory." - - -def test_token_url_is_mandatory(): - with pytest.raises(Exception) as exception_info: - requests_auth.OAuth2AuthorizationCodePKCE("http://test_url", "") - assert str(exception_info.value) == "Token URL is mandatory." - - -def test_header_value_must_contains_token(): - with pytest.raises(Exception) as exception_info: - requests_auth.OAuth2AuthorizationCodePKCE( - "http://test_url", "http://test_url", header_value="Bearer token" - ) - assert str(exception_info.value) == "header_value parameter must contains {token}." diff --git a/tests/test_oauth2_resource_owner_password.py b/tests/test_oauth2_resource_owner_password.py index 6604b87..86d6b0f 100644 --- a/tests/test_oauth2_resource_owner_password.py +++ b/tests/test_oauth2_resource_owner_password.py @@ -3,18 +3,15 @@ import requests import requests_auth -from tests.oauth2_helper import authenticated_service, token_cache, TIMEOUT +from tests.oauth2_helper import token_cache from tests.auth_helper import get_header, get_request def test_oauth2_password_credentials_flow_token_is_sent_in_authorization_header_by_default( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock ): auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( - "http://provide_access_token", - username="test_user", - password="test_pwd", - timeout=TIMEOUT, + "http://provide_access_token", username="test_user", password="test_pwd" ) responses.add( responses.POST, @@ -37,14 +34,11 @@ def test_oauth2_password_credentials_flow_token_is_sent_in_authorization_header_ ) -def test_scope_is_sent_as_is_when_provided_as_str( - authenticated_service, token_cache, responses: RequestsMock -): +def test_scope_is_sent_as_is_when_provided_as_str(token_cache, responses: RequestsMock): auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd", - timeout=TIMEOUT, scope="my_scope+my_other_scope", ) responses.add( @@ -69,13 +63,12 @@ def test_scope_is_sent_as_is_when_provided_as_str( def test_scope_is_sent_as_str_when_provided_as_list( - authenticated_service, token_cache, responses: RequestsMock + token_cache, responses: RequestsMock ): auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd", - timeout=TIMEOUT, scope=["my_scope", "my_other_scope"], ) responses.add( @@ -99,15 +92,241 @@ def test_scope_is_sent_as_str_when_provided_as_list( ) -def test_without_expected_token( - authenticated_service, token_cache, responses: RequestsMock +def test_with_invalid_grant_request_no_json(token_cache, responses: RequestsMock): + auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + responses.add( + responses.POST, "http://provide_access_token", body="failure", status=400 + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "failure" + + +def test_with_invalid_grant_request_invalid_request_error( + token_cache, responses: RequestsMock ): + auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "invalid_request"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_request: The request is missing a required parameter, includes an " + "unsupported parameter value (other than grant type), repeats a parameter, " + "includes multiple credentials, utilizes more than one mechanism for " + "authenticating the client, or is otherwise malformed." + ) + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description( + token_cache, responses: RequestsMock +): + auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "invalid_request", "error_description": "desc of the error"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "invalid_request: desc of the error" + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri( + token_cache, responses: RequestsMock +): + auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + }, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == f"invalid_request: desc of the error\nMore information can be found on http://test_url" + ) + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri_and_other_fields( + token_cache, responses: RequestsMock +): + auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + "other": "other info", + }, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == f"invalid_request: desc of the error\nMore information can be found on http://test_url\nAdditional information: {{'other': 'other info'}}" + ) + + +def test_with_invalid_grant_request_without_error(token_cache, responses: RequestsMock): + auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"other": "other info"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "{'other': 'other info'}" + + +def test_with_invalid_grant_request_invalid_client_error( + token_cache, responses: RequestsMock +): + auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "invalid_client"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_client: Client authentication failed (e.g., unknown client, no " + "client authentication included, or unsupported authentication method). The " + "authorization server MAY return an HTTP 401 (Unauthorized) status code to " + "indicate which HTTP authentication schemes are supported. If the client " + 'attempted to authenticate via the "Authorization" request header field, the ' + "authorization server MUST respond with an HTTP 401 (Unauthorized) status " + 'code and include the "WWW-Authenticate" response header field matching the ' + "authentication scheme used by the client." + ) + + +def test_with_invalid_grant_request_invalid_grant_error( + token_cache, responses: RequestsMock +): + auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "invalid_grant"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_grant: The provided authorization grant (e.g., authorization code, " + "resource owner credentials) or refresh token is invalid, expired, revoked, " + "does not match the redirection URI used in the authorization request, or was " + "issued to another client." + ) + + +def test_with_invalid_grant_request_unauthorized_client_error( + token_cache, responses: RequestsMock +): + auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "unauthorized_client"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "unauthorized_client: The authenticated client is not authorized to use this " + "authorization grant type." + ) + + +def test_with_invalid_grant_request_unsupported_grant_type_error( + token_cache, responses: RequestsMock +): + auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "unsupported_grant_type"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "unsupported_grant_type: The authorization grant type is not supported by the " + "authorization server." + ) + + +def test_with_invalid_grant_request_invalid_scope_error( + token_cache, responses: RequestsMock +): + auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + responses.add( + responses.POST, + "http://provide_access_token", + json={"error": "invalid_scope"}, + status=400, + ) + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_scope: The requested scope is invalid, unknown, malformed, or " + "exceeds the scope granted by the resource owner." + ) + + +def test_without_expected_token(token_cache, responses: RequestsMock): auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd", token_field_name="not_provided", - timeout=TIMEOUT, ) responses.add( responses.POST,