From 8070a43b4f00101f7ec1227b25a7ff19f94822d1 Mon Sep 17 00:00:00 2001 From: Colin-b Date: Mon, 10 Feb 2020 19:25:06 +0100 Subject: [PATCH] Port requests_auth to httpx (without NTLM support) --- CHANGELOG.md | 7 +- README.md | 715 ++++++++++- httpx_auth/__init__.py | 31 +- httpx_auth/authentication.py | 1121 +++++++++++++++++ httpx_auth/errors.py | 130 ++ .../oauth2_authentication_responses_server.py | 225 ++++ httpx_auth/oauth2_tokens.py | 188 +++ httpx_auth/testing.py | 136 ++ httpx_auth/version.py | 2 +- setup.py | 4 + tests/auth_helper.py | 13 + tests/test_add_operator.py | 388 ++++++ tests/test_and_operator.py | 388 ++++++ tests/test_api_key.py | 45 + tests/test_auths.py | 16 + tests/test_basic.py | 12 + tests/test_json_token_file_cache.py | 78 ++ tests/test_oauth2_authorization_code.py | 673 ++++++++++ tests/test_oauth2_authorization_code_okta.py | 600 +++++++++ tests/test_oauth2_authorization_code_pkce.py | 698 ++++++++++ ...est_oauth2_authorization_code_pkce_okta.py | 622 +++++++++ tests/test_oauth2_client_credential.py | 309 +++++ tests/test_oauth2_client_credential_okta.py | 49 + tests/test_oauth2_implicit.py | 713 +++++++++++ ..._oauth2_implicit_azure_active_directory.py | 26 + ...mplicit_id_token_azure_active_directory.py | 26 + tests/test_oauth2_implicit_id_token_okta.py | 27 + tests/test_oauth2_implicit_okta.py | 27 + tests/test_oauth2_resource_owner_password.py | 395 ++++++ tests/test_temporary.py | 5 - .../test_testing_oauth2_authorization_code.py | 51 + tests/test_testing_oauth2_implicit.py | 59 + tests/test_testing_token_mock.py | 13 + 33 files changed, 7783 insertions(+), 9 deletions(-) create mode 100644 httpx_auth/authentication.py create mode 100644 httpx_auth/errors.py create mode 100644 httpx_auth/oauth2_authentication_responses_server.py create mode 100644 httpx_auth/oauth2_tokens.py create mode 100644 httpx_auth/testing.py create mode 100644 tests/auth_helper.py create mode 100644 tests/test_add_operator.py create mode 100644 tests/test_and_operator.py create mode 100644 tests/test_api_key.py create mode 100644 tests/test_auths.py create mode 100644 tests/test_basic.py create mode 100644 tests/test_json_token_file_cache.py create mode 100644 tests/test_oauth2_authorization_code.py create mode 100644 tests/test_oauth2_authorization_code_okta.py create mode 100644 tests/test_oauth2_authorization_code_pkce.py create mode 100644 tests/test_oauth2_authorization_code_pkce_okta.py create mode 100644 tests/test_oauth2_client_credential.py create mode 100644 tests/test_oauth2_client_credential_okta.py create mode 100644 tests/test_oauth2_implicit.py create mode 100644 tests/test_oauth2_implicit_azure_active_directory.py create mode 100644 tests/test_oauth2_implicit_id_token_azure_active_directory.py create mode 100644 tests/test_oauth2_implicit_id_token_okta.py create mode 100644 tests/test_oauth2_implicit_okta.py create mode 100644 tests/test_oauth2_resource_owner_password.py delete mode 100644 tests/test_temporary.py create mode 100644 tests/test_testing_oauth2_authorization_code.py create mode 100644 tests/test_testing_oauth2_implicit.py create mode 100644 tests/test_testing_token_mock.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a45add..4936e5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.2] - 2020-02-10 +### Added +- Port of requests_auth 5.0.2 for httpx + ## [0.0.1] - 2020-02-04 ### Added - Placeholder for port of requests_auth to httpx -[Unreleased]: https://github.com/Colin-b/httpx_auth/compare/v0.0.1...HEAD +[Unreleased]: https://github.com/Colin-b/httpx_auth/compare/v0.0.2...HEAD +[0.0.2]: https://github.com/Colin-b/httpx_auth/compare/v0.0.1...v0.0.2 [0.0.1]: https://github.com/Colin-b/httpx_auth/releases/tag/v0.0.1 diff --git a/README.md b/README.md index d9306c2..2ff6a4f 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,724 @@ Build status Coverage Code style: black -Number of tests +Number of tests Number of downloads

Provides authentication classes to be used with [`httpx`][1] [authentication parameter][2]. +

+ OAuth2 + Okta + Azure Active Directory (AD) +

+

Some of the supported authentication

+ +## Available authentication + +- [OAuth2](#oauth-2) + - [Authorization Code Flow](#authorization-code-flow) + - [Okta](#okta-oauth2-authorization-code) + - [Authorization Code Flow with PKCE](#authorization-code-flow-with-proof-key-for-code-exchange) + - [Okta](#okta-oauth2-proof-key-for-code-exchange) + - [Resource Owner Password Credentials flow](#resource-owner-password-credentials-flow) + - [Client Credentials Flow](#client-credentials-flow) + - [Okta](#okta-oauth2-client-credentials) + - [Implicit Flow](#implicit-flow) + - [Azure AD (Access Token)](#microsoft---azure-active-directory-oauth2-access-token) + - [Azure AD (ID token)](#microsoft---azure-active-directory-openid-connect-id-token) + - [Okta (Access Token)](#okta-oauth2-implicit-access-token) + - [Okta (ID token)](#okta-openid-connect-implicit-id-token) + - [Managing token cache](#managing-token-cache) +- API key + - [In header](#api-key-in-header) + - [In query](#api-key-in-query) +- [Basic](#basic) +- [Multiple authentication at once](#multiple-authentication-at-once) + +## OAuth 2 + +Most of [OAuth2](https://oauth.net/2/) flows are supported. + +If the one you are looking for is not yet supported, feel free to [ask for its implementation](https://github.com/Colin-b/httpx_auth/issues/new). + +### Authorization Code flow + +Authorization Code Grant is implemented following [rfc6749](https://tools.ietf.org/html/rfc6749#section-4.1). + +Use `httpx_auth.OAuth2AuthorizationCode` to configure this kind of authentication. + +```python +import httpx +from httpx_auth import OAuth2AuthorizationCode + +with httpx.Client() as client: + client.get('http://www.example.com', auth=OAuth2AuthorizationCode('https://www.authorization.url', 'https://www.token.url')) +``` + +#### Parameters + +| Name | Description | Mandatory | Default value | +|:------------------------|:---------------------------|:----------|:--------------| +| `authorization_url` | OAuth 2 authorization URL. | Mandatory | | +| `token_url` | OAuth 2 token URL. | Mandatory | | +| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://localhost:/. | Optional | '' | +| `redirect_uri_port` | The port on which the server listening for the OAuth 2 code will be started. | Optional | 5000 | +| `timeout` | Maximum amount of seconds to wait for a code or a token to be received once requested. | Optional | 60 | +| `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. | Optional | 1 | +| `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. | Optional | 5000 | +| `header_name` | Name of the header field used to send token. | Optional | Authorization | +| `header_value` | Format used to send the token value. "{token}" must be present as it will be replaced by the actual token. | Optional | Bearer {token} | +| `response_type` | Value of the response_type query parameter if not already provided in authorization URL. | Optional | code | +| `token_field_name` | Field name containing the token. | Optional | access_token | +| `code_field_name` | Field name containing the code. | Optional | code | +| `username` | User name in case basic authentication should be used to retrieve token. | Optional | | +| `password` | User password in case basic authentication should be used to retrieve token. | Optional | | + +Any other parameter will be put as query parameter in the authorization URL and as body parameters in the token URL. + +Usual extra parameters are: + +| Name | Description | +|:----------------|:---------------------------------------------------------------------| +| `client_id` | Corresponding to your Application ID (in Microsoft Azure app portal) | +| `client_secret` | If client is not authenticated with the authorization server | +| `nonce` | Refer to [OpenID ID Token specifications][3] for more details | + +#### Common providers + +Most of [OAuth2](https://oauth.net/2/) Authorization Code Grant providers are supported. + +If the one you are looking for is not yet supported, feel free to [ask for its implementation](https://github.com/Colin-b/httpx_auth/issues/new). + +##### Okta (OAuth2 Authorization Code) + +[Okta Authorization Code Grant](https://developer.okta.com/docs/guides/implement-auth-code/overview/) providing access tokens is supported. + +Use `httpx_auth.OktaAuthorizationCode` to configure this kind of authentication. + +```python +import httpx +from httpx_auth import OktaAuthorizationCode + + +okta = OktaAuthorizationCode(instance='testserver.okta-emea.com', client_id='54239d18-c68c-4c47-8bdd-ce71ea1d50cd') +with httpx.Client() as client: + client.get('http://www.example.com', auth=okta) +``` + +###### Parameters + +| Name | Description | Mandatory | Default value | +|:------------------------|:---------------------------|:----------|:--------------| +| `instance` | Okta instance (like "testserver.okta-emea.com"). | Mandatory | | +| `client_id` | Okta Application Identifier (formatted as an Universal Unique Identifier). | Mandatory | | +| `response_type` | Value of the response_type query parameter if not already provided in authorization URL. | Optional | token | +| `token_field_name` | Field name containing the token. | Optional | access_token | +| `nonce` | Refer to [OpenID ID Token specifications][3] for more details. | Optional | Newly generated Universal Unique Identifier. | +| `scope` | Scope parameter sent in query. Can also be a list of scopes. | Optional | openid | +| `authorization_server` | Okta authorization server. | Optional | 'default' | +| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://localhost:/. | Optional | '' | +| `redirect_uri_port` | The port on which the server listening for the OAuth 2 token will be started. | Optional | 5000 | +| `timeout` | Maximum amount of seconds to wait for a token to be received once requested. | Optional | 60 | +| `success_display_time` | In case a token is successfully received, this is the maximum amount of milliseconds the success page will be displayed in your browser. | Optional | 1 | +| `failure_display_time` | In case received token is not valid, this is the maximum amount of milliseconds the failure page will be displayed in your browser. | Optional | 5000 | +| `header_name` | Name of the header field used to send token. | Optional | Authorization | +| `header_value` | Format used to send the token value. "{token}" must be present as it will be replaced by the actual token. | Optional | Bearer {token} | + +Any other parameter will be put as query parameter in the authorization URL. + +Usual extra parameters are: + +| Name | Description | +|:----------------|:---------------------------------------------------------------------| +| `prompt` | none to avoid prompting the user if a session is already opened. | + +### Authorization Code Flow with Proof Key for Code Exchange + +Proof Key for Code Exchange is implemented following [rfc7636](https://tools.ietf.org/html/rfc7636). + +Use `httpx_auth.OAuth2AuthorizationCodePKCE` to configure this kind of authentication. + +```python +import httpx +from httpx_auth import OAuth2AuthorizationCodePKCE + +with httpx.Client() as client: + client.get('http://www.example.com', auth=OAuth2AuthorizationCodePKCE('https://www.authorization.url', 'https://www.token.url')) +``` + +#### Parameters + +| Name | Description | Mandatory | Default value | +|:------------------------|:---------------------------|:----------|:--------------| +| `authorization_url` | OAuth 2 authorization URL. | Mandatory | | +| `token_url` | OAuth 2 token URL. | Mandatory | | +| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://localhost:/. | Optional | '' | +| `redirect_uri_port` | The port on which the server listening for the OAuth 2 code will be started. | Optional | 5000 | +| `timeout` | Maximum amount of seconds to wait for a code or a token to be received once requested. | Optional | 60 | +| `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. | Optional | 1 | +| `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. | Optional | 5000 | +| `header_name` | Name of the header field used to send token. | Optional | Authorization | +| `header_value` | Format used to send the token value. "{token}" must be present as it will be replaced by the actual token. | Optional | Bearer {token} | +| `response_type` | Value of the response_type query parameter if not already provided in authorization URL. | Optional | code | +| `token_field_name` | Field name containing the token. | Optional | access_token | +| `code_field_name` | Field name containing the code. | Optional | code | + +Any other parameter will be put as query parameter in the authorization URL and as body parameters in the token URL. + +Usual extra parameters are: + +| Name | Description | +|:----------------|:---------------------------------------------------------------------| +| `client_id` | Corresponding to your Application ID (in Microsoft Azure app portal) | +| `client_secret` | If client is not authenticated with the authorization server | +| `nonce` | Refer to [OpenID ID Token specifications][3] for more details | + +#### Common providers + +Most of [OAuth2](https://oauth.net/2/) Proof Key for Code Exchange providers are supported. + +If the one you are looking for is not yet supported, feel free to [ask for its implementation](https://github.com/Colin-b/httpx_auth/issues/new). + +##### Okta (OAuth2 Proof Key for Code Exchange) + +[Okta Proof Key for Code Exchange](https://developer.okta.com/docs/guides/implement-auth-code-pkce/overview/) providing access tokens is supported. + +Use `httpx_auth.OktaAuthorizationCodePKCE` to configure this kind of authentication. + +```python +import httpx +from httpx_auth import OktaAuthorizationCodePKCE + + +okta = OktaAuthorizationCodePKCE(instance='testserver.okta-emea.com', client_id='54239d18-c68c-4c47-8bdd-ce71ea1d50cd') +with httpx.Client() as client: + client.get('http://www.example.com', auth=okta) +``` + +###### Parameters + +| Name | Description | Mandatory | Default value | +|:------------------------|:---------------------------|:----------|:--------------| +| `instance` | Okta instance (like "testserver.okta-emea.com"). | Mandatory | | +| `client_id` | Okta Application Identifier (formatted as an Universal Unique Identifier). | Mandatory | | +| `response_type` | Value of the response_type query parameter if not already provided in authorization URL. | Optional | code | +| `token_field_name` | Field name containing the token. | Optional | access_token | +| `code_field_name` | Field name containing the code. | Optional | code | +| `nonce` | Refer to [OpenID ID Token specifications][3] for more details. | Optional | Newly generated Universal Unique Identifier. | +| `scope` | Scope parameter sent in query. Can also be a list of scopes. | Optional | openid | +| `authorization_server` | Okta authorization server. | Optional | 'default' | +| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://localhost:/. | Optional | '' | +| `redirect_uri_port` | The port on which the server listening for the OAuth 2 token will be started. | Optional | 5000 | +| `timeout` | Maximum amount of seconds to wait for a token to be received once requested. | Optional | 60 | +| `success_display_time` | In case a token is successfully received, this is the maximum amount of milliseconds the success page will be displayed in your browser. | Optional | 1 | +| `failure_display_time` | In case received token is not valid, this is the maximum amount of milliseconds the failure page will be displayed in your browser. | Optional | 5000 | +| `header_name` | Name of the header field used to send token. | Optional | Authorization | +| `header_value` | Format used to send the token value. "{token}" must be present as it will be replaced by the actual token. | Optional | Bearer {token} | + +Any other parameter will be put as query parameter in the authorization URL and as body parameters in the token URL. + +Usual extra parameters are: + +| Name | Description | +|:----------------|:---------------------------------------------------------------------| +| `client_secret` | If client is not authenticated with the authorization server | +| `nonce` | Refer to http://openid.net/specs/openid-connect-core-1_0.html#IDToken for more details | + +### Resource Owner Password Credentials flow + +Resource Owner Password Credentials Grant is implemented following [rfc6749](https://tools.ietf.org/html/rfc6749#section-4.3). + +Use `httpx_auth.OAuth2ResourceOwnerPasswordCredentials` to configure this kind of authentication. + +```python +import httpx +from httpx_auth import OAuth2ResourceOwnerPasswordCredentials + +with httpx.Client() as client: + client.get('http://www.example.com', auth=OAuth2ResourceOwnerPasswordCredentials('https://www.token.url', 'user name', 'user password')) +``` + +#### Parameters + +| Name | Description | Mandatory | Default value | +|:-------------------|:---------------------------------------------|:----------|:--------------| +| `token_url` | OAuth 2 token URL. | Mandatory | | +| `username` | Resource owner user name. | Mandatory | | +| `password` | 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} | +| `scope` | Scope parameter sent to token URL as body. Can also be a list of scopes. | Optional | | +| `token_field_name` | Field name containing the token. | Optional | access_token | + +Any other parameter will be put as body parameter in the token URL. + +### Client Credentials flow + +Client Credentials Grant is implemented following [rfc6749](https://tools.ietf.org/html/rfc6749#section-4.4). + +Use `httpx_auth.OAuth2ClientCredentials` to configure this kind of authentication. + +```python +import httpx +from httpx_auth import OAuth2ClientCredentials + +with httpx.Client() as client: + client.get('http://www.example.com', auth=OAuth2ClientCredentials('https://www.token.url', client_id='id', client_secret='secret')) +``` + +#### Parameters + +| Name | Description | Mandatory | Default value | +|:-------------------|:---------------------------------------------|:----------|:--------------| +| `token_url` | OAuth 2 token URL. | 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} | +| `scope` | Scope parameter sent to token URL as body. Can also be a list of scopes. | Optional | | +| `token_field_name` | Field name containing the token. | Optional | access_token | + +Any other parameter will be put as body parameter in the token URL. + +#### Common providers + +Most of [OAuth2](https://oauth.net/2/) Client Credentials Grant providers are supported. + +If the one you are looking for is not yet supported, feel free to [ask for its implementation](https://github.com/Colin-b/httpx_auth/issues/new). + +##### Okta (OAuth2 Client Credentials) + +[Okta Client Credentials Grant](https://developer.okta.com/docs/guides/implement-client-creds/overview/) providing access tokens is supported. + +Use `httpx_auth.OktaClientCredentials` to configure this kind of authentication. + +```python +import httpx +from httpx_auth import OktaClientCredentials + + +okta = OktaClientCredentials(instance='testserver.okta-emea.com', client_id='54239d18-c68c-4c47-8bdd-ce71ea1d50cd', client_secret="secret") +with httpx.Client() as client: + client.get('http://www.example.com', auth=okta) +``` + +###### Parameters + +| Name | Description | Mandatory | Default value | +|:------------------------|:---------------------------|:----------|:--------------| +| `instance` | Okta instance (like "testserver.okta-emea.com"). | Mandatory | | +| `client_id` | Okta Application Identifier (formatted as an Universal Unique Identifier). | Mandatory | | +| `client_secret` | Resource owner password. | Mandatory | | +| `authorization_server` | Okta authorization server. | Optional | 'default' | +| `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} | +| `scope` | Scope parameter sent in query. Can also be a list of scopes. | Optional | openid | +| `token_field_name` | Field name containing the token. | Optional | access_token | + +Any other parameter will be put as query parameter in the token URL. + +### Implicit flow + +Implicit Grant is implemented following [rfc6749](https://tools.ietf.org/html/rfc6749#section-4.2). + +Use `httpx_auth.OAuth2Implicit` to configure this kind of authentication. + +```python +import httpx +from httpx_auth import OAuth2Implicit + +with httpx.Client() as client: + client.get('http://www.example.com', auth=OAuth2Implicit('https://www.authorization.url')) +``` + +#### Parameters + +| Name | Description | Mandatory | Default value | +|:------------------------|:---------------------------|:----------|:--------------| +| `authorization_url` | OAuth 2 authorization URL. | Mandatory | | +| `response_type` | Value of the response_type query parameter if not already provided in authorization URL. | Optional | token | +| `token_field_name` | Field name containing the token. | Optional | id_token if response_type is id_token, otherwise access_token | +| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://localhost:/. | Optional | '' | +| `redirect_uri_port` | The port on which the server listening for the OAuth 2 token will be started. | Optional | 5000 | +| `timeout` | Maximum amount of seconds to wait for a token to be received once requested. | Optional | 60 | +| `success_display_time` | In case a token is successfully received, this is the maximum amount of milliseconds the success page will be displayed in your browser. | Optional | 1 | +| `failure_display_time` | In case received token is not valid, this is the maximum amount of milliseconds the failure page will be displayed in your browser. | Optional | 5000 | +| `header_name` | Name of the header field used to send token. | Optional | Authorization | +| `header_value` | Format used to send the token value. "{token}" must be present as it will be replaced by the actual token. | Optional | Bearer {token} | + +Any other parameter will be put as query parameter in the authorization URL. + +Usual extra parameters are: + +| Name | Description | +|:----------------|:---------------------------------------------------------------------| +| `client_id` | Corresponding to your Application ID (in Microsoft Azure app portal) | +| `nonce` | Refer to [OpenID ID Token specifications][3] for more details | +| `prompt` | none to avoid prompting the user if a session is already opened. | + +#### Common providers + +Most of [OAuth2](https://oauth.net/2/) Implicit Grant providers are supported. + +If the one you are looking for is not yet supported, feel free to [ask for its implementation](https://github.com/Colin-b/httpx_auth/issues/new). + +##### Microsoft - Azure Active Directory (OAuth2 Access Token) + +[Microsoft identity platform access tokens](https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens) are supported. + +Use `httpx_auth.AzureActiveDirectoryImplicit` to configure this kind of authentication. + +```python +import httpx +from httpx_auth import AzureActiveDirectoryImplicit + + +aad = AzureActiveDirectoryImplicit(tenant_id='45239d18-c68c-4c47-8bdd-ce71ea1d50cd', client_id='54239d18-c68c-4c47-8bdd-ce71ea1d50cd') +with httpx.Client() as client: + client.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 | +|:------------------------|:---------------------------|:----------|:--------------| +| `tenant_id` | Microsoft Tenant Identifier (formatted as an Universal Unique Identifier). | Mandatory | | +| `client_id` | Microsoft Application Identifier (formatted as an Universal Unique Identifier). | Mandatory | | +| `response_type` | Value of the response_type query parameter if not already provided in authorization URL. | Optional | token | +| `token_field_name` | Field name containing the token. | Optional | access_token | +| `nonce` | Refer to [OpenID ID Token specifications][3] for more details | Optional | Newly generated Universal Unique Identifier. | +| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://localhost:/. | Optional | '' | +| `redirect_uri_port` | The port on which the server listening for the OAuth 2 token will be started. | Optional | 5000 | +| `timeout` | Maximum amount of seconds to wait for a token to be received once requested. | Optional | 60 | +| `success_display_time` | In case a token is successfully received, this is the maximum amount of milliseconds the success page will be displayed in your browser. | Optional | 1 | +| `failure_display_time` | In case received token is not valid, this is the maximum amount of milliseconds the failure page will be displayed in your browser. | Optional | 5000 | +| `header_name` | Name of the header field used to send token. | Optional | Authorization | +| `header_value` | Format used to send the token value. "{token}" must be present as it will be replaced by the actual token. | Optional | Bearer {token} | + +Any other parameter will be put as query parameter in the authorization URL. + +Usual extra parameters are: + +| Name | Description | +|:----------------|:---------------------------------------------------------------------| +| `prompt` | none to avoid prompting the user if a session is already opened. | + +##### Microsoft - Azure Active Directory (OpenID Connect ID token) + +[Microsoft identity platform ID tokens](https://docs.microsoft.com/en-us/azure/active-directory/develop/id-tokens) are supported. + +Use `httpx_auth.AzureActiveDirectoryImplicitIdToken` to configure this kind of authentication. + +```python +import httpx +from httpx_auth import AzureActiveDirectoryImplicitIdToken + + +aad = AzureActiveDirectoryImplicitIdToken(tenant_id='45239d18-c68c-4c47-8bdd-ce71ea1d50cd', client_id='54239d18-c68c-4c47-8bdd-ce71ea1d50cd') +with httpx.Client() as client: + client.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 | +|:------------------------|:---------------------------|:----------|:--------------| +| `tenant_id` | Microsoft Tenant Identifier (formatted as an Universal Unique Identifier). | Mandatory | | +| `client_id` | Microsoft Application Identifier (formatted as an Universal Unique Identifier). | Mandatory | | +| `response_type` | Value of the response_type query parameter if not already provided in authorization URL. | Optional | id_token | +| `token_field_name` | Field name containing the token. | Optional | id_token | +| `nonce` | Refer to [OpenID ID Token specifications][3] for more details | Optional | Newly generated Universal Unique Identifier. | +| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://localhost:/. | Optional | '' | +| `redirect_uri_port` | The port on which the server listening for the OAuth 2 token will be started. | Optional | 5000 | +| `timeout` | Maximum amount of seconds to wait for a token to be received once requested. | Optional | 60 | +| `success_display_time` | In case a token is successfully received, this is the maximum amount of milliseconds the success page will be displayed in your browser. | Optional | 1 | +| `failure_display_time` | In case received token is not valid, this is the maximum amount of milliseconds the failure page will be displayed in your browser. | Optional | 5000 | +| `header_name` | Name of the header field used to send token. | Optional | Authorization | +| `header_value` | Format used to send the token value. "{token}" must be present as it will be replaced by the actual token. | Optional | Bearer {token} | + +Any other parameter will be put as query parameter in the authorization URL. + +Usual extra parameters are: + +| Name | Description | +|:----------------|:---------------------------------------------------------------------| +| `prompt` | none to avoid prompting the user if a session is already opened. | + +##### Okta (OAuth2 Implicit Access Token) + +[Okta Implicit Grant](https://developer.okta.com/docs/guides/implement-implicit/overview/) providing access tokens is supported. + +Use `httpx_auth.OktaImplicit` to configure this kind of authentication. + +```python +import httpx +from httpx_auth import OktaImplicit + + +okta = OktaImplicit(instance='testserver.okta-emea.com', client_id='54239d18-c68c-4c47-8bdd-ce71ea1d50cd') +with httpx.Client() as client: + client.get('http://www.example.com', auth=okta) +``` + +###### Parameters + +| Name | Description | Mandatory | Default value | +|:------------------------|:---------------------------|:----------|:--------------| +| `instance` | Okta instance (like "testserver.okta-emea.com"). | Mandatory | | +| `client_id` | Okta Application Identifier (formatted as an Universal Unique Identifier). | Mandatory | | +| `response_type` | Value of the response_type query parameter if not already provided in authorization URL. | Optional | token | +| `token_field_name` | Field name containing the token. | Optional | access_token | +| `nonce` | Refer to [OpenID ID Token specifications][3] for more details. | Optional | Newly generated Universal Unique Identifier. | +| `scope` | Scope parameter sent in query. Can also be a list of scopes. | Optional | ['openid', 'profile', 'email'] | +| `authorization_server` | Okta authorization server. | Optional | 'default' | +| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://localhost:/. | Optional | '' | +| `redirect_uri_port` | The port on which the server listening for the OAuth 2 token will be started. | Optional | 5000 | +| `timeout` | Maximum amount of seconds to wait for a token to be received once requested. | Optional | 60 | +| `success_display_time` | In case a token is successfully received, this is the maximum amount of milliseconds the success page will be displayed in your browser. | Optional | 1 | +| `failure_display_time` | In case received token is not valid, this is the maximum amount of milliseconds the failure page will be displayed in your browser. | Optional | 5000 | +| `header_name` | Name of the header field used to send token. | Optional | Authorization | +| `header_value` | Format used to send the token value. "{token}" must be present as it will be replaced by the actual token. | Optional | Bearer {token} | + +Any other parameter will be put as query parameter in the authorization URL. + +Usual extra parameters are: + +| Name | Description | +|:----------------|:---------------------------------------------------------------------| +| `prompt` | none to avoid prompting the user if a session is already opened. | + +##### Okta (OpenID Connect Implicit ID token) + +[Okta Implicit Grant](https://developer.okta.com/docs/guides/implement-implicit/overview/) providing ID tokens is supported. + +Use `httpx_auth.OktaImplicitIdToken` to configure this kind of authentication. + +```python +import httpx +from httpx_auth import OktaImplicitIdToken + + +okta = OktaImplicitIdToken(instance='testserver.okta-emea.com', client_id='54239d18-c68c-4c47-8bdd-ce71ea1d50cd') +with httpx.Client() as client: + client.get('http://www.example.com', auth=okta) +``` + +###### Parameters + +| Name | Description | Mandatory | Default value | +|:------------------------|:---------------------------|:----------|:--------------| +| `instance` | Okta instance (like "testserver.okta-emea.com"). | Mandatory | | +| `client_id` | Okta Application Identifier (formatted as an Universal Unique Identifier). | Mandatory | | +| `response_type` | Value of the response_type query parameter if not already provided in authorization URL. | Optional | id_token | +| `token_field_name` | Field name containing the token. | Optional | id_token | +| `nonce` | Refer to [OpenID ID Token specifications][3] for more details. | Optional | Newly generated Universal Unique Identifier. | +| `scope` | Scope parameter sent in query. Can also be a list of scopes. | Optional | ['openid', 'profile', 'email'] | +| `authorization_server` | Okta authorization server. | Optional | 'default' | +| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://localhost:/. | Optional | '' | +| `redirect_uri_port` | The port on which the server listening for the OAuth 2 token will be started. | Optional | 5000 | +| `timeout` | Maximum amount of seconds to wait for a token to be received once requested. | Optional | 60 | +| `success_display_time` | In case a token is successfully received, this is the maximum amount of milliseconds the success page will be displayed in your browser. | Optional | 1 | +| `failure_display_time` | In case received token is not valid, this is the maximum amount of milliseconds the failure page will be displayed in your browser. | Optional | 5000 | +| `header_name` | Name of the header field used to send token. | Optional | Authorization | +| `header_value` | Format used to send the token value. "{token}" must be present as it will be replaced by the actual token. | Optional | Bearer {token} | + +Any other parameter will be put as query parameter in the authorization URL. + +Usual extra parameters are: + +| Name | Description | +|:----------------|:---------------------------------------------------------------------| +| `prompt` | none to avoid prompting the user if a session is already opened. | + +### Managing token cache + +To avoid asking for a new token every new request, a token cache is used. + +Default cache is in memory but it is also possible to use a physical cache. + +You need to provide the location of your token cache file. It can be a full or relative path. + +If the file already exists it will be used, if the file do not exists it will be created. + +```python +from httpx_auth import OAuth2, JsonTokenFileCache + +OAuth2.token_cache = JsonTokenFileCache('path/to/my_token_cache.json') +``` + +## API key in header + +You can send an API key inside the header of your request using `httpx_auth.HeaderApiKey`. + +```python +import httpx +from httpx_auth import HeaderApiKey + +with httpx.Client() as client: + client.get('http://www.example.com', auth=HeaderApiKey('my_api_key')) +``` + +### Parameters + +| Name | Description | Mandatory | Default value | +|:------------------------|:-------------------------------|:----------|:--------------| +| `api_key` | The API key that will be sent. | Mandatory | | +| `header_name` | Name of the header field. | Optional | "X-API-Key" | + +## API key in query + +You can send an API key inside the query parameters of your request using `httpx_auth.QueryApiKey`. + +```python +import httpx +from httpx_auth import QueryApiKey + +with httpx.Client() as client: + client.get('http://www.example.com', auth=QueryApiKey('my_api_key')) +``` + +### Parameters + +| Name | Description | Mandatory | Default value | +|:------------------------|:-------------------------------|:----------|:--------------| +| `api_key` | The API key that will be sent. | Mandatory | | +| `query_parameter_name` | Name of the query parameter. | Optional | "api_key" | + +## Basic + +You can use basic authentication using `httpx_auth.Basic`. + +The only advantage of using this class instead of `httpx` native support of basic authentication, is to be able to use it in [multiple authentication](#multiple-authentication-at-once). + +```python +import httpx +from httpx_auth import Basic + +with httpx.Client() as client: + client.get('http://www.example.com', auth=Basic('username', 'password')) +``` + +### Parameters + +| Name | Description | Mandatory | Default value | +|:------------------------|:-------------------------------|:----------|:--------------| +| `username` | User name. | Mandatory | | +| `password` | User password. | Mandatory | | + +## Multiple authentication at once + +You can also use a combination of authentication using `+` as in the following sample: + +```python +import httpx +from httpx_auth import HeaderApiKey, OAuth2Implicit + +api_key = HeaderApiKey('my_api_key') +oauth2 = OAuth2Implicit('https://www.example.com') +with httpx.Client() as client: + client.get('http://www.example.com', auth=api_key + oauth2) +``` + +## Available pytest fixtures + +Testing the code using httpx_auth authentication classes can be achieved using provided [`pytest`][6] fixtures. + +### token_cache_mock + +```python +from httpx_auth.testing import token_cache_mock, token_mock + +def test_something(token_cache_mock): + # perform code using authentication + pass +``` + +Use this fixture to mock authentication success for any of the following classes: + * OAuth2AuthorizationCodePKCE + * OktaAuthorizationCodePKCE + * OAuth2Implicit + * OktaImplicit + * OktaImplicitIdToken + * AzureActiveDirectoryImplicit + * AzureActiveDirectoryImplicitIdToken + * OAuth2AuthorizationCode + * OktaAuthorizationCode + * OAuth2ClientCredentials + * OktaClientCredentials + * OAuth2ResourceOwnerPasswordCredentials, + +By default, [`pyjwt`](https://pypi.org/project/PyJWT/) is a required dependency as it is used to generate the token returned by the authentication. + +You can however return your custom token by providing your own `token_mock` fixture as in the following sample: + +```python +import pytest + +from httpx_auth.testing import token_cache_mock + + +@pytest.fixture +def token_mock() -> str: + return "2YotnFZFEjr1zCsicMWpAA" + + +def test_something(token_cache_mock): + # perform code using authentication + pass +``` + +### Advanced testing + +#### token_cache + +This [`pytest`][6] fixture will return the token cache and ensure it is reset at the end of the test case. + +```python +from httpx_auth.testing import token_cache + +def test_something(token_cache): + # perform code using authentication + pass +``` + +#### browser_mock + +This [`pytest`][6] fixture will allow to mock the behavior of a web browser. + +With this [`pytest`][6] fixture you will be allowed to fine tune your authentication related failures handling. + +[`pyjwt`](https://pypi.org/project/PyJWT/) is a required dependency if you use `create_token` helper function. + +```python +import datetime + +from httpx_auth.testing import browser_mock, BrowserMock, create_token + +def test_something(browser_mock: BrowserMock): + token_expiry = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token = create_token(token_expiry) + tab = browser_mock.add_response( + opened_url="http://url_opened_by_browser?state=1234", + reply_url=f"http://localhost:5000#access_token={token}&state=1234", + ) + + # perform code using authentication + + tab.assert_success( + "You are now authenticated on 1234 You may close this tab." + ) +``` + [1]: https://pypi.python.org/pypi/httpx "httpx module" [2]: https://www.python-httpx.org/advanced/#customizing-authentication "authentication parameter on httpx module" +[3]: http://openid.net/specs/openid-connect-core-1_0.html#IDToken "OpenID ID Token specifications" +[6]: https://docs.pytest.org/en/latest/ "pytest module" diff --git a/httpx_auth/__init__.py b/httpx_auth/__init__.py index 0cc29b4..c755c31 100644 --- a/httpx_auth/__init__.py +++ b/httpx_auth/__init__.py @@ -1 +1,30 @@ -from httpx_auth.version import __version__ \ No newline at end of file +from httpx_auth.authentication import ( + Basic, + HeaderApiKey, + QueryApiKey, + Auths, + OAuth2, + OAuth2AuthorizationCodePKCE, + OktaAuthorizationCodePKCE, + OAuth2Implicit, + OktaImplicit, + OktaImplicitIdToken, + AzureActiveDirectoryImplicit, + AzureActiveDirectoryImplicitIdToken, + OAuth2AuthorizationCode, + OktaAuthorizationCode, + OAuth2ClientCredentials, + OktaClientCredentials, + OAuth2ResourceOwnerPasswordCredentials, +) +from httpx_auth.oauth2_tokens import JsonTokenFileCache +from httpx_auth.errors import ( + GrantNotProvided, + TimeoutOccurred, + AuthenticationFailed, + StateNotProvided, + InvalidToken, + TokenExpiryNotProvided, + InvalidGrantRequest, +) +from httpx_auth.version import __version__ diff --git a/httpx_auth/authentication.py b/httpx_auth/authentication.py new file mode 100644 index 0000000..f76b750 --- /dev/null +++ b/httpx_auth/authentication.py @@ -0,0 +1,1121 @@ +import base64 +import os +import uuid +from hashlib import sha256, sha512 +from urllib.parse import parse_qs, urlsplit, urlunsplit, urlencode +from typing import Optional, Generator + +import httpx +import httpx.auth +import warnings + +from httpx import Request, Response + +from httpx_auth import oauth2_authentication_responses_server, oauth2_tokens +from httpx_auth.errors import InvalidGrantRequest, GrantNotProvided + + +def _add_parameters(initial_url: str, extra_parameters: dict) -> str: + """ + Add parameters to an URL and return the new URL. + + :param initial_url: + :param extra_parameters: dictionary of parameters name and value. + :return: the new URL containing parameters. + """ + scheme, netloc, path, query_string, fragment = urlsplit(initial_url) + query_params = parse_qs(query_string) + query_params.update( + { + parameter_name: [parameter_value] + for parameter_name, parameter_value in extra_parameters.items() + } + ) + + new_query_string = urlencode(query_params, doseq=True) + + return urlunsplit((scheme, netloc, path, new_query_string, fragment)) + + +def _pop_parameter(url: str, query_parameter_name: str) -> (str, Optional[str]): + """ + Remove and return parameter of an URL. + + :param url: The URL containing (or not) the parameter. + :param query_parameter_name: The query parameter to pop. + :return: The new URL (without this parameter) and the parameter value (None if not found). + """ + scheme, netloc, path, query_string, fragment = urlsplit(url) + query_params = parse_qs(query_string) + parameter_value = query_params.pop(query_parameter_name, None) + new_query_string = urlencode(query_params, doseq=True) + + return ( + urlunsplit((scheme, netloc, path, new_query_string, fragment)), + parameter_value, + ) + + +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: str, data, grant_name: str, timeout: float, auth=None +) -> (str, int): + response = httpx.post(url, data=data, timeout=timeout, auth=auth) + if response.is_error: + # As described in https://tools.ietf.org/html/rfc6749#section-5.2 + raise InvalidGrantRequest(response) + + content = response.json() + token = content.get(grant_name) + if not token: + raise GrantNotProvided(grant_name, content) + return token, content.get("expires_in") + + +class OAuth2: + token_cache = oauth2_tokens.TokenMemoryCache() + + +class SupportMultiAuth: + """Inherit from this class to be able to use your class with httpx_auth provided authentication classes.""" + + def __add__(self, other): + if isinstance(other, _MultiAuth): + return _MultiAuth(self, *other.authentication_modes) + return _MultiAuth(self, other) + + def __and__(self, other): + if isinstance(other, _MultiAuth): + return _MultiAuth(self, *other.authentication_modes) + return _MultiAuth(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:/. 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(httpx.auth.Auth, SupportMultiAuth): + """ + Resource Owner Password Credentials Grant + + Describes an OAuth 2 resource owner password credentials (also called password) flow requests authentication. + More details can be found in https://tools.ietf.org/html/rfc6749#section-4.3 + """ + + def __init__(self, token_url: str, username: str, password: str, **kwargs): + """ + :param token_url: OAuth 2 token URL. + :param username: Resource owner user name. + :param password: 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. + Token will be sent in Authorization header field by default. + :param header_value: Format used to send the token value. + "{token}" must be present as it will be replaced by the actual token. + Token will be sent as "Bearer {token}" by default. + :param scope: Scope parameter sent to token URL as body. Can also be a list of scopes. Not sent by default. + :param token_field_name: Field name containing the token. access_token by default. + :param kwargs: all additional authorization parameters that should be put as body parameters in the token URL. + """ + 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.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}" + ) + if "{token}" not in self.header_value: + raise Exception("header_value parameter must contains {token}.") + + self.token_field_name = ( + extra_parameters.pop("token_field_name", None) or "access_token" + ) + + # Time is expressed in seconds + self.timeout = int(extra_parameters.pop("timeout", None) or 60) + + # As described in https://tools.ietf.org/html/rfc6749#section-4.3.2 + self.data = { + "grant_type": "password", + "username": self.username, + "password": self.password, + } + scope = extra_parameters.pop("scope", None) + if scope: + self.data["scope"] = " ".join(scope) if isinstance(scope, list) else scope + self.data.update(extra_parameters) + + all_parameters_in_url = _add_parameters(self.token_url, self.data) + self.state = sha512(all_parameters_in_url.encode("unicode_escape")).hexdigest() + + def auth_flow(self, request: Request) -> Generator[Request, Response, None]: + token = OAuth2.token_cache.get_token(self.state, self.request_new_token) + request.headers[self.header_name] = self.header_value.format(token=token) + yield request + + def request_new_token(self): + # As described in https://tools.ietf.org/html/rfc6749#section-4.3.3 + token, expires_in = request_new_grant_with_post( + self.token_url, + self.data, + self.token_field_name, + self.timeout, + auth=(self.username, self.password), + ) + # Handle both Access and Bearer tokens + return (self.state, token, expires_in) if expires_in else (self.state, token) + + +class OAuth2ClientCredentials(httpx.auth.Auth, SupportMultiAuth): + """ + Client Credentials Grant + + Describes an OAuth 2 client credentials (also called application) flow requests authentication. + More details can be found in https://tools.ietf.org/html/rfc6749#section-4.4 + """ + + def __init__(self, token_url: str, client_id: str, client_secret: str, **kwargs): + """ + :param token_url: OAuth 2 token URL. + :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. + Token will be sent in Authorization header field by default. + :param header_value: Format used to send the token value. + "{token}" must be present as it will be replaced by the actual token. + Token will be sent as "Bearer {token}" by default. + :param scope: Scope parameter sent to token URL as body. Can also be a list of scopes. Not sent by default. + :param token_field_name: Field name containing the token. access_token by default. + :param kwargs: all additional authorization parameters that should be put as query parameter in the token URL. + """ + self.token_url = token_url + if not self.token_url: + raise Exception("Token URL 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) + self.header_name = extra_parameters.pop("header_name", None) or "Authorization" + self.header_value = ( + extra_parameters.pop("header_value", None) or "Bearer {token}" + ) + if "{token}" not in self.header_value: + raise Exception("header_value parameter must contains {token}.") + + self.token_field_name = ( + extra_parameters.pop("token_field_name", None) or "access_token" + ) + + # Time is expressed in seconds + self.timeout = int(extra_parameters.pop("timeout", None) or 60) + + # As described in https://tools.ietf.org/html/rfc6749#section-4.4.2 + self.data = {"grant_type": "client_credentials"} + scope = extra_parameters.pop("scope", None) + if scope: + self.data["scope"] = " ".join(scope) if isinstance(scope, list) else scope + self.data.update(extra_parameters) + + all_parameters_in_url = _add_parameters(self.token_url, self.data) + self.state = sha512(all_parameters_in_url.encode("unicode_escape")).hexdigest() + + def auth_flow(self, request: Request) -> Generator[Request, Response, None]: + token = OAuth2.token_cache.get_token(self.state, self.request_new_token) + request.headers[self.header_name] = self.header_value.format(token=token) + yield request + + def request_new_token(self) -> tuple: + # As described in https://tools.ietf.org/html/rfc6749#section-4.4.3 + token, expires_in = request_new_grant_with_post( + self.token_url, + self.data, + self.token_field_name, + self.timeout, + auth=(self.client_id, self.client_secret), + ) + # Handle both Access and Bearer tokens + return (self.state, token, expires_in) if expires_in else (self.state, token) + + +class OAuth2AuthorizationCode(httpx.auth.Auth, SupportMultiAuth, BrowserAuth): + """ + Authorization Code Grant + + Describes an OAuth 2 authorization code (also called access code) flow requests authentication. + + Request a code with client browser, then request a token using this code. + Store the token and use it for subsequent valid requests. + + More details can be found in https://tools.ietf.org/html/rfc6749#section-4.1 + """ + + def __init__(self, authorization_url: str, token_url: str, **kwargs): + """ + :param authorization_url: OAuth 2 authorization URL. + :param token_url: OAuth 2 token URL. + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://localhost:/. 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 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. + :param header_name: Name of the header field used to send token. + Token will be sent in Authorization header field by default. + :param header_value: Format used to send the token value. + "{token}" must be present as it will be replaced by the actual token. + Token will be sent as "Bearer {token}" by default. + :param response_type: Value of the response_type query parameter if not already provided in authorization URL. + code by default. + :param token_field_name: Field name containing the token. access_token by default. + :param code_field_name: Field name containing the code. code by default. + :param username: User name in case basic authentication should be used to retrieve token. + :param password: User password in case basic authentication should be used to retrieve token. + :param kwargs: all additional authorization parameters that should be put as query parameter + in the authorization URL and as body parameters in the token URL. + Usual parameters are: + * client_id: Corresponding to your Application ID (in Microsoft Azure app portal) + * client_secret: If client is not authenticated with the authorization server + * nonce: Refer to http://openid.net/specs/openid-connect-core-1_0.html#IDToken for more details + """ + self.authorization_url = authorization_url + if not self.authorization_url: + raise Exception("Authorization URL is mandatory.") + + self.token_url = token_url + if not self.token_url: + raise Exception("Token URL is mandatory.") + + 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}.") + + self.token_field_name = kwargs.pop("token_field_name", None) or "access_token" + + 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 = kwargs.pop("code_field_name", "code") + if _get_query_parameter(self.authorization_url, "response_type"): + # 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 + kwargs.setdefault("response_type", "code") + + authorization_url_without_nonce = _add_parameters( + self.authorization_url, kwargs + ) + authorization_url_without_nonce, nonce = _pop_parameter( + authorization_url_without_nonce, "nonce" + ) + self.state = sha512( + authorization_url_without_nonce.encode("unicode_escape") + ).hexdigest() + custom_code_parameters = { + "state": self.state, + "redirect_uri": self.redirect_uri, + } + if nonce: + custom_code_parameters["nonce"] = nonce + code_grant_url = _add_parameters( + authorization_url_without_nonce, custom_code_parameters + ) + self.code_grant_details = oauth2_authentication_responses_server.GrantDetails( + code_grant_url, + code_field_name, + self.timeout, + 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": self.redirect_uri, + } + self.token_data.update(kwargs) + + def auth_flow(self, request: Request) -> Generator[Request, Response, None]: + token = OAuth2.token_cache.get_token(self.state, self.request_new_token) + request.headers[self.header_name] = self.header_value.format(token=token) + yield request + + def request_new_token(self): + # Request code + state, code = oauth2_authentication_responses_server.request_new_grant( + self.code_grant_details + ) + + # As described in https://tools.ietf.org/html/rfc6749#section-4.1.3 + self.token_data["code"] = code + # As described in https://tools.ietf.org/html/rfc6749#section-4.1.4 + token, expires_in = request_new_grant_with_post( + self.token_url, + self.token_data, + self.token_field_name, + self.timeout, + auth=self.auth, + ) + # Handle both Access and Bearer tokens + return (self.state, token, expires_in) if expires_in else (self.state, token) + + +class OAuth2AuthorizationCodePKCE( + httpx.auth.Auth, SupportMultiAuth, BrowserAuth +): + """ + Proof Key for Code Exchange + + Describes an OAuth 2 Proof Key for Code Exchange (PKCE) flow requests authentication. + + Request a code with client browser, then request a token using this code. + Store the token and use it for subsequent valid requests. + + More details can be found in https://tools.ietf.org/html/rfc7636 + """ + + def __init__(self, authorization_url: str, token_url: str, **kwargs): + """ + :param authorization_url: OAuth 2 authorization URL. + :param token_url: OAuth 2 token URL. + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://localhost:/. 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 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. + :param header_name: Name of the header field used to send token. + Token will be sent in Authorization header field by default. + :param header_value: Format used to send the token value. + "{token}" must be present as it will be replaced by the actual token. + Token will be sent as "Bearer {token}" by default. + :param response_type: Value of the response_type query parameter if not already provided in authorization URL. + code by default. + :param token_field_name: Field name containing the token. access_token by default. + :param code_field_name: Field name containing the code. code by default. + :param kwargs: all additional authorization parameters that should be put as query parameter + in the authorization URL and as body parameters in the token URL. + Usual parameters are: + * client_id: Corresponding to your Application ID (in Microsoft Azure app portal) + * client_secret: If client is not authenticated with the authorization server + * nonce: Refer to http://openid.net/specs/openid-connect-core-1_0.html#IDToken for more details + """ + self.authorization_url = authorization_url + if not self.authorization_url: + raise Exception("Authorization URL is mandatory.") + + self.token_url = token_url + if not self.token_url: + raise Exception("Token URL is mandatory.") + + 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}.") + + 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 = 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 + kwargs["response_type"] = response_type + else: + # As described in https://tools.ietf.org/html/rfc6749#section-4.1.1 + kwargs.setdefault("response_type", "code") + + authorization_url_without_nonce = _add_parameters( + authorization_url_without_response_type, kwargs + ) + authorization_url_without_nonce, nonce = _pop_parameter( + authorization_url_without_nonce, "nonce" + ) + self.state = sha512( + authorization_url_without_nonce.encode("unicode_escape") + ).hexdigest() + custom_code_parameters = { + "state": self.state, + "redirect_uri": self.redirect_uri, + } + if nonce: + custom_code_parameters["nonce"] = nonce + + # generate PKCE code verifier and challenge + code_verifier = self.generate_code_verifier() + code_challenge = self.generate_code_challenge(code_verifier) + + # add code challenge parameters to the authorization_url request + custom_code_parameters["code_challenge"] = code_challenge + custom_code_parameters["code_challenge_method"] = "S256" + + code_grant_url = _add_parameters( + authorization_url_without_nonce, custom_code_parameters + ) + self.code_grant_details = oauth2_authentication_responses_server.GrantDetails( + code_grant_url, + code_field_name, + self.timeout, + 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 + # include the PKCE code verifier used in the second part of the flow + self.token_data = { + "code_verifier": code_verifier, + "grant_type": "authorization_code", + "redirect_uri": self.redirect_uri, + } + self.token_data.update(kwargs) + + def auth_flow(self, request: Request) -> Generator[Request, Response, None]: + token = OAuth2.token_cache.get_token(self.state, self.request_new_token) + request.headers[self.header_name] = self.header_value.format(token=token) + yield request + + def request_new_token(self) -> tuple: + # Request code + state, code = oauth2_authentication_responses_server.request_new_grant( + self.code_grant_details + ) + + # As described in https://tools.ietf.org/html/rfc6749#section-4.1.3 + self.token_data["code"] = code + # As described in https://tools.ietf.org/html/rfc6749#section-4.1.4 + token, expires_in = request_new_grant_with_post( + self.token_url, self.token_data, self.token_field_name, self.timeout + ) + # Handle both Access and Bearer tokens + return (self.state, token, expires_in) if expires_in else (self.state, token) + + @staticmethod + def generate_code_verifier() -> bytes: + """ + Source: https://github.com/openstack/deb-python-oauth2client/blob/master/oauth2client/_pkce.py + + Generates a 'code_verifier' as described in section 4.1 of RFC 7636. + This is a 'high-entropy cryptographic random string' that will be + impractical for an attacker to guess. + + https://tools.ietf.org/html/rfc7636#section-4.1 + + :return: urlsafe base64-encoded random data. + """ + return base64.urlsafe_b64encode(os.urandom(64)).rstrip(b"=") + + @staticmethod + def generate_code_challenge(verifier: bytes) -> bytes: + """ + Source: https://github.com/openstack/deb-python-oauth2client/blob/master/oauth2client/_pkce.py + + Creates a 'code_challenge' as described in section 4.2 of RFC 7636 + by taking the sha256 hash of the verifier and then urlsafe + base64-encoding it. + + https://tools.ietf.org/html/rfc7636#section-4.1 + + :param verifier: code_verifier as generated by generate_code_verifier() + :return: urlsafe base64-encoded sha256 hash digest, without '=' padding. + """ + digest = sha256(verifier).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=") + + +class OAuth2Implicit(httpx.auth.Auth, SupportMultiAuth, BrowserAuth): + """ + Implicit Grant + + Describes an OAuth 2 implicit flow requests authentication. + + Request a token with client browser. + Store the token and use it for subsequent valid requests. + + More details can be found in https://tools.ietf.org/html/rfc6749#section-4.2 + """ + + 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. + token by default. + :param token_field_name: Name of the expected field containing the token. + id_token by default if response_type is id_token, else access_token. + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://localhost:/. Default value is to redirect on / (root). + :param redirect_uri_port: The port on which the server listening for the OAuth 2 token will be started. + Listen on port 5000 by default. + :param timeout: Maximum amount of seconds to wait for a token to be received once requested. + Wait for 1 minute by default. + :param success_display_time: In case a token 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 token 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. + :param header_name: Name of the header field used to send token. + Token will be sent in Authorization header field by default. + :param header_value: Format used to send the token value. + "{token}" must be present as it will be replaced by the actual token. + Token will be sent as "Bearer {token}" by default. + :param kwargs: all additional authorization parameters that should be put as query parameter + in the authorization URL. + Usual parameters are: + * client_id: Corresponding to your Application ID (in Microsoft Azure app portal) + * nonce: Refer to http://openid.net/specs/openid-connect-core-1_0.html#IDToken for more details + * prompt: none to avoid prompting the user if a session is already opened. + """ + self.authorization_url = authorization_url + if not self.authorization_url: + raise Exception("Authorization URL is mandatory.") + + 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}.") + + response_type = _get_query_parameter(self.authorization_url, "response_type") + if response_type: + # 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 = kwargs.setdefault("response_type", "token") + + # As described in https://tools.ietf.org/html/rfc6749#section-4.2.2 + 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" + ) + + authorization_url_without_nonce = _add_parameters( + self.authorization_url, kwargs + ) + authorization_url_without_nonce, nonce = _pop_parameter( + authorization_url_without_nonce, "nonce" + ) + self.state = sha512( + authorization_url_without_nonce.encode("unicode_escape") + ).hexdigest() + 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, + self.timeout, + self.success_display_time, + self.failure_display_time, + self.redirect_uri_port, + ) + + def auth_flow(self, request: Request) -> Generator[Request, Response, None]: + token = OAuth2.token_cache.get_token( + self.state, + oauth2_authentication_responses_server.request_new_grant, + self.grant_details, + ) + request.headers[self.header_name] = self.header_value.format(token=token) + yield request + + +class AzureActiveDirectoryImplicit(OAuth2Implicit): + """ + Describes an Azure Active Directory (OAuth 2) "Access Token" requests authentication. + https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens + """ + + 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) + :param response_type: Value of the response_type query parameter. + token by default. + :param token_field_name: Name of the expected field containing the token. + access_token by default. + :param nonce: Refer to http://openid.net/specs/openid-connect-core-1_0.html#IDToken for more details + (formatted as an Universal Unique Identifier - UUID). Use a newly generated UUID by default. + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://localhost:/. Default value is to redirect on / (root). + :param redirect_uri_port: The port on which the server listening for the OAuth 2 token will be started. + Listen on port 5000 by default. + :param timeout: Maximum amount of seconds to wait for a token to be received once requested. + Wait for 1 minute by default. + :param success_display_time: In case a token 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 token 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. + :param header_name: Name of the header field used to send token. + Token will be sent in Authorization header field by default. + :param header_value: Format used to send the token value. + "{token}" must be present as it will be replaced by the actual token. + Token will be sent as "Bearer {token}" by default. + :param kwargs: all additional authorization parameters that should be put as query parameter + in the authorization URL. + Usual parameters are: + * prompt: none to avoid prompting the user if a session is already opened. + """ + OAuth2Implicit.__init__( + self, + f"https://login.microsoftonline.com/{tenant_id}/oauth2/authorize", + client_id=client_id, + nonce=kwargs.pop("nonce", None) or str(uuid.uuid4()), + **kwargs, + ) + + +class AzureActiveDirectoryImplicitIdToken(OAuth2Implicit): + """ + Describes an Azure Active Directory (OpenID Connect) "ID Token" requests authentication. + https://docs.microsoft.com/en-us/azure/active-directory/develop/id-tokens + """ + + 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) + :param response_type: Value of the response_type query parameter. + id_token by default. + :param token_field_name: Name of the expected field containing the token. + id_token by default. + :param nonce: Refer to http://openid.net/specs/openid-connect-core-1_0.html#IDToken for more details + (formatted as an Universal Unique Identifier - UUID). Use a newly generated UUID by default. + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://localhost:/. Default value is to redirect on / (root). + :param redirect_uri_port: The port on which the server listening for the OAuth 2 token will be started. + Listen on port 5000 by default. + :param timeout: Maximum amount of seconds to wait for a token to be received once requested. + Wait for 1 minute by default. + :param success_display_time: In case a token 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 token 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. + :param header_name: Name of the header field used to send token. + Token will be sent in Authorization header field by default. + :param header_value: Format used to send the token value. + "{token}" must be present as it will be replaced by the actual token. + Token will be sent as "Bearer {token}" by default. + :param kwargs: all additional authorization parameters that should be put as query parameter + in the authorization URL. + Usual parameters are: + * prompt: none to avoid prompting the user if a session is already opened. + """ + OAuth2Implicit.__init__( + self, + 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, + ) + + +class OktaImplicit(OAuth2Implicit): + """ + Describes an Okta (OAuth 2) "Access Token" implicit flow requests authentication. + + https://developer.okta.com/docs/guides/implement-implicit/overview/ + """ + + 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) + :param response_type: Value of the response_type query parameter. + token by default. + :param token_field_name: Name of the expected field containing the token. + access_token by default. + :param nonce: Refer to http://openid.net/specs/openid-connect-core-1_0.html#IDToken for more details + (formatted as an Universal Unique Identifier - UUID). Use a newly generated UUID by default. + :param authorization_server: Okta authorization server. + default by default. + :param scope: Scope parameter sent in query. Can also be a list of scopes. + Request ['openid', 'profile', 'email'] by default. + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://localhost:/. Default value is to redirect on / (root). + :param redirect_uri_port: The port on which the server listening for the OAuth 2 token will be started. + Listen on port 5000 by default. + :param timeout: Maximum amount of seconds to wait for a token to be received once requested. + Wait for 1 minute by default. + :param success_display_time: In case a token 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 token 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. + :param header_name: Name of the header field used to send token. + Token will be sent in Authorization header field by default. + :param header_value: Format used to send the token value. + "{token}" must be present as it will be replaced by the actual token. + Token will be sent as "Bearer {token}" by default. + :param kwargs: all additional authorization parameters that should be put as query parameter + in the authorization URL. + Usual parameters are: + * prompt: none to avoid prompting the user if a session is already opened. + """ + authorization_server = kwargs.pop("authorization_server", None) or "default" + scopes = kwargs.pop("scope", None) or ["openid", "profile", "email"] + kwargs["scope"] = " ".join(scopes) if isinstance(scopes, list) else scopes + OAuth2Implicit.__init__( + self, + f"https://{instance}/oauth2/{authorization_server}/v1/authorize", + client_id=client_id, + nonce=kwargs.pop("nonce", None) or str(uuid.uuid4()), + **kwargs, + ) + + +class OktaImplicitIdToken(OAuth2Implicit): + """ + Describes an Okta (OpenID Connect) "ID Token" implicit flow requests authentication. + """ + + 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) + :param response_type: Value of the response_type query parameter. + id_token by default. + :param token_field_name: Name of the expected field containing the token. + id_token by default. + :param nonce: Refer to http://openid.net/specs/openid-connect-core-1_0.html#IDToken for more details + (formatted as an Universal Unique Identifier - UUID). Use a newly generated UUID by default. + :param authorization_server: Okta authorization server + default by default. + :param scope: Scope parameter sent in query. Can also be a list of scopes. + Request ['openid', 'profile', 'email'] by default. + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://localhost:/. Default value is to redirect on / (root). + :param redirect_uri_port: The port on which the server listening for the OAuth 2 token will be started. + Listen on port 5000 by default. + :param timeout: Maximum amount of seconds to wait for a token to be received once requested. + Wait for 1 minute by default. + :param success_display_time: In case a token 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 token 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. + :param header_name: Name of the header field used to send token. + Token will be sent in Authorization header field by default. + :param header_value: Format used to send the token value. + "{token}" must be present as it will be replaced by the actual token. + Token will be sent as "Bearer {token}" by default. + :param kwargs: all additional authorization parameters that should be put as query parameter + in the authorization URL. + Usual parameters are: + * prompt: none to avoid prompting the user if a session is already opened. + """ + authorization_server = kwargs.pop("authorization_server", None) or "default" + scopes = kwargs.pop("scope", None) or ["openid", "profile", "email"] + kwargs["scope"] = " ".join(scopes) if isinstance(scopes, list) else scopes + OAuth2Implicit.__init__( + self, + 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, + ) + + +class OktaAuthorizationCode(OAuth2AuthorizationCode): + """ + Describes an Okta (OAuth 2) "Access Token" authorization code flow requests authentication. + """ + + 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) + :param response_type: Value of the response_type query parameter. + token by default. + :param token_field_name: Name of the expected field containing the token. + access_token by default. + :param nonce: Refer to http://openid.net/specs/openid-connect-core-1_0.html#IDToken for more details + (formatted as an Universal Unique Identifier - UUID). Use a newly generated UUID by default. + :param authorization_server: Okta authorization server + default by default. + :param scope: Scope parameter sent in query. Can also be a list of scopes. + Request 'openid' by default. + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://localhost:/. Default value is to redirect on / (root). + :param redirect_uri_port: The port on which the server listening for the OAuth 2 token will be started. + Listen on port 5000 by default. + :param timeout: Maximum amount of seconds to wait for a token to be received once requested. + Wait for 1 minute by default. + :param success_display_time: In case a token 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 token 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. + :param header_name: Name of the header field used to send token. + Token will be sent in Authorization header field by default. + :param header_value: Format used to send the token value. + "{token}" must be present as it will be replaced by the actual token. + Token will be sent as "Bearer {token}" by default. + :param kwargs: all additional authorization parameters that should be put as query parameter + in the authorization URL. + Usual parameters are: + * prompt: none to avoid prompting the user if a session is already opened. + """ + authorization_server = kwargs.pop("authorization_server", None) or "default" + scopes = kwargs.pop("scope", "openid") + kwargs["scope"] = " ".join(scopes) if isinstance(scopes, list) else scopes + OAuth2AuthorizationCode.__init__( + self, + f"https://{instance}/oauth2/{authorization_server}/v1/authorize", + f"https://{instance}/oauth2/{authorization_server}/v1/token", + client_id=client_id, + **kwargs, + ) + + +class OktaAuthorizationCodePKCE(OAuth2AuthorizationCodePKCE): + """ + Describes an Okta (OAuth 2) "Access Token" Proof Key for Code Exchange (PKCE) flow requests authentication. + """ + + 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) + :param response_type: Value of the response_type query parameter. + code by default. + :param token_field_name: Name of the expected field containing the token. + access_token by default. + :param code_field_name: Field name containing the code. code by default. + :param nonce: Refer to http://openid.net/specs/openid-connect-core-1_0.html#IDToken for more details + (formatted as an Universal Unique Identifier - UUID). Use a newly generated UUID by default. + :param authorization_server: Okta authorization server + default by default. + :param scope: Scope parameter sent in query. Can also be a list of scopes. + Request 'openid' by default. + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://localhost:/. Default value is to redirect on / (root). + :param redirect_uri_port: The port on which the server listening for the OAuth 2 token will be started. + Listen on port 5000 by default. + :param timeout: Maximum amount of seconds to wait for a token to be received once requested. + Wait for 1 minute by default. + :param success_display_time: In case a token 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 token 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. + :param header_name: Name of the header field used to send token. + Token will be sent in Authorization header field by default. + :param header_value: Format used to send the token value. + "{token}" must be present as it will be replaced by the actual token. + Token will be sent as "Bearer {token}" by default. + :param kwargs: all additional authorization parameters that should be put as query parameter + in the authorization URL and as body parameters in the token URL. + Usual parameters are: + * client_secret: If client is not authenticated with the authorization server + * nonce: Refer to http://openid.net/specs/openid-connect-core-1_0.html#IDToken for more details + """ + authorization_server = kwargs.pop("authorization_server", None) or "default" + scopes = kwargs.pop("scope", "openid") + kwargs["scope"] = " ".join(scopes) if isinstance(scopes, list) else scopes + OAuth2AuthorizationCodePKCE.__init__( + self, + f"https://{instance}/oauth2/{authorization_server}/v1/authorize", + f"https://{instance}/oauth2/{authorization_server}/v1/token", + client_id=client_id, + **kwargs, + ) + + +class OktaClientCredentials(OAuth2ClientCredentials): + """ + Describes an Okta (OAuth 2) client credentials (also called application) flow requests authentication. + """ + + 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) + :param client_secret: Resource owner password. + :param authorization_server: Okta authorization server + default by default. + :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. + Token will be sent in Authorization header field by default. + :param header_value: Format used to send the token value. + "{token}" must be present as it will be replaced by the actual token. + Token will be sent as "Bearer {token}" by default. + :param scope: Scope parameter sent to token URL as body. Can also be a list of scopes. + Request 'openid' by default. + :param token_field_name: Field name containing the token. access_token by default. + :param kwargs: all additional authorization parameters that should be put as query parameter in the token URL. + """ + authorization_server = kwargs.pop("authorization_server", None) or "default" + scopes = kwargs.pop("scope", "openid") + kwargs["scope"] = " ".join(scopes) if isinstance(scopes, list) else scopes + OAuth2ClientCredentials.__init__( + self, + f"https://{instance}/oauth2/{authorization_server}/v1/token", + client_id=client_id, + client_secret=client_secret, + **kwargs, + ) + + +class HeaderApiKey(httpx.auth.Auth, SupportMultiAuth): + """Describes an API Key requests authentication.""" + + 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. + """ + self.api_key = api_key + if not api_key: + raise Exception("API Key is mandatory.") + self.header_name = header_name or "X-API-Key" + + def auth_flow(self, request: Request) -> Generator[Request, Response, None]: + request.headers[self.header_name] = self.api_key + yield request + + +class QueryApiKey(httpx.auth.Auth, SupportMultiAuth): + """Describes an API Key requests authentication.""" + + 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. + """ + self.api_key = api_key + if not api_key: + raise Exception("API Key is mandatory.") + self.query_parameter_name = query_parameter_name or "api_key" + + def auth_flow(self, request: Request) -> Generator[Request, Response, None]: + request.url = httpx.URL(request.url, params={self.query_parameter_name: self.api_key}) + yield request + + +class Basic(httpx.auth.BasicAuth, SupportMultiAuth): + """Describes a basic requests authentication.""" + + def __init__(self, username: str, password: str): + httpx.auth.BasicAuth.__init__(self, username, password) + + +class _MultiAuth(httpx.auth.Auth): + """Authentication using multiple authentication methods.""" + + def __init__(self, *authentication_modes): + self.authentication_modes = authentication_modes + + def auth_flow(self, request: Request) -> Generator[Request, Response, None]: + for authentication_mode in self.authentication_modes: + next(authentication_mode.auth_flow(request)) + yield request + + def __add__(self, other): + if isinstance(other, _MultiAuth): + return _MultiAuth(*self.authentication_modes, *other.authentication_modes) + return _MultiAuth(*self.authentication_modes, other) + + def __and__(self, other): + if isinstance(other, _MultiAuth): + return _MultiAuth(*self.authentication_modes, *other.authentication_modes) + return _MultiAuth(*self.authentication_modes, other) + + +class Auths(_MultiAuth): + def __init__(self, *authentication_modes): + warnings.warn( + "Auths class will be removed in the future. Use + instead.", + DeprecationWarning, + ) + super().__init__(*authentication_modes) diff --git a/httpx_auth/errors.py b/httpx_auth/errors.py new file mode 100644 index 0000000..b0b5829 --- /dev/null +++ b/httpx_auth/errors.py @@ -0,0 +1,130 @@ +from json import JSONDecodeError +from typing import Union + +from httpx import Response + + +class AuthenticationFailed(Exception): + """ User was not authenticated. """ + + def __init__(self): + Exception.__init__(self, "User was not authenticated.") + + +class TimeoutOccurred(Exception): + """ No response within timeout interval. """ + + def __init__(self, timeout: float): + Exception.__init__( + self, f"User authentication was not received within {timeout} seconds." + ) + + +class InvalidToken(Exception): + """ Token is invalid. """ + + 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: str, dictionary_without_grant: dict): + Exception.__init__( + 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: dict): + Exception.__init__( + self, f"state not provided within {dictionary_without_state}." + ) + + +class TokenExpiryNotProvided(Exception): + """ Token expiry was not provided. """ + + def __init__(self, token_body: dict): + Exception.__init__(self, f"Expiry (exp) is not provided in {token_body}.") diff --git a/httpx_auth/oauth2_authentication_responses_server.py b/httpx_auth/oauth2_authentication_responses_server.py new file mode 100644 index 0000000..43ef2d4 --- /dev/null +++ b/httpx_auth/oauth2_authentication_responses_server.py @@ -0,0 +1,225 @@ +import webbrowser +import logging +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import parse_qs, urlparse +from socket import socket + +import httpx + +from httpx_auth.errors import * + +logger = logging.getLogger(__name__) + + +class OAuth2ResponseHandler(BaseHTTPRequestHandler): + def do_GET(self): + # Do not consider a favicon request as an error + if self.path == "/favicon.ico": + logger.debug( + "Favicon request received on OAuth2 authentication response server." + ) + return self.send_html("Favicon is not provided.") + + logger.debug(f"GET received on {self.path}") + try: + args = self._get_params() + if self.server.grant_details.name in args or args.pop( + "httpx_auth_redirect", None + ): + self._parse_grant(args) + else: + logger.debug("Send anchor grant as query parameter.") + self.send_html(self.fragment_redirect_page()) + except Exception as e: + self.server.request_error = e + logger.exception("Unable to properly perform authentication.") + self.send_html( + self.error_page(f"Unable to properly perform authentication: {e}") + ) + + def do_POST(self): + logger.debug(f"POST received on {self.path}") + try: + form_dict = self._get_form() + self._parse_grant(form_dict) + except Exception as e: + self.server.request_error = e + logger.exception("Unable to properly perform authentication.") + self.send_html( + self.error_page(f"Unable to properly perform authentication: {e}") + ) + + 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(f"Received grants: {grants}") + grant = grants[0] + + states = arguments.get("state") + if not states or len(states) > 1: + raise StateNotProvided(arguments) + logger.debug(f"Received states: {states}") + state = states[0] + self.server.grant = state, grant + self.send_html( + self.success_page( + f"You are now authenticated on {state}. You may close this tab." + ) + ) + + def _get_form(self): + content_length = int(self.headers.get("Content-Length", 0)) + body_str = self.rfile.read(content_length).decode("utf-8") + return parse_qs(body_str, keep_blank_values=1) + + def _get_params(self): + return parse_qs(urlparse(self.path).query) + + 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: str): + return f""" +
{text}
+ """ + + def error_page(self, text: str): + return f""" +
{text}
+ """ + + def fragment_redirect_page(self): + """Return a page with JS that calls back the server on the url + original url: scheme://FQDN/path#fragment + call back url: scheme://FQDN/path?fragment + + The fragment part is used in the protocol for the client to retrieve the token. + As the fragment part is not sent to the server (to avoid normally to see the token in the logs) + we must call again the localhost server with the fragment transformed as query string. + """ + return """""" + + def log_message(self, format: str, *args): + """Make sure that messages are logged even with pythonw (seems like a bug in BaseHTTPRequestHandler).""" + logger.debug(format, *args) + + +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 + + +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(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: 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 + raise self.request_error + return not self.grant + + def handle_timeout(self): + raise TimeoutOccurred(self.timeout) + + +def request_new_grant(grant_details: GrantDetails) -> (str, str): + """ + Ask for a new OAuth2 grant. + :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(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: 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) + try: + browser = ( + webbrowser.get(webbrowser.iexplore) + if hasattr(webbrowser, "iexplore") + else webbrowser.get() + ) + 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.") + httpx.get(url) + except webbrowser.Error: + logger.exception("Unable to open URL, try with a GET request.") + httpx.get(url) + + +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() + server.ensure_no_error_occurred() + return server.grant diff --git a/httpx_auth/oauth2_tokens.py b/httpx_auth/oauth2_tokens.py new file mode 100644 index 0000000..e2616ee --- /dev/null +++ b/httpx_auth/oauth2_tokens.py @@ -0,0 +1,188 @@ +import base64 +import json +import os +import datetime +import threading +import logging +from httpx_auth.errors import * + +logger = logging.getLogger(__name__) + + +def decode_base64(base64_encoded_string: str) -> str: + """ + Decode base64, padding being optional. + + :param base64_encoded_string: Base64 data as an ASCII byte string + :returns: The decoded byte string. + """ + missing_padding = len(base64_encoded_string) % 4 + if missing_padding != 0: + base64_encoded_string += "=" * (4 - missing_padding) + return base64.b64decode(base64_encoded_string).decode("unicode_escape") + + +def is_expired(expiry: float) -> bool: + return datetime.datetime.utcfromtimestamp(expiry) < datetime.datetime.utcnow() + + +class TokenMemoryCache: + """ + Class to manage tokens using memory storage. + """ + + def __init__(self): + self.tokens = {} + self.forbid_concurrent_cache_access = threading.Lock() + self.forbid_concurrent_missing_token_function_call = threading.Lock() + + def add_bearer_token(self, key: str, token: str): + """ + Set the bearer token and save it + :param key: key identifier of the token + :param token: value + :raise InvalidToken: In case token is invalid. + :raise TokenExpiryNotProvided: In case expiry is not provided. + """ + if not token: + raise InvalidToken(token) + + header, body, other = token.split(".") + body = json.loads(decode_base64(body)) + expiry = body.get("exp") + if not expiry: + raise TokenExpiryNotProvided(expiry) + + self._add_token(key, token, expiry) + + 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 + :param token: value + :param expires_in: Number of seconds before token expiry + :raise InvalidToken: In case token is invalid. + """ + expiry = datetime.datetime.utcnow().replace( + tzinfo=datetime.timezone.utc + ) + datetime.timedelta(seconds=int(expires_in)) + self._add_token(key, token, expiry.timestamp()) + + def _add_token(self, key: str, token: str, expiry: float): + """ + Set the bearer token and save it + :param key: key identifier of the token + :param token: value + :param expiry: UTC timestamp of expiry + """ + with self.forbid_concurrent_cache_access: + self.tokens[key] = token, expiry + self._save_tokens() + logger.debug( + f'Inserting token expiring on {datetime.datetime.utcfromtimestamp(expiry)} (UTC) with "{key}" key: {token}' + ) + + 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 + :param on_missing_token: function to call when token is expired or missing (returning token and expiry tuple) + :param on_missing_token_args: arguments of the function + :return: the token + :raise AuthenticationFailed: in case token cannot be retrieved. + """ + 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(f'Authentication token with "{key}" key is expired.') + del self.tokens[key] + else: + logger.debug( + f"Using already received authentication, will expire on {datetime.datetime.utcfromtimestamp(expiry)} (UTC)." + ) + return bearer + + logger.debug("Token cannot be found in cache.") + if on_missing_token is not None: + with self.forbid_concurrent_missing_token_function_call: + new_token = on_missing_token(*on_missing_token_args) + if len(new_token) == 2: # Bearer token + state, token = new_token + self.add_bearer_token(state, token) + else: # Access Token + state, token, expires_in = new_token + self.add_access_token(state, token, expires_in) + if key != state: + logger.warning( + 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( + f"Using newly received authentication, expiring on {datetime.datetime.utcfromtimestamp(expiry)} (UTC)." + ) + return bearer + + logger.debug( + f"User was not authenticated: key {key} cannot be found in {self.tokens}." + ) + raise AuthenticationFailed() + + def clear(self): + with self.forbid_concurrent_cache_access: + logger.debug("Clearing token cache.") + self.tokens = {} + self._clear() + + def _save_tokens(self): + pass + + def _load_tokens(self): + pass + + def _clear(self): + pass + + +class JsonTokenFileCache(TokenMemoryCache): + """ + Class to manage tokens using a cache file. + """ + + def __init__(self, tokens_path: str): + TokenMemoryCache.__init__(self) + self.tokens_path = tokens_path + self.last_save_time = 0 + self._load_tokens() + + def _clear(self): + self.last_save_time = 0 + try: + os.remove(self.tokens_path) + except: + logger.debug("Cannot remove tokens file.") + + def _save_tokens(self): + try: + with open(self.tokens_path, "w") as tokens_cache_file: + json.dump(self.tokens, tokens_cache_file) + self.last_save_time = os.path.getmtime(self.tokens_path) + except: + logger.exception("Cannot save tokens.") + + def _load_tokens(self): + if not os.path.exists(self.tokens_path): + logger.debug("No token loaded. Token cache does not exists.") + return + try: + last_modification_time = os.path.getmtime(self.tokens_path) + if last_modification_time > self.last_save_time: + self.last_save_time = last_modification_time + with open(self.tokens_path, "r") as tokens_cache_file: + self.tokens = json.load(tokens_cache_file) + except: + logger.exception("Cannot load tokens.") diff --git a/httpx_auth/testing.py b/httpx_auth/testing.py new file mode 100644 index 0000000..346129b --- /dev/null +++ b/httpx_auth/testing.py @@ -0,0 +1,136 @@ +import urllib.request +import threading +from urllib.parse import urlsplit +from typing import Dict, Optional +import datetime + +import pytest + +import httpx_auth + + +def create_token(expiry: Optional[datetime.datetime]) -> str: + import jwt # Consider jwt an optional dependency for testing + + token = ( + jwt.encode({"exp": expiry}, "secret") if expiry else jwt.encode({}, "secret") + ) + return token.decode("unicode_escape") + + +@pytest.fixture +def token_cache(): + yield httpx_auth.OAuth2.token_cache + httpx_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'' + ): + content = self._simulate_httpx_auth_redirect() + return content + + def _simulate_httpx_auth_redirect(self) -> bytes: + reply_url = self.reply_url.replace("#", "?") + reply_url += ( + "&httpx_auth_redirect=1" + if "?" in reply_url + else "?httpx_auth_redirect=1" + ) + 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"\n
{expected_message}
\n " + ) + self.checked = True + + def assert_failure(self, expected_message: str, timeout: int = 5000): + self.join() + assert ( + self.content + == f"\n
{expected_message}
\n " + ) + self.checked = True + + +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 httpx_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 browser_mock(monkeypatch) -> BrowserMock: + mock = BrowserMock() + import httpx_auth.oauth2_authentication_responses_server + + monkeypatch.setattr( + httpx_auth.oauth2_authentication_responses_server.webbrowser, + "get", + lambda *args: mock, + ) + yield mock + mock.assert_checked() + + +@pytest.fixture +def token_mock() -> str: + return create_token(None) + + +@pytest.fixture +def token_cache_mock(monkeypatch, token_mock: str): + class TokenCacheMock: + def get_token(self, *args, **kwargs) -> str: + return token_mock + + monkeypatch.setattr(httpx_auth.OAuth2, "token_cache", TokenCacheMock()) diff --git a/httpx_auth/version.py b/httpx_auth/version.py index e973d31..cad1630 100644 --- a/httpx_auth/version.py +++ b/httpx_auth/version.py @@ -3,4 +3,4 @@ # Major should be incremented in case there is a breaking change. (eg: 2.5.8 -> 3.0.0) # Minor should be incremented in case there is an enhancement. (eg: 2.5.8 -> 2.6.0) # Patch should be incremented in case there is a bug fix. (eg: 2.5.8 -> 2.5.9) -__version__ = "0.0.1" +__version__ = "0.0.2" diff --git a/setup.py b/setup.py index e46b254..a5dac44 100644 --- a/setup.py +++ b/setup.py @@ -41,6 +41,10 @@ ], extras_require={ "testing": [ + # Used to generate test tokens + "pyjwt==1.*", + # Used to mock httpx + "pytest_httpx==0.0.5", # Used to check coverage "pytest-cov==2.*", ] diff --git a/tests/auth_helper.py b/tests/auth_helper.py new file mode 100644 index 0000000..8f6cb9b --- /dev/null +++ b/tests/auth_helper.py @@ -0,0 +1,13 @@ +import httpx +import httpx.auth +from pytest_httpx import HTTPXMock + + +# TODO Remove +def get_header(httpx_mock: HTTPXMock, auth: httpx.auth.Auth) -> dict: + # Mock a dummy response + httpx_mock.add_response() + # Send a request to this dummy URL with authentication + response = httpx.get("http://authorized_only", auth=auth) + # Return headers received on this dummy URL + return response.request.headers diff --git a/tests/test_add_operator.py b/tests/test_add_operator.py new file mode 100644 index 0000000..a59a232 --- /dev/null +++ b/tests/test_add_operator.py @@ -0,0 +1,388 @@ +import datetime + +from pytest_httpx import httpx_mock, HTTPXMock +import httpx + +import httpx_auth +from httpx_auth.testing import BrowserMock, create_token, token_cache, browser_mock +from tests.auth_helper import get_header + + +def test_basic_and_api_key_authentication_can_be_combined(httpx_mock: HTTPXMock): + basic_auth = httpx_auth.Basic("test_user", "test_pwd") + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + header = get_header(httpx_mock, basic_auth + api_key_auth) + assert header.get("Authorization") == "Basic dGVzdF91c2VyOnRlc3RfcHdk" + assert header.get("X-Api-Key") == "my_provided_api_key" + + +def test_header_api_key_and_multiple_authentication_can_be_combined( + token_cache, httpx_mock: HTTPXMock +): + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + api_key_auth2 = httpx_auth.HeaderApiKey( + "my_provided_api_key2", header_name="X-Api-Key2" + ) + api_key_auth3 = httpx_auth.HeaderApiKey( + "my_provided_api_key3", header_name="X-Api-Key3" + ) + header = get_header(httpx_mock, api_key_auth + (api_key_auth2 + api_key_auth3)) + assert header.get("X-Api-Key") == "my_provided_api_key" + assert header.get("X-Api-Key2") == "my_provided_api_key2" + assert header.get("X-Api-Key3") == "my_provided_api_key3" + + +def test_multiple_auth_and_header_api_key_can_be_combined( + token_cache, httpx_mock: HTTPXMock +): + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + api_key_auth2 = httpx_auth.HeaderApiKey( + "my_provided_api_key2", header_name="X-Api-Key2" + ) + api_key_auth3 = httpx_auth.HeaderApiKey( + "my_provided_api_key3", header_name="X-Api-Key3" + ) + header = get_header(httpx_mock, (api_key_auth + api_key_auth2) + api_key_auth3) + assert header.get("X-Api-Key") == "my_provided_api_key" + assert header.get("X-Api-Key2") == "my_provided_api_key2" + assert header.get("X-Api-Key3") == "my_provided_api_key3" + + +def test_multiple_auth_and_multiple_auth_can_be_combined( + token_cache, httpx_mock: HTTPXMock +): + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + api_key_auth2 = httpx_auth.HeaderApiKey( + "my_provided_api_key2", header_name="X-Api-Key2" + ) + api_key_auth3 = httpx_auth.HeaderApiKey( + "my_provided_api_key3", header_name="X-Api-Key3" + ) + api_key_auth4 = httpx_auth.HeaderApiKey( + "my_provided_api_key4", header_name="X-Api-Key4" + ) + header = get_header( + httpx_mock, (api_key_auth + api_key_auth2) + (api_key_auth3 + api_key_auth4) + ) + assert header.get("X-Api-Key") == "my_provided_api_key" + assert header.get("X-Api-Key2") == "my_provided_api_key2" + assert header.get("X-Api-Key3") == "my_provided_api_key3" + assert header.get("X-Api-Key4") == "my_provided_api_key4" + + +def test_basic_and_multiple_authentication_can_be_combined( + token_cache, httpx_mock: HTTPXMock +): + basic_auth = httpx_auth.Basic("test_user", "test_pwd") + api_key_auth2 = httpx_auth.HeaderApiKey( + "my_provided_api_key2", header_name="X-Api-Key2" + ) + api_key_auth3 = httpx_auth.HeaderApiKey( + "my_provided_api_key3", header_name="X-Api-Key3" + ) + header = get_header(httpx_mock, basic_auth + (api_key_auth2 + api_key_auth3)) + assert header.get("Authorization") == "Basic dGVzdF91c2VyOnRlc3RfcHdk" + assert header.get("X-Api-Key2") == "my_provided_api_key2" + assert header.get("X-Api-Key3") == "my_provided_api_key3" + + +def test_query_api_key_and_multiple_authentication_can_be_combined( + token_cache, httpx_mock: HTTPXMock +): + api_key_auth = httpx_auth.QueryApiKey("my_provided_api_key") + api_key_auth2 = httpx_auth.QueryApiKey( + "my_provided_api_key2", query_parameter_name="api_key2" + ) + api_key_auth3 = httpx_auth.HeaderApiKey( + "my_provided_api_key3", header_name="X-Api-Key3" + ) + + # Mock a dummy response + httpx_mock.add_response( + url="http://authorized_only?api_key=my_provided_api_key&api_key2=my_provided_api_key2", + match_headers={"X-Api-Key3": "my_provided_api_key3"} + ) + # Send a request to this dummy URL with authentication + httpx.get( + "http://authorized_only", auth=api_key_auth + (api_key_auth2 + api_key_auth3) + ) + + +def test_oauth2_resource_owner_password_and_api_key_authentication_can_be_combined( + token_cache, httpx_mock: HTTPXMock +): + resource_owner_password_auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + header = get_header(httpx_mock, resource_owner_password_auth + api_key_auth) + assert header.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" + assert header.get("X-Api-Key") == "my_provided_api_key" + + +def test_oauth2_resource_owner_password_and_multiple_authentication_can_be_combined( + token_cache, httpx_mock: HTTPXMock +): + resource_owner_password_auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + api_key_auth2 = httpx_auth.HeaderApiKey( + "my_provided_api_key2", header_name="X-Api-Key2" + ) + header = get_header( + httpx_mock, resource_owner_password_auth + (api_key_auth + api_key_auth2) + ) + 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" + + +def test_oauth2_client_credential_and_api_key_authentication_can_be_combined( + token_cache, httpx_mock: HTTPXMock +): + resource_owner_password_auth = httpx_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + header = get_header(httpx_mock, resource_owner_password_auth + api_key_auth) + assert header.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" + assert header.get("X-Api-Key") == "my_provided_api_key" + + +def test_oauth2_client_credential_and_multiple_authentication_can_be_combined( + token_cache, httpx_mock: HTTPXMock +): + resource_owner_password_auth = httpx_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + api_key_auth2 = httpx_auth.HeaderApiKey( + "my_provided_api_key2", header_name="X-Api-Key2" + ) + header = get_header( + httpx_mock, resource_owner_password_auth + (api_key_auth + api_key_auth2) + ) + 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" + + +def test_oauth2_authorization_code_and_api_key_authentication_can_be_combined( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + authorization_code_auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + header = get_header(httpx_mock, 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( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + authorization_code_auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + api_key_auth2 = httpx_auth.HeaderApiKey( + "my_provided_api_key2", header_name="X-Api-Key2" + ) + header = get_header( + httpx_mock, authorization_code_auth + (api_key_auth + api_key_auth2) + ) + 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( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock, monkeypatch +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + pkce_auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + header = get_header(httpx_mock, 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( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock, monkeypatch +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + pkce_auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + api_key_auth2 = httpx_auth.HeaderApiKey( + "my_provided_api_key2", header_name="X-Api-Key2" + ) + header = get_header(httpx_mock, pkce_auth + (api_key_auth + api_key_auth2)) + 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( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + implicit_auth = httpx_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 = httpx_auth.HeaderApiKey("my_provided_api_key") + header = get_header(httpx_mock, implicit_auth + api_key_auth) + 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( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + implicit_auth = httpx_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 = httpx_auth.HeaderApiKey("my_provided_api_key") + api_key_auth2 = httpx_auth.HeaderApiKey( + "my_provided_api_key2", header_name="X-Api-Key2" + ) + header = get_header(httpx_mock, implicit_auth + (api_key_auth + api_key_auth2)) + 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_and_operator.py b/tests/test_and_operator.py new file mode 100644 index 0000000..5755bef --- /dev/null +++ b/tests/test_and_operator.py @@ -0,0 +1,388 @@ +import datetime + +from pytest_httpx import httpx_mock, HTTPXMock +import httpx + +import httpx_auth +from httpx_auth.testing import BrowserMock, create_token, token_cache, browser_mock +from tests.auth_helper import get_header + + +def test_basic_and_api_key_authentication_can_be_combined(httpx_mock: HTTPXMock): + basic_auth = httpx_auth.Basic("test_user", "test_pwd") + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + header = get_header(httpx_mock, basic_auth & api_key_auth) + assert header.get("Authorization") == "Basic dGVzdF91c2VyOnRlc3RfcHdk" + assert header.get("X-Api-Key") == "my_provided_api_key" + + +def test_header_api_key_and_multiple_authentication_can_be_combined( + token_cache, httpx_mock: HTTPXMock +): + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + api_key_auth2 = httpx_auth.HeaderApiKey( + "my_provided_api_key2", header_name="X-Api-Key2" + ) + api_key_auth3 = httpx_auth.HeaderApiKey( + "my_provided_api_key3", header_name="X-Api-Key3" + ) + header = get_header(httpx_mock, api_key_auth & (api_key_auth2 & api_key_auth3)) + assert header.get("X-Api-Key") == "my_provided_api_key" + assert header.get("X-Api-Key2") == "my_provided_api_key2" + assert header.get("X-Api-Key3") == "my_provided_api_key3" + + +def test_multiple_auth_and_header_api_key_can_be_combined( + token_cache, httpx_mock: HTTPXMock +): + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + api_key_auth2 = httpx_auth.HeaderApiKey( + "my_provided_api_key2", header_name="X-Api-Key2" + ) + api_key_auth3 = httpx_auth.HeaderApiKey( + "my_provided_api_key3", header_name="X-Api-Key3" + ) + header = get_header(httpx_mock, (api_key_auth & api_key_auth2) & api_key_auth3) + assert header.get("X-Api-Key") == "my_provided_api_key" + assert header.get("X-Api-Key2") == "my_provided_api_key2" + assert header.get("X-Api-Key3") == "my_provided_api_key3" + + +def test_multiple_auth_and_multiple_auth_can_be_combined( + token_cache, httpx_mock: HTTPXMock +): + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + api_key_auth2 = httpx_auth.HeaderApiKey( + "my_provided_api_key2", header_name="X-Api-Key2" + ) + api_key_auth3 = httpx_auth.HeaderApiKey( + "my_provided_api_key3", header_name="X-Api-Key3" + ) + api_key_auth4 = httpx_auth.HeaderApiKey( + "my_provided_api_key4", header_name="X-Api-Key4" + ) + header = get_header( + httpx_mock, (api_key_auth & api_key_auth2) & (api_key_auth3 & api_key_auth4) + ) + assert header.get("X-Api-Key") == "my_provided_api_key" + assert header.get("X-Api-Key2") == "my_provided_api_key2" + assert header.get("X-Api-Key3") == "my_provided_api_key3" + assert header.get("X-Api-Key4") == "my_provided_api_key4" + + +def test_basic_and_multiple_authentication_can_be_combined( + token_cache, httpx_mock: HTTPXMock +): + basic_auth = httpx_auth.Basic("test_user", "test_pwd") + api_key_auth2 = httpx_auth.HeaderApiKey( + "my_provided_api_key2", header_name="X-Api-Key2" + ) + api_key_auth3 = httpx_auth.HeaderApiKey( + "my_provided_api_key3", header_name="X-Api-Key3" + ) + header = get_header(httpx_mock, basic_auth & (api_key_auth2 & api_key_auth3)) + assert header.get("Authorization") == "Basic dGVzdF91c2VyOnRlc3RfcHdk" + assert header.get("X-Api-Key2") == "my_provided_api_key2" + assert header.get("X-Api-Key3") == "my_provided_api_key3" + + +def test_query_api_key_and_multiple_authentication_can_be_combined( + token_cache, httpx_mock: HTTPXMock +): + api_key_auth = httpx_auth.QueryApiKey("my_provided_api_key") + api_key_auth2 = httpx_auth.QueryApiKey( + "my_provided_api_key2", query_parameter_name="api_key2" + ) + api_key_auth3 = httpx_auth.HeaderApiKey( + "my_provided_api_key3", header_name="X-Api-Key3" + ) + + # Mock a dummy response + httpx_mock.add_response( + url="http://authorized_only?api_key=my_provided_api_key&api_key2=my_provided_api_key2", + headers={"X-Api-Key3": "my_provided_api_key3"} + ) + # Send a request to this dummy URL with authentication + httpx.get( + "http://authorized_only", auth=api_key_auth & (api_key_auth2 & api_key_auth3) + ) + + +def test_oauth2_resource_owner_password_and_api_key_authentication_can_be_combined( + token_cache, httpx_mock: HTTPXMock +): + resource_owner_password_auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + header = get_header(httpx_mock, resource_owner_password_auth & api_key_auth) + assert header.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" + assert header.get("X-Api-Key") == "my_provided_api_key" + + +def test_oauth2_resource_owner_password_and_multiple_authentication_can_be_combined( + token_cache, httpx_mock: HTTPXMock +): + resource_owner_password_auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + api_key_auth2 = httpx_auth.HeaderApiKey( + "my_provided_api_key2", header_name="X-Api-Key2" + ) + header = get_header( + httpx_mock, resource_owner_password_auth & (api_key_auth & api_key_auth2) + ) + 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" + + +def test_oauth2_client_credential_and_api_key_authentication_can_be_combined( + token_cache, httpx_mock: HTTPXMock +): + resource_owner_password_auth = httpx_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + header = get_header(httpx_mock, resource_owner_password_auth & api_key_auth) + assert header.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" + assert header.get("X-Api-Key") == "my_provided_api_key" + + +def test_oauth2_client_credential_and_multiple_authentication_can_be_combined( + token_cache, httpx_mock: HTTPXMock +): + resource_owner_password_auth = httpx_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + api_key_auth2 = httpx_auth.HeaderApiKey( + "my_provided_api_key2", header_name="X-Api-Key2" + ) + header = get_header( + httpx_mock, resource_owner_password_auth & (api_key_auth & api_key_auth2) + ) + 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" + + +def test_oauth2_authorization_code_and_api_key_authentication_can_be_combined( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + authorization_code_auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + header = get_header(httpx_mock, 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( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + authorization_code_auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + api_key_auth2 = httpx_auth.HeaderApiKey( + "my_provided_api_key2", header_name="X-Api-Key2" + ) + header = get_header( + httpx_mock, authorization_code_auth & (api_key_auth & api_key_auth2) + ) + 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( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock, monkeypatch +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + pkce_auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + header = get_header(httpx_mock, 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( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock, monkeypatch +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + pkce_auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + api_key_auth2 = httpx_auth.HeaderApiKey( + "my_provided_api_key2", header_name="X-Api-Key2" + ) + header = get_header(httpx_mock, pkce_auth & (api_key_auth & api_key_auth2)) + 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( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + implicit_auth = httpx_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 = httpx_auth.HeaderApiKey("my_provided_api_key") + header = get_header(httpx_mock, implicit_auth & api_key_auth) + 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( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + implicit_auth = httpx_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 = httpx_auth.HeaderApiKey("my_provided_api_key") + api_key_auth2 = httpx_auth.HeaderApiKey( + "my_provided_api_key2", header_name="X-Api-Key2" + ) + header = get_header(httpx_mock, implicit_auth & (api_key_auth & api_key_auth2)) + 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_api_key.py b/tests/test_api_key.py new file mode 100644 index 0000000..43601b5 --- /dev/null +++ b/tests/test_api_key.py @@ -0,0 +1,45 @@ +import pytest +from pytest_httpx import httpx_mock, HTTPXMock +import httpx + + +import httpx_auth +from tests.auth_helper import get_header + + +def test_header_api_key_requires_an_api_key(): + with pytest.raises(Exception) as exception_info: + httpx_auth.HeaderApiKey(None) + assert str(exception_info.value) == "API Key is mandatory." + + +def test_query_api_key_requires_an_api_key(): + with pytest.raises(Exception) as exception_info: + httpx_auth.QueryApiKey(None) + assert str(exception_info.value) == "API Key is mandatory." + + +def test_header_api_key_is_sent_in_x_api_key_by_default(httpx_mock: HTTPXMock): + auth = httpx_auth.HeaderApiKey("my_provided_api_key") + assert get_header(httpx_mock, auth).get("X-Api-Key") == "my_provided_api_key" + + +def test_query_api_key_is_sent_in_api_key_by_default(httpx_mock: HTTPXMock): + auth = httpx_auth.QueryApiKey("my_provided_api_key") + # Mock a dummy response + httpx_mock.add_response(url="http://authorized_only?api_key=my_provided_api_key") + # Send a request to this dummy URL with authentication + httpx.get("http://authorized_only", auth=auth) + + +def test_header_api_key_can_be_sent_in_a_custom_field_name(httpx_mock: HTTPXMock): + auth = httpx_auth.HeaderApiKey("my_provided_api_key", "X-API-HEADER-KEY") + assert get_header(httpx_mock, auth).get("X-Api-Header-Key") == "my_provided_api_key" + + +def test_query_api_key_can_be_sent_in_a_custom_field_name(httpx_mock: HTTPXMock): + auth = httpx_auth.QueryApiKey("my_provided_api_key", "X-API-QUERY-KEY") + # Mock a dummy response + httpx_mock.add_response(url="http://authorized_only?X-API-QUERY-KEY=my_provided_api_key") + # Send a request to this dummy URL with authentication + httpx.get("http://authorized_only", auth=auth) diff --git a/tests/test_auths.py b/tests/test_auths.py new file mode 100644 index 0000000..ba6d2e1 --- /dev/null +++ b/tests/test_auths.py @@ -0,0 +1,16 @@ +import pytest +from pytest_httpx import httpx_mock, HTTPXMock + +import httpx_auth +from tests.auth_helper import get_header + + +def test_basic_and_api_key_authentication_can_be_combined_deprecated( + httpx_mock: HTTPXMock, +): + basic_auth = httpx_auth.Basic("test_user", "test_pwd") + api_key_auth = httpx_auth.HeaderApiKey("my_provided_api_key") + with pytest.warns(DeprecationWarning): + header = get_header(httpx_mock, httpx_auth.Auths(basic_auth, api_key_auth)) + assert header.get("Authorization") == "Basic dGVzdF91c2VyOnRlc3RfcHdk" + assert header.get("X-Api-Key") == "my_provided_api_key" diff --git a/tests/test_basic.py b/tests/test_basic.py new file mode 100644 index 0000000..951db26 --- /dev/null +++ b/tests/test_basic.py @@ -0,0 +1,12 @@ +from pytest_httpx import httpx_mock, HTTPXMock + +import httpx_auth +from tests.auth_helper import get_header + + +def test_basic_authentication_send_authorization_header(httpx_mock: HTTPXMock): + auth = httpx_auth.Basic("test_user", "test_pwd") + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Basic dGVzdF91c2VyOnRlc3RfcHdk" + ) diff --git a/tests/test_json_token_file_cache.py b/tests/test_json_token_file_cache.py new file mode 100644 index 0000000..0f90383 --- /dev/null +++ b/tests/test_json_token_file_cache.py @@ -0,0 +1,78 @@ +import logging +import datetime +import sys + +import pytest +import jwt + +import httpx_auth +import httpx_auth.errors + + +@pytest.fixture +def token_cache(request): + _token_cache = httpx_auth.JsonTokenFileCache(request.node.name + ".cache") + yield _token_cache + _token_cache.clear() + + +def test_add_bearer_tokens(token_cache): + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token1 = jwt.encode({"exp": expiry_in_1_hour}, "secret").decode("unicode_escape") + token_cache.add_bearer_token("key1", token1) + + expiry_in_2_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=2) + token2 = jwt.encode({"exp": expiry_in_2_hour}, "secret").decode("unicode_escape") + token_cache.add_bearer_token("key2", token2) + + # Assert that tokens can be retrieved properly even after other token were inserted + assert token_cache.get_token("key1") == token1 + assert token_cache.get_token("key2") == token2 + + # Assert that tokens are not removed from the cache on retrieval + assert token_cache.get_token("key1") == token1 + assert token_cache.get_token("key2") == token2 + + +def test_save_bearer_tokens(token_cache, request): + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token1 = jwt.encode({"exp": expiry_in_1_hour}, "secret").decode("unicode_escape") + token_cache.add_bearer_token("key1", token1) + + expiry_in_2_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=2) + token2 = jwt.encode({"exp": expiry_in_2_hour}, "secret").decode("unicode_escape") + token_cache.add_bearer_token("key2", token2) + + same_cache = httpx_auth.JsonTokenFileCache(request.node.name + ".cache") + assert same_cache.get_token("key1") == token1 + assert same_cache.get_token("key2") == token2 + + +def test_save_bearer_token_exception_handling(token_cache, request, monkeypatch): + def failing_dump(*args): + raise Exception("Failure") + + monkeypatch.setattr(httpx_auth.oauth2_tokens.json, "dump", failing_dump) + + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token1 = jwt.encode({"exp": expiry_in_1_hour}, "secret").decode("unicode_escape") + + # Assert that the exception is not thrown + token_cache.add_bearer_token("key1", token1) + + same_cache = httpx_auth.JsonTokenFileCache(request.node.name + ".cache") + with pytest.raises(httpx_auth.AuthenticationFailed) as exception_info: + same_cache.get_token("key1") + assert str(exception_info.value) == "User was not authenticated." + + +def test_missing_token(token_cache): + with pytest.raises(httpx_auth.AuthenticationFailed): + token_cache.get_token("key1") + + +def test_missing_token_function(token_cache): + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token = jwt.encode({"exp": expiry_in_1_hour}, "secret").decode("unicode_escape") + retrieved_token = token_cache.get_token("key1", lambda: ("key1", token)) + assert retrieved_token == token diff --git a/tests/test_oauth2_authorization_code.py b/tests/test_oauth2_authorization_code.py new file mode 100644 index 0000000..433fdaa --- /dev/null +++ b/tests/test_oauth2_authorization_code.py @@ -0,0 +1,673 @@ +from pytest_httpx import httpx_mock, HTTPXMock +import pytest +import httpx + +import httpx_auth +from httpx_auth.testing import BrowserMock, browser_mock, token_cache +from tests.auth_helper import get_header + + +def test_oauth2_authorization_code_flow_get_code_is_sent_in_authorization_header_by_default( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_content=b"grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" + ) + httpx_mock.add_response(match_headers={"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"}) + # Send a request to this dummy URL with authentication + httpx.get("http://authorized_only", auth=auth) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_empty_token_is_invalid( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + with pytest.raises(httpx_auth.GrantNotProvided) as exception_info: + httpx.get("http://authorized_only", auth=auth) + assert ( + 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_with_invalid_grant_request_no_json( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + data="failure", + status_code=400 + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_request"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_request", "error_description": "desc of the error"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + }, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + "other": "other info", + }, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"other": "other info"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_client"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_grant"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "unauthorized_client"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "unsupported_grant_type"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_scope"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_content=b"grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" + ) + # Mock a dummy response + httpx_mock.add_response(match_headers={"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"}) + # Send a request to this dummy URL with authentication + httpx.get("http://authorized_only", auth=auth) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_response_type_can_be_provided_in_url( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.OAuth2AuthorizationCode( + "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=my_code&state=49b67a19e70f692c3fc09dd124e5782b41a86f4f4931e1cc938ccbb466eecf1b730edb9eb01e42005de77ce3dd5a016418f8e780f30c4477d71102fe03e39e62&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=49b67a19e70f692c3fc09dd124e5782b41a86f4f4931e1cc938ccbb466eecf1b730edb9eb01e42005de77ce3dd5a016418f8e780f30c4477d71102fe03e39e62", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_content=b"grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code=SplxlOBeZQQYbYS6WxSbIA" + ) + # Mock a dummy response + httpx_mock.add_response(match_headers={"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"}) + # Send a request to this dummy URL with authentication + httpx.get("http://authorized_only", auth=auth) + tab.assert_success( + "You are now authenticated on 49b67a19e70f692c3fc09dd124e5782b41a86f4f4931e1cc938ccbb466eecf1b730edb9eb01e42005de77ce3dd5a016418f8e780f30c4477d71102fe03e39e62. You may close this tab." + ) + + +def test_authorization_url_is_mandatory(): + with pytest.raises(Exception) as exception_info: + httpx_auth.OAuth2AuthorizationCode("", "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: + httpx_auth.OAuth2AuthorizationCode("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: + httpx_auth.OAuth2AuthorizationCode( + "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_authorization_code_okta.py b/tests/test_oauth2_authorization_code_okta.py new file mode 100644 index 0000000..4eb702e --- /dev/null +++ b/tests/test_oauth2_authorization_code_okta.py @@ -0,0 +1,600 @@ +from pytest_httpx import httpx_mock, HTTPXMock +import pytest +import httpx + +import httpx_auth +from httpx_auth.testing import BrowserMock, browser_mock, token_cache +from tests.auth_helper import get_header + + +def test_oauth2_authorization_code_flow_get_code_is_sent_in_authorization_header_by_default( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_content=b"grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + tab.assert_success( + "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_empty_token_is_invalid( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={ + "access_token": "", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + with pytest.raises(httpx_auth.GrantNotProvided) as exception_info: + httpx.get("http://authorized_only", auth=auth) + assert ( + 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 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_no_json( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + data="failure", + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "failure" + tab.assert_success( + "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={"error": "invalid_request"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={"error": "invalid_request", "error_description": "desc of the error"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + }, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri_and_other_fields( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + "other": "other info", + }, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_without_error( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={"other": "other info"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "{'other': 'other info'}" + tab.assert_success( + "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_client_error( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={"error": "invalid_client"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_grant_error( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={"error": "invalid_grant"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_unauthorized_client_error( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={"error": "unauthorized_client"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_unsupported_grant_type_error( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={"error": "unsupported_grant_type"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_scope_error( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={"error": "invalid_scope"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_token_request_invalid_request_error( + token_cache, browser_mock: BrowserMock +): + auth = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=invalid_request", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=invalid_request&error_description=desc", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=invalid_request&error_description=desc&error_uri=http://test_url", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=invalid_request&error_description=desc&error_uri=http://test_url&other=test", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=unauthorized_client", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=access_denied", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=unsupported_response_type", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=invalid_scope", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=server_error", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=temporarily_unavailable", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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_header_value_must_contains_token(): + with pytest.raises(Exception) as exception_info: + httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", + "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + header_value="Bearer token", + ) + assert str(exception_info.value) == "header_value parameter must contains {token}." diff --git a/tests/test_oauth2_authorization_code_pkce.py b/tests/test_oauth2_authorization_code_pkce.py new file mode 100644 index 0000000..233bc59 --- /dev/null +++ b/tests/test_oauth2_authorization_code_pkce.py @@ -0,0 +1,698 @@ +from pytest_httpx import httpx_mock, HTTPXMock +import pytest +import httpx + +import httpx_auth +from tests.auth_helper import get_header +from httpx_auth.testing import BrowserMock, browser_mock, token_cache + + +def test_oauth2_pkce_flow_get_code_is_sent_in_authorization_header_by_default( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_content=b"code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_expires_in_sent_as_str( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": "3600", + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_content=b"code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_nonce_is_sent_if_provided_in_authorization_url( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_content=b"code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + tab.assert_success( + "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + ) + + +def test_with_invalid_grant_request_no_json( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + data="failure", + status_code=400 + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_request"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_request", "error_description": "desc of the error"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + }, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + "other": "other info", + }, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"other": "other info"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_client"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_grant"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "unauthorized_client"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "unsupported_grant_type"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_scope"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_content=b"code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=my_code&code=SplxlOBeZQQYbYS6WxSbIA" + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + 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: + httpx_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: + httpx_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: + httpx_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_authorization_code_pkce_okta.py b/tests/test_oauth2_authorization_code_pkce_okta.py new file mode 100644 index 0000000..84fbb1c --- /dev/null +++ b/tests/test_oauth2_authorization_code_pkce_okta.py @@ -0,0 +1,622 @@ +from pytest_httpx import httpx_mock, HTTPXMock +import pytest +import httpx + +import httpx_auth +from tests.auth_helper import get_header +from httpx_auth.testing import BrowserMock, browser_mock, token_cache + + +def test_oauth2_pkce_flow_get_code_is_sent_in_authorization_header_by_default( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_content=b"code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + tab.assert_success( + "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_expires_in_sent_as_str( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": "3600", + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_content=b"code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + tab.assert_success( + "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_no_json( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + data="failure", + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "failure" + tab.assert_success( + "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={"error": "invalid_request"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={"error": "invalid_request", "error_description": "desc of the error"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + }, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri_and_other_fields( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + "other": "other info", + }, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_without_error( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={"other": "other info"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "{'other': 'other info'}" + tab.assert_success( + "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_client_error( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={"error": "invalid_client"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_grant_error( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={"error": "invalid_grant"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_unauthorized_client_error( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={"error": "unauthorized_client"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_unsupported_grant_type_error( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={"error": "unsupported_grant_type"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_scope_error( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + httpx_mock.add_response( + method="POST", + url="https://testserver.okta-emea.com/oauth2/default/v1/token", + json={"error": "invalid_scope"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + ) + + +def test_with_invalid_token_request_invalid_request_error( + token_cache, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#error=invalid_request", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#error=invalid_request&error_description=desc", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#error=invalid_request&error_description=desc&error_uri=http://test_url", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#error=invalid_request&error_description=desc&error_uri=http://test_url&other=test", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#error=unauthorized_client", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#error=access_denied", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#error=unsupported_response_type", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#error=invalid_scope", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#error=server_error", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr(httpx_auth.authentication.os, "urandom", lambda x: b"1" * 63) + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + tab = browser_mock.add_response( + opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#error=temporarily_unavailable", + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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_header_value_must_contains_token(): + with pytest.raises(Exception) as exception_info: + httpx_auth.OktaAuthorizationCodePKCE( + "test_url", + "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + header_value="Bearer token", + ) + assert str(exception_info.value) == "header_value parameter must contains {token}." diff --git a/tests/test_oauth2_client_credential.py b/tests/test_oauth2_client_credential.py new file mode 100644 index 0000000..c26ece9 --- /dev/null +++ b/tests/test_oauth2_client_credential.py @@ -0,0 +1,309 @@ +from pytest_httpx import httpx_mock, HTTPXMock +import pytest +import httpx + +import httpx_auth +from tests.auth_helper import get_header +from httpx_auth.testing import token_cache + + +def test_oauth2_client_credentials_flow_token_is_sent_in_authorization_header_by_default( + token_cache, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + + +def test_expires_in_sent_as_str(token_cache, httpx_mock: HTTPXMock): + auth = httpx_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": "3600", + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + + +def test_with_invalid_grant_request_no_json(token_cache, httpx_mock: HTTPXMock): + auth = httpx_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + data="failure", + status_code=400 + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "failure" + + +def test_with_invalid_grant_request_invalid_request_error( + token_cache, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_request"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_request", "error_description": "desc of the error"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + }, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + "other": "other info", + }, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock): + auth = httpx_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"other": "other info"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_client"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_grant"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "unauthorized_client"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "unsupported_grant_type"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ClientCredentials( + "http://provide_access_token", client_id="test_user", client_secret="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_scope"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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: + httpx_auth.OAuth2ClientCredentials("", "test_user", "test_pwd") + assert str(exception_info.value) == "Token URL is mandatory." + + +def test_client_id_is_mandatory(): + with pytest.raises(Exception) as exception_info: + httpx_auth.OAuth2ClientCredentials("http://test_url", "", "test_pwd") + assert str(exception_info.value) == "client_id is mandatory." + + +def test_client_secret_is_mandatory(): + with pytest.raises(Exception) as exception_info: + httpx_auth.OAuth2ClientCredentials("http://test_url", "test_user", "") + assert str(exception_info.value) == "client_secret is mandatory." + + +def test_header_value_must_contains_token(): + with pytest.raises(Exception) as exception_info: + httpx_auth.OAuth2ClientCredentials( + "http://test_url", "test_user", "test_pwd", header_value="Bearer token" + ) + assert str(exception_info.value) == "header_value parameter must contains {token}." diff --git a/tests/test_oauth2_client_credential_okta.py b/tests/test_oauth2_client_credential_okta.py new file mode 100644 index 0000000..b583a49 --- /dev/null +++ b/tests/test_oauth2_client_credential_okta.py @@ -0,0 +1,49 @@ +from pytest_httpx import httpx_mock, HTTPXMock + +import httpx_auth +from tests.auth_helper import get_header +from httpx_auth.testing import token_cache + + +def test_okta_client_credentials_flow_token_is_sent_in_authorization_header_by_default( + token_cache, httpx_mock: HTTPXMock +): + auth = httpx_auth.OktaClientCredentials( + "test_okta", client_id="test_user", client_secret="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="https://test_okta/oauth2/default/v1/token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + + +def test_expires_in_sent_as_str(token_cache, httpx_mock: HTTPXMock): + auth = httpx_auth.OktaClientCredentials( + "test_okta", client_id="test_user", client_secret="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="https://test_okta/oauth2/default/v1/token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": "3600", + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) diff --git a/tests/test_oauth2_implicit.py b/tests/test_oauth2_implicit.py new file mode 100644 index 0000000..90213c0 --- /dev/null +++ b/tests/test_oauth2_implicit.py @@ -0,0 +1,713 @@ +import time +import datetime + +import httpx +import pytest +from pytest_httpx import httpx_mock, HTTPXMock + +from httpx_auth.testing import BrowserMock, create_token, token_cache, browser_mock +from tests.auth_helper import get_header +import httpx_auth + + +def test_oauth2_implicit_flow_url_is_mandatory(): + with pytest.raises(Exception) as exception_info: + httpx_auth.OAuth2Implicit(None) + assert str(exception_info.value) == "Authorization URL is mandatory." + + +def test_header_value_must_contains_token(): + with pytest.raises(Exception) as exception_info: + httpx_auth.OAuth2Implicit("http://test_url", header_value="Bearer token") + assert str(exception_info.value) == "header_value parameter must contains {token}." + + +def test_oauth2_implicit_flow_token_is_not_reused_if_a_url_parameter_is_changing( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth1 = httpx_auth.OAuth2Implicit( + "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", + ) + + assert get_header(httpx_mock, auth1).get("Authorization") == f"Bearer {first_token}" + + # Ensure that the new token is different than previous one + expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta( + hours=1, seconds=1 + ) + + auth2 = httpx_auth.OAuth2Implicit( + "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 = httpx.get("http://authorized_only", auth=auth2) + # Return headers received on this dummy URL + 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( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth1 = httpx_auth.OAuth2Implicit( + "http://provide_token?response_type=custom_token&nonce=1", + token_field_name="custom_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=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(httpx_mock, auth1).get("Authorization") == f"Bearer {token}" + + auth2 = httpx_auth.OAuth2Implicit( + "http://provide_token?response_type=custom_token&nonce=2", + token_field_name="custom_token", + ) + response = httpx.get("http://authorized_only", auth=auth2) + # Return headers received on this dummy URL + 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( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + # TODO Should use a method to retrieve a free port instead + available_port = 5002 + auth = httpx_auth.OAuth2Implicit( + "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(httpx_mock, auth).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_token_is_sent_in_authorization_header_by_default( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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(httpx_mock, auth).get("Authorization") == f"Bearer {token}" + tab.assert_success( + "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." + ) + + +def test_browser_opening_failure(token_cache, httpx_mock: HTTPXMock, monkeypatch): + import httpx_auth.oauth2_authentication_responses_server + + auth = httpx_auth.OAuth2Implicit("http://provide_token", timeout=0.1) + + class FakeBrowser: + def open(self, url, new): + return False + + monkeypatch.setattr( + httpx_auth.oauth2_authentication_responses_server.webbrowser, + "get", + lambda *args: FakeBrowser(), + ) + + httpx_mock.add_response( + method="GET", + url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + ) + with pytest.raises(httpx_auth.TimeoutOccurred) as exception_info: + httpx.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "User authentication was not received within 0.1 seconds." + ) + + +def test_browser_error(token_cache, httpx_mock: HTTPXMock, monkeypatch): + import httpx_auth.oauth2_authentication_responses_server + + auth = httpx_auth.OAuth2Implicit("http://provide_token", timeout=0.1) + + class FakeBrowser: + def open(self, url, new): + import webbrowser + + raise webbrowser.Error("Failure") + + monkeypatch.setattr( + httpx_auth.oauth2_authentication_responses_server.webbrowser, + "get", + lambda *args: FakeBrowser(), + ) + + httpx_mock.add_response( + method="GET", + url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + ) + with pytest.raises(httpx_auth.TimeoutOccurred) as exception_info: + httpx.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "User authentication was not received within 0.1 seconds." + ) + + +def test_state_change(token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock): + auth = httpx_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 get_header(httpx_mock, 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(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(httpx_auth.InvalidToken) as exception_info: + httpx.get( + "http://authorized_only", + auth=httpx_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(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(httpx_auth.TokenExpiryNotProvided) as exception_info: + httpx.get( + "http://authorized_only", + auth=httpx_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( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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(httpx_mock, auth).get("Authorization") == f"Bearer {token}" + tab.assert_success( + "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." + ) + + +def test_oauth2_implicit_flow_token_is_sent_in_requested_field( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.OAuth2Implicit( + "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(httpx_mock, auth).get("Bearer") == token + tab.assert_success( + "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." + ) + + +def test_oauth2_implicit_flow_can_send_a_custom_response_type_and_expects_token_to_be_received_with_this_name( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.OAuth2Implicit( + "http://provide_token", + response_type="custom_token", + token_field_name="custom_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=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(httpx_mock, 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( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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(httpx_mock, auth).get("Authorization") == f"Bearer {token}" + tab.assert_success( + "You are now authenticated on 87c4108ec0eb03599335333a40434a36674269690b6957fef684bfb6c5a849ce660ef7031aa874c44d67cd3eada8febdfce41efb1ed3bc53a0a7e716cbba025a. You may close this tab." + ) + + +def test_oauth2_implicit_flow_expects_token_in_id_token_if_response_type_in_url_is_id_token( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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(httpx_mock, auth).get("Authorization") == f"Bearer {token}" + tab.assert_success( + "You are now authenticated on 87c4108ec0eb03599335333a40434a36674269690b6957fef684bfb6c5a849ce660ef7031aa874c44d67cd3eada8febdfce41efb1ed3bc53a0a7e716cbba025a. You may close this tab." + ) + + +def test_oauth2_implicit_flow_expects_token_to_be_stored_in_access_token_by_default( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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(httpx_mock, auth).get("Authorization") == f"Bearer {token}" + tab.assert_success( + "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." + ) + + +def test_oauth2_implicit_flow_token_is_reused_if_not_expired( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth1 = httpx_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(httpx_mock, auth1).get("Authorization") == f"Bearer {token}" + + oauth2 = httpx_auth.OAuth2Implicit("http://provide_token") + response = httpx.get("http://authorized_only", auth=oauth2) + # Return headers received on this dummy URL + 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( + 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: + httpx.get( + "http://authorized_only", + auth=httpx_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( + 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: + httpx.get( + "http://authorized_only", + auth=httpx_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( + 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(httpx_auth.StateNotProvided) as exception_info: + httpx.get( + "http://authorized_only", + auth=httpx_auth.OAuth2Implicit("http://provide_token"), + ) + 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( + 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(httpx_auth.StateNotProvided) as exception_info: + httpx.get( + "http://authorized_only", + auth=httpx_auth.OAuth2Implicit("http://provide_token"), + ) + assert ( + str(exception_info.value) + == f"state not provided within {{'access_token': ['{token}'], 'httpx_auth_redirect': ['1']}}." + ) + tab.assert_failure( + f"Unable to properly perform authentication: state not provided within {{'access_token': ['{token}'], 'httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get( + "http://authorized_only", + auth=httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get( + "http://authorized_only", + auth=httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get( + "http://authorized_only", + auth=httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get( + "http://authorized_only", + auth=httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get( + "http://authorized_only", + auth=httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get( + "http://authorized_only", + auth=httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get( + "http://authorized_only", + auth=httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get( + "http://authorized_only", + auth=httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get( + "http://authorized_only", + auth=httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get( + "http://authorized_only", + auth=httpx_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( + 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(httpx_auth.TimeoutOccurred) as exception_info: + httpx.get( + "http://authorized_only", + auth=httpx_auth.OAuth2Implicit("http://provide_token", timeout=0.1), + ) + 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( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.OAuth2Implicit("http://provide_token") + # This token will expires in 100 milliseconds + expiry_in_1_second = datetime.datetime.utcnow() + datetime.timedelta( + milliseconds=100 + ) + 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(httpx_mock, auth).get("Authorization") == f"Bearer {first_token}" + + # 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 + 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 = httpx.get("http://authorized_only", auth=auth) + # Return headers received on this dummy URL + 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 new file mode 100644 index 0000000..d7d0f13 --- /dev/null +++ b/tests/test_oauth2_implicit_azure_active_directory.py @@ -0,0 +1,26 @@ +import httpx_auth + + +def test_corresponding_oauth2_implicit_flow_instance(monkeypatch): + monkeypatch.setattr( + httpx_auth.authentication.uuid, + "uuid4", + lambda *args: "27ddfeed4e-854b-4361-8e7a-eab371c9bc91", + ) + aad = httpx_auth.AzureActiveDirectoryImplicit( + "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 ( + aad.authorization_url + == "https://login.microsoftonline.com/45239d18-c68c-4c47-8bdd-ce71ea1d50cd/oauth2/authorize" + ) + assert aad.grant_details.name == "access_token" diff --git a/tests/test_oauth2_implicit_id_token_azure_active_directory.py b/tests/test_oauth2_implicit_id_token_azure_active_directory.py new file mode 100644 index 0000000..3fd5441 --- /dev/null +++ b/tests/test_oauth2_implicit_id_token_azure_active_directory.py @@ -0,0 +1,26 @@ +import httpx_auth + + +def test_corresponding_oauth2_implicit_flow_id_token_instance(monkeypatch): + monkeypatch.setattr( + httpx_auth.authentication.uuid, + "uuid4", + lambda *args: "27ddfeed4e-854b-4361-8e7a-eab371c9bc91", + ) + aad = httpx_auth.AzureActiveDirectoryImplicitIdToken( + "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=id_token" + "&state=c141cf16f45343f37ca8053b6d0c67bad30a777b00221132d5a4514dd23082994e553a9f9fb45224ab9c2da3380047b32948fc2bf233efddc2fbd5801fc1d2d9" + "&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F" + "&nonce=%5B%2727ddfeed4e-854b-4361-8e7a-eab371c9bc91%27%5D" + ) + assert ( + 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_id_token_okta.py b/tests/test_oauth2_implicit_id_token_okta.py new file mode 100644 index 0000000..fa75e5c --- /dev/null +++ b/tests/test_oauth2_implicit_id_token_okta.py @@ -0,0 +1,27 @@ +import httpx_auth + + +def test_corresponding_oauth2_implicit_flow_id_token_instance(monkeypatch): + monkeypatch.setattr( + httpx_auth.authentication.uuid, + "uuid4", + lambda *args: "27ddfeed4e-854b-4361-8e7a-eab371c9bc91", + ) + okta = httpx_auth.OktaImplicitIdToken( + "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" + "&response_type=id_token" + "&scope=openid+profile+email" + "&state=7579d97df20e73313aee14fb698cf9a23b7990dc18f0b8d16e459d25524e3451c96b20d5a4ea3ef21b0cb9413fb6f931f7e1789dc9fc65c53042d792fee7f237" + "&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F" + "&nonce=%5B%2727ddfeed4e-854b-4361-8e7a-eab371c9bc91%27%5D" + ) + assert ( + 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_implicit_okta.py b/tests/test_oauth2_implicit_okta.py new file mode 100644 index 0000000..c453c63 --- /dev/null +++ b/tests/test_oauth2_implicit_okta.py @@ -0,0 +1,27 @@ +import httpx_auth + + +def test_corresponding_oauth2_implicit_flow_instance(monkeypatch): + monkeypatch.setattr( + httpx_auth.authentication.uuid, + "uuid4", + lambda *args: "27ddfeed4e-854b-4361-8e7a-eab371c9bc91", + ) + okta = httpx_auth.OktaImplicit( + "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 ( + okta.authorization_url + == "https://testserver.okta-emea.com/oauth2/default/v1/authorize" + ) + assert okta.grant_details.name == "access_token" diff --git a/tests/test_oauth2_resource_owner_password.py b/tests/test_oauth2_resource_owner_password.py new file mode 100644 index 0000000..710f79a --- /dev/null +++ b/tests/test_oauth2_resource_owner_password.py @@ -0,0 +1,395 @@ +from pytest_httpx import httpx_mock, HTTPXMock +import pytest +import httpx + +import httpx_auth +from tests.auth_helper import get_header +from httpx_auth.testing import token_cache + + +def test_oauth2_password_credentials_flow_token_is_sent_in_authorization_header_by_default( + token_cache, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_content=b"grant_type=password&username=test_user&password=test_pwd" + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + + +def test_expires_in_sent_as_str(token_cache, httpx_mock: HTTPXMock): + auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": "3600", + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_content=b"grant_type=password&username=test_user&password=test_pwd" + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + + +def test_scope_is_sent_as_is_when_provided_as_str(token_cache, httpx_mock: HTTPXMock): + auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", + username="test_user", + password="test_pwd", + scope="my_scope+my_other_scope", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_content=b"grant_type=password&username=test_user&password=test_pwd&scope=my_scope%2Bmy_other_scope" + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + + +def test_scope_is_sent_as_str_when_provided_as_list( + token_cache, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", + username="test_user", + password="test_pwd", + scope=["my_scope", "my_other_scope"], + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match_content=b"grant_type=password&username=test_user&password=test_pwd&scope=my_scope+my_other_scope" + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + + +def test_with_invalid_grant_request_no_json(token_cache, httpx_mock: HTTPXMock): + auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + data="failure", + status_code=400 + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get("http://authorized_only", auth=auth) + assert str(exception_info.value) == "failure" + + +def test_with_invalid_grant_request_invalid_request_error( + token_cache, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_request"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_request", "error_description": "desc of the error"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + }, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "http://test_url", + "other": "other info", + }, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock): + auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"other": "other info"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_client"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_grant"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "unauthorized_client"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "unsupported_grant_type"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock +): + auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", username="test_user", password="test_pwd" + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={"error": "invalid_scope"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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, httpx_mock: HTTPXMock): + auth = httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://provide_access_token", + username="test_user", + password="test_pwd", + token_field_name="not_provided", + ) + httpx_mock.add_response( + method="POST", + url="http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + ) + with pytest.raises(httpx_auth.GrantNotProvided) as exception_info: + httpx.get("http://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "not_provided not provided within {'access_token': '2YotnFZFEjr1zCsicMWpAA', 'token_type': 'example', 'expires_in': 3600, 'refresh_token': 'tGzv3JOkF0XG5Qx2TlKWIA', 'example_parameter': 'example_value'}." + ) + + +def test_token_url_is_mandatory(): + with pytest.raises(Exception) as exception_info: + httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "", "test_user", "test_pwd" + ) + assert str(exception_info.value) == "Token URL is mandatory." + + +def test_user_name_is_mandatory(): + with pytest.raises(Exception) as exception_info: + httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://test_url", "", "test_pwd" + ) + assert str(exception_info.value) == "User name is mandatory." + + +def test_password_is_mandatory(): + with pytest.raises(Exception) as exception_info: + httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://test_url", "test_user", "" + ) + assert str(exception_info.value) == "Password is mandatory." + + +def test_header_value_must_contains_token(): + with pytest.raises(Exception) as exception_info: + httpx_auth.OAuth2ResourceOwnerPasswordCredentials( + "http://test_url", "test_user", "test_pwd", header_value="Bearer token" + ) + assert str(exception_info.value) == "header_value parameter must contains {token}." diff --git a/tests/test_temporary.py b/tests/test_temporary.py deleted file mode 100644 index 205bc8e..0000000 --- a/tests/test_temporary.py +++ /dev/null @@ -1,5 +0,0 @@ -import httpx_auth - - -def test_temp(): - assert True diff --git a/tests/test_testing_oauth2_authorization_code.py b/tests/test_testing_oauth2_authorization_code.py new file mode 100644 index 0000000..50932f9 --- /dev/null +++ b/tests/test_testing_oauth2_authorization_code.py @@ -0,0 +1,51 @@ +import pytest +from pytest_httpx import httpx_mock, HTTPXMock + +import httpx_auth +from httpx_auth.testing import token_cache_mock +from tests.auth_helper import get_header + + +@pytest.fixture +def token_mock() -> str: + return "2YotnFZFEjr1zCsicMWpAA" + + +def test_oauth2_authorization_code_flow(token_cache_mock, httpx_mock: HTTPXMock): + auth = httpx_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + + +def test_okta_authorization_code_flow(token_cache_mock, httpx_mock: HTTPXMock): + auth = httpx_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + + +def test_oauth2_authorization_code_pkce_flow(token_cache_mock, httpx_mock: HTTPXMock): + auth = httpx_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code", "http://provide_access_token" + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + + +def test_okta_authorization_code_pkce_flow(token_cache_mock, httpx_mock: HTTPXMock): + auth = httpx_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) diff --git a/tests/test_testing_oauth2_implicit.py b/tests/test_testing_oauth2_implicit.py new file mode 100644 index 0000000..7abab34 --- /dev/null +++ b/tests/test_testing_oauth2_implicit.py @@ -0,0 +1,59 @@ +import pytest +from pytest_httpx import httpx_mock, HTTPXMock + +import httpx_auth +from httpx_auth.testing import token_cache_mock +from tests.auth_helper import get_header + + +@pytest.fixture +def token_mock() -> str: + return "2YotnFZFEjr1zCsicMWpAA" + + +def test_oauth2_implicit_flow(token_cache_mock, httpx_mock: HTTPXMock): + auth = httpx_auth.OAuth2Implicit("http://provide_token") + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + + +def test_okta_implicit_flow(token_cache_mock, httpx_mock: HTTPXMock): + auth = httpx_auth.OktaImplicit( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + + +def test_aad_implicit_flow(token_cache_mock, httpx_mock: HTTPXMock): + auth = httpx_auth.AzureActiveDirectoryImplicit( + "45239d18-c68c-4c47-8bdd-ce71ea1d50cd", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + + +def test_okta_implicit_id_token_flow(token_cache_mock, httpx_mock: HTTPXMock): + auth = httpx_auth.OktaImplicitIdToken( + "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) + + +def test_aad_implicit_id_token_flow(token_cache_mock, httpx_mock: HTTPXMock): + auth = httpx_auth.AzureActiveDirectoryImplicitIdToken( + "45239d18-c68c-4c47-8bdd-ce71ea1d50cd", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + ) + assert ( + get_header(httpx_mock, auth).get("Authorization") + == "Bearer 2YotnFZFEjr1zCsicMWpAA" + ) diff --git a/tests/test_testing_token_mock.py b/tests/test_testing_token_mock.py new file mode 100644 index 0000000..7b96d0f --- /dev/null +++ b/tests/test_testing_token_mock.py @@ -0,0 +1,13 @@ +from pytest_httpx import httpx_mock, HTTPXMock + +import httpx_auth +from httpx_auth.testing import token_cache_mock, token_mock +from tests.auth_helper import get_header + + +def test_token_mock(token_cache_mock, httpx_mock: HTTPXMock): + auth = httpx_auth.OAuth2Implicit("http://provide_token") + expected_token = httpx_auth.OAuth2.token_cache.get_token("") + assert ( + get_header(httpx_mock, auth).get("Authorization") == f"Bearer {expected_token}" + )