diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3473122..97c3ea1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,25 +7,13 @@ on: jobs: build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install .[testing] - - name: Test with pytest - run: | - pytest --cov=requests_auth --cov-fail-under=100 --cov-report=term-missing + python-version: '3.12' - name: Create packages run: | python -m pip install build @@ -33,4 +21,4 @@ jobs: - name: Publish packages run: | python -m pip install twine - python -m twine upload dist/* --skip-existing --username __token__ --password ${{ secrets.pypi_password }} \ No newline at end of file + python -m twine upload dist/* --skip-existing --username __token__ --password ${{ secrets.pypi_password }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f1071bd..8d78b5b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,12 +8,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -22,4 +22,17 @@ jobs: python -m pip install .[testing] - name: Test with pytest run: | - pytest --cov=requests_auth --cov-fail-under=100 --cov-report=term-missing \ No newline at end of file + pytest --cov=requests_auth --cov-fail-under=100 --cov-report=term-missing + - name: Create packages + run: | + python -m pip install build + python -m build . + rm -Rf requests_auth + - name: Install wheel + run: | + python -m pip install dist/requests_auth-8.0.0-py3-none-any.whl --force-reinstall + python -c 'import requests_auth' + - name: Install source distribution + run: | + python -m pip install dist/requests_auth-8.0.0.tar.gz --force-reinstall + python -c 'import requests_auth' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d31238e..d8ea773 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ repos: - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 24.4.2 hooks: - id: black \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 582f37c..b74556d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [8.0.0] - 2024-06-18 +### Added +- Adding explicit support for Python `3.12`. +- Publicly expose `requests_auth.SupportMultiAuth`, allowing multiple authentication support for every `requests` authentication class that exists. +- Publicly expose `requests_auth.TokenMemoryCache`, allowing to create custom Oauth2 token cache based on this default implementation. +- You can now provide your own HTML success (`success_html`) and failure (`failure_html`) display via the new `OAuth2.display` shared setting. Refer to documentation for more details. +- Thanks to the new `redirect_uri_domain` parameter on Authorization code (with and without PKCE) and Implicit flows, you can now provide the [FQDN](https://en.wikipedia.org/wiki/Fully_qualified_domain_name) to use in the `redirect_uri` when `localhost` (the default) is not allowed. +- `requests_auth.WakaTimeAuthorizationCode` handling access to the [WakaTime API](https://wakatime.com/developers). + +### Changed +- Except for `requests_auth.testing`, only direct access via `requests_auth.` was considered publicly exposed. This is now explicit, as inner packages are now using private prefix (`_`). + If you were relying on some classes or functions that are now internal, feel free to open an issue. +- `requests_auth.JsonTokenFileCache` and `requests_auth.TokenMemoryCache` `get_token` method does not handle kwargs anymore, the `on_missing_token` callable does not expect any arguments anymore. +- `requests_auth.JsonTokenFileCache` does not expose `tokens_path` or `last_save_time` attributes anymore and is also allowing `pathlib.Path` instances as cache location. +- `requests_auth.TokenMemoryCache` does not expose `forbid_concurrent_cache_access` or `forbid_concurrent_missing_token_function_call` attributes anymore. +- Browser display settings have been moved to a shared setting, see documentation for more information on `requests_auth.OAuth2.display`. + The failure page will be displayed for 10 seconds by default instead of 5 seconds previously. + As a result the following classes no longer expose `success_display_time` and `failure_display_time` parameters. + - `requests_auth.OAuth2AuthorizationCode`. + - `requests_auth.OktaAuthorizationCode`. + - `requests_auth.WakaTimeAuthorizationCode`. + - `requests_auth.OAuth2AuthorizationCodePKCE`. + - `requests_auth.OktaAuthorizationCodePKCE`. + - `requests_auth.OAuth2Implicit`. + - `requests_auth.AzureActiveDirectoryImplicit`. + - `requests_auth.AzureActiveDirectoryImplicitIdToken`. + - `requests_auth.OktaImplicit`. + - `requests_auth.OktaImplicitIdToken`. +- The authentication success and failure displayed in the browser were revamped to be more user-friendly. `requests_auth.testing` was modified to accommodate this change: + - `tab.assert_success` `expected_message` parameter was removed. + - `tab.assert_failure` `expected_message` parameter should not be prefixed with `Unable to properly perform authentication: ` anymore and `\n` in the message should be replaced with `
`. +- Exceptions issued by `requests_auth` are now inheriting from `requests_auth.RequestsAuthException`, itself inheriting from `requests.RequestException`, instead of `Exception`. + +### Fixed +- Type information is now provided following [PEP 561](https://www.python.org/dev/peps/pep-0561/). +- Remove deprecation warnings due to usage of `utcnow` and `utcfromtimestamp`. +- `requests_auth.OktaClientCredentials` `scope` parameter is now mandatory and does not default to `openid` anymore. +- `requests_auth.OktaClientCredentials` will now display a more user-friendly error message in case Okta instance is not provided. +- Tokens cache `DEBUG` logs will not display tokens anymore. +- Handle `text/html; charset=utf-8` content-type in token responses. + +### Removed +- Removing support for Python `3.7`. +- Deprecated `requests_auth.Auths` class has been removed. + ## [7.0.0] - 2023-04-27 ### Changed - `requests_auth.OAuth2ResourceOwnerPasswordCredentials` does not send basic authentication by default. @@ -13,10 +58,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `session_auth` as a parameter of `requests_auth.OAuth2ResourceOwnerPasswordCredentials`. Allowing to provide any kind of optional authentication. - `requests_auth.OktaResourceOwnerPasswordCredentials` providing Okta resource owner password credentials flow easy setup. -- Explicit support for Python 3.11 +- Explicit support for Python `3.11`. ### Removed -- Explicit support for Python 3.6 +- Explicit support for Python `3.6`. ## [6.0.0] - 2022-01-11 ### Changed @@ -58,7 +103,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [5.2.0] - 2020-10-14 ### Added - Allow to provide a `requests.Session` instance for `*AuthorizationCode` flows (even `PKCE`), `*ClientCredentials` and `*ResourceOwnerPasswordCredentials` flows. -- Explicit support for Python 3.9 +- Explicit support for Python `3.9`. ### Changed - Code now follow `black==20.8b1` formatting instead of the git master version. @@ -179,7 +224,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Public release -[Unreleased]: https://github.com/Colin-b/requests_auth/compare/v7.0.0...HEAD +[Unreleased]: https://github.com/Colin-b/requests_auth/compare/v8.0.0...HEAD +[8.0.0]: https://github.com/Colin-b/requests_auth/compare/v7.0.0...v8.0.0 [7.0.0]: https://github.com/Colin-b/requests_auth/compare/v6.0.0...v7.0.0 [6.0.0]: https://github.com/Colin-b/requests_auth/compare/v5.3.0...v6.0.0 [5.3.0]: https://github.com/Colin-b/requests_auth/compare/v5.2.0...v5.3.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49bfd27..01ab6ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ Before creating an issue please make sure that it was not already reported. 1) Go to the *Issues* tab and click on the *New issue* button. 2) Title should be a small sentence describing the request. -3) The comment should contains as much information as possible +3) The comment should contain as much information as possible * Actual behavior (including the version you used) * Expected behavior * Steps to reproduce @@ -56,7 +56,7 @@ Before creating an issue please make sure that it was not already reported. 1) Go to the *Pull requests* tab and click on the *New pull request* button. 2) *base* should always be set to `develop` and it should be compared to your branch. 3) Title should be a small sentence describing the request. -3) The comment should contains as much information as possible +4) The comment should contain as much information as possible * Actual behavior (before the new code) * Expected behavior (with the new code) * Steps to reproduce (with and without the new code to see the difference) diff --git a/LICENSE b/LICENSE index 3738ea5..cab2d30 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Colin Bounouar +Copyright (c) 2024 Colin Bounouar Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 2278ff5..af4e947 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Build status Coverage Code style: black -Number of tests +Number of tests Number of downloads

@@ -14,7 +14,7 @@ Provides authentication classes to be used with [`requests`][1] [authentication

OAuth2 Okta - Azure Active Directory (AD) + Microsoft Entra ID, formerly Azure Active Directory (AD)

Some of the supported authentication

@@ -29,11 +29,12 @@ Provides authentication classes to be used with [`requests`][1] [authentication - [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) + - [Microsoft Entra (Access Token)](#microsoft---azure-active-directory-oauth2-access-token) + - [Microsoft Entra (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) + - [Managing browser](#managing-the-web-browser) - API key - [In header](#api-key-in-header) - [In query](#api-key-in-query) @@ -61,17 +62,20 @@ from requests_auth import OAuth2AuthorizationCode requests.get('https://www.example.com', auth=OAuth2AuthorizationCode('https://www.authorization.url', 'https://www.token.url')) ``` +Note: +* You can persist tokens thanks to [the token cache](#managing-token-cache). +* You can tweak web browser interaction thanks to [the display settings](#managing-the-web-browser). + #### 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_domain` | [FQDN](https://en.wikipedia.org/wiki/Fully_qualified_domain_name) to use in the redirect_uri when localhost is not allowed. | Optional | localhost | +| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://:/. | 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 | @@ -113,6 +117,10 @@ okta = OktaAuthorizationCode(instance='testserver.okta-emea.com', client_id='542 requests.get('https://www.example.com', auth=okta) ``` +Note: +* You can persist tokens thanks to [the token cache](#managing-token-cache). +* You can tweak web browser interaction thanks to [the display settings](#managing-the-web-browser). + ###### Parameters | Name | Description | Mandatory | Default value | @@ -125,11 +133,10 @@ requests.get('https://www.example.com', auth=okta) | `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_domain` | [FQDN](https://en.wikipedia.org/wiki/Fully_qualified_domain_name) to use in the redirect_uri when localhost is not allowed. | Optional | localhost | +| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://:/. | 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} | | `session` | `requests.Session` instance that will be used to request the token. Use it to provide a custom proxying rule for instance. | Optional | | @@ -142,6 +149,46 @@ Usual extra parameters are: |:----------------|:---------------------------------------------------------------------| | `prompt` | none to avoid prompting the user if a session is already opened. | +##### WakaTime (OAuth2 Authorization Code) + +[WakaTime Authorization Code Grant](https://wakatime.com/developers#authentication) providing access tokens is supported. + +Use `requests_auth.WakaTimeAuthorizationCode` to configure this kind of authentication. + +```python +import requests +from requests_auth import WakaTimeAuthorizationCode + + +waka_time = WakaTimeAuthorizationCode(client_id="aPJQV0op6Pu3b66MWDi9b1wB", client_secret="waka_sec_0c5MB", scope="email") +requests.get('https://wakatime.com/api/v1/users/current', auth=waka_time) +``` + +Note: +* You can persist tokens thanks to [the token cache](#managing-token-cache). +* You can tweak web browser interaction thanks to [the display settings](#managing-the-web-browser). + +###### Parameters + +| Name | Description | Mandatory | Default value | +|:------------------------|:---------------------------|:----------|:---------------------------------------------| +| `client_id` | WakaTime Application Identifier (formatted as an Universal Unique Identifier). | Mandatory | | +| `client_secret` | WakaTime Application Secret (formatted as waka_sec_ followed by an Universal Unique Identifier). | Mandatory | | +| `scope` | Scope parameter sent in query. Can also be a list of scopes. | 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 | +| `early_expiry` | Number of seconds before actual token expiry where token will be considered as expired. Used to ensure token will not expire between the time of retrieval and the time the request reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. | Optional | 30.0 | +| `nonce` | Refer to [OpenID ID Token specifications][3] for more details. | Optional | Newly generated Universal Unique Identifier. | +| `redirect_uri_domain` | [FQDN](https://en.wikipedia.org/wiki/Fully_qualified_domain_name) to use in the redirect_uri when localhost is not allowed. | Optional | localhost | +| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://:/. | 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 | +| `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} | +| `session` | `requests.Session` instance that will be used to request the token. Use it to provide a custom proxying rule for instance. | Optional | | + +Any other parameter will be put as query parameter in the authorization URL. + ### Authorization Code Flow with Proof Key for Code Exchange Proof Key for Code Exchange is implemented following [rfc7636](https://tools.ietf.org/html/rfc7636). @@ -155,17 +202,20 @@ from requests_auth import OAuth2AuthorizationCodePKCE requests.get('https://www.example.com', auth=OAuth2AuthorizationCodePKCE('https://www.authorization.url', 'https://www.token.url')) ``` +Note: +* You can persist tokens thanks to [the token cache](#managing-token-cache). +* You can tweak web browser interaction thanks to [the display settings](#managing-the-web-browser). + #### 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_domain` | [FQDN](https://en.wikipedia.org/wiki/Fully_qualified_domain_name) to use in the redirect_uri when localhost is not allowed. | Optional | localhost | +| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://:/. | 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 | @@ -205,6 +255,10 @@ okta = OktaAuthorizationCodePKCE(instance='testserver.okta-emea.com', client_id= requests.get('https://www.example.com', auth=okta) ``` +Note: +* You can persist tokens thanks to [the token cache](#managing-token-cache). +* You can tweak web browser interaction thanks to [the display settings](#managing-the-web-browser). + ###### Parameters | Name | Description | Mandatory | Default value | @@ -218,11 +272,10 @@ requests.get('https://www.example.com', auth=okta) | `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_domain` | [FQDN](https://en.wikipedia.org/wiki/Fully_qualified_domain_name) to use in the redirect_uri when localhost is not allowed. | Optional | localhost | +| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://:/. | 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} | | `session` | `requests.Session` instance that will be used to request the token. Use it to provide a custom proxying rule for instance. | Optional | | @@ -231,10 +284,10 @@ Any other parameter will be put as query parameter in the authorization URL and Usual extra parameters are: -| Name | Description | -|:----------------|:---------------------------------------------------------------------| -| `client_secret` | If client is not authenticated with the authorization server | -| `nonce` | Refer to [OpenID ID Token specifications][3] for more details | +| Name | Description | +|:----------------------|:------------------------------------------------------------------| +| `client_secret` | If client is not authenticated with the authorization server | +| `nonce` | Refer to [OpenID ID Token specifications][3] for more details | ### Resource Owner Password Credentials flow @@ -249,6 +302,9 @@ from requests_auth import OAuth2ResourceOwnerPasswordCredentials requests.get('https://www.example.com', auth=OAuth2ResourceOwnerPasswordCredentials('https://www.token.url', 'user name', 'user password')) ``` +Note: +* You can persist tokens thanks to [the token cache](#managing-token-cache). + #### Parameters | Name | Description | Mandatory | Default value | @@ -256,7 +312,7 @@ requests.get('https://www.example.com', auth=OAuth2ResourceOwnerPasswordCredenti | `token_url` | OAuth 2 token URL. | Mandatory | | | `username` | Resource owner user name. | Mandatory | | | `password` | Resource owner password. | Mandatory | | -| `session_auth` | Client authentication if the client type is confidential or the client was issued client credentials (or assigned other authentication requirements). Can be a tuple or any requests authentication class instance. | Optional | | +| `session_auth` | Client authentication if the client type is confidential or the client was issued client credentials (or assigned other authentication requirements). Can be a tuple or any requests authentication class instance. | Optional | | | `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} | @@ -288,22 +344,25 @@ okta = OktaResourceOwnerPasswordCredentials(instance='testserver.okta-emea.com', requests.get('https://www.example.com', auth=okta) ``` +Note: +* You can persist tokens thanks to [the token cache](#managing-token-cache). + ###### Parameters -| Name | Description | Mandatory | Default value | -|:------------------------|:---------------------------|:----------|:--------------| -| `instance` | Okta instance (like "testserver.okta-emea.com"). | Mandatory | | -| `username` | Resource owner user name. | Mandatory | | -| `password` | Resource owner password. | Mandatory | | -| `client_id` | Okta Application Identifier (formatted as an Universal Unique Identifier). | 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 in query. Can also be a list of scopes. | Optional | openid | -| `token_field_name` | Field name containing the token. | Optional | access_token | -| `early_expiry` | Number of seconds before actual token expiry where token will be considered as expired. Used to ensure token will not expire between the time of retrieval and the time the request reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. | Optional | 30.0 | -| `session` | `requests.Session` instance that will be used to request the token. Use it to provide a custom proxying rule for instance. | Optional | | +| Name | Description | Mandatory | Default value | +|:-----------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------|:---------------| +| `instance` | Okta instance (like "testserver.okta-emea.com"). | Mandatory | | +| `username` | Resource owner user name. | Mandatory | | +| `password` | Resource owner password. | Mandatory | | +| `client_id` | Okta Application Identifier (formatted as an Universal Unique Identifier). | 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 in query. Can also be a list of scopes. | Optional | openid | +| `token_field_name` | Field name containing the token. | Optional | access_token | +| `early_expiry` | Number of seconds before actual token expiry where token will be considered as expired. Used to ensure token will not expire between the time of retrieval and the time the request reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. | Optional | 30.0 | +| `session` | `requests.Session` instance that will be used to request the token. Use it to provide a custom proxying rule for instance. | Optional | | Any other parameter will be put as body parameters in the token URL. @@ -320,6 +379,9 @@ from requests_auth import OAuth2ClientCredentials requests.get('https://www.example.com', auth=OAuth2ClientCredentials('https://www.token.url', client_id='id', client_secret='secret')) ``` +Note: +* You can persist tokens thanks to [the token cache](#managing-token-cache). + #### Parameters | Name | Description | Mandatory | Default value | @@ -354,10 +416,13 @@ import requests from requests_auth import OktaClientCredentials -okta = OktaClientCredentials(instance='testserver.okta-emea.com', client_id='54239d18-c68c-4c47-8bdd-ce71ea1d50cd', client_secret="secret") +okta = OktaClientCredentials(instance='testserver.okta-emea.com', client_id='54239d18-c68c-4c47-8bdd-ce71ea1d50cd', client_secret="secret", scope=["scope1", "scope2"]) requests.get('https://www.example.com', auth=okta) ``` +Note: +* You can persist tokens thanks to [the token cache](#managing-token-cache). + ###### Parameters | Name | Description | Mandatory | Default value | @@ -365,11 +430,11 @@ requests.get('https://www.example.com', auth=okta) | `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 | | +| `scope` | Scope parameter sent in query. Can also be a list of scopes. | 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 | | `early_expiry` | Number of seconds before actual token expiry where token will be considered as expired. Used to ensure token will not expire between the time of retrieval and the time the request reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. | Optional | 30.0 | | `session` | `requests.Session` instance that will be used to request the token. Use it to provide a custom proxying rule for instance. | Optional | | @@ -389,6 +454,10 @@ from requests_auth import OAuth2Implicit requests.get('https://www.example.com', auth=OAuth2Implicit('https://www.authorization.url')) ``` +Note: +* You can persist tokens thanks to [the token cache](#managing-token-cache). +* You can tweak web browser interaction thanks to [the display settings](#managing-the-web-browser). + #### Parameters | Name | Description | Mandatory | Default value | @@ -397,11 +466,10 @@ requests.get('https://www.example.com', auth=OAuth2Implicit('https://www.authori | `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 | | `early_expiry` | Number of seconds before actual token expiry where token will be considered as expired. Used to ensure token will not expire between the time of retrieval and the time the request reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. | Optional | 30.0 | -| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://localhost:/. | Optional | '' | +| `redirect_uri_domain` | [FQDN](https://en.wikipedia.org/wiki/Fully_qualified_domain_name) to use in the redirect_uri when localhost is not allowed. | Optional | localhost | +| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://:/. | 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} | @@ -436,6 +504,10 @@ aad = AzureActiveDirectoryImplicit(tenant_id='45239d18-c68c-4c47-8bdd-ce71ea1d50 requests.get('https://www.example.com', auth=aad) ``` +Note: +* You can persist tokens thanks to [the token cache](#managing-token-cache). +* You can tweak web browser interaction thanks to [the display settings](#managing-the-web-browser). + 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 @@ -448,11 +520,10 @@ You can retrieve Microsoft Azure Active Directory application information thanks | `token_field_name` | Field name containing the token. | Optional | access_token | | `early_expiry` | Number of seconds before actual token expiry where token will be considered as expired. Used to ensure token will not expire between the time of retrieval and the time the request reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. | Optional | 30.0 | | `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_domain` | [FQDN](https://en.wikipedia.org/wiki/Fully_qualified_domain_name) to use in the redirect_uri when localhost is not allowed. | Optional | localhost | +| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://:/. | 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} | @@ -479,6 +550,10 @@ aad = AzureActiveDirectoryImplicitIdToken(tenant_id='45239d18-c68c-4c47-8bdd-ce7 requests.get('https://www.example.com', auth=aad) ``` +Note: +* You can persist tokens thanks to [the token cache](#managing-token-cache). +* You can tweak web browser interaction thanks to [the display settings](#managing-the-web-browser). + 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 @@ -491,11 +566,10 @@ You can retrieve Microsoft Azure Active Directory application information thanks | `token_field_name` | Field name containing the token. | Optional | id_token | | `early_expiry` | Number of seconds before actual token expiry where token will be considered as expired. Used to ensure token will not expire between the time of retrieval and the time the request reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. | Optional | 30.0 | | `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_domain` | [FQDN](https://en.wikipedia.org/wiki/Fully_qualified_domain_name) to use in the redirect_uri when localhost is not allowed. | Optional | localhost | +| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://:/. | 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} | @@ -522,6 +596,10 @@ okta = OktaImplicit(instance='testserver.okta-emea.com', client_id='54239d18-c68 requests.get('https://www.example.com', auth=okta) ``` +Note: +* You can persist tokens thanks to [the token cache](#managing-token-cache). +* You can tweak web browser interaction thanks to [the display settings](#managing-the-web-browser). + ###### Parameters | Name | Description | Mandatory | Default value | @@ -534,11 +612,10 @@ requests.get('https://www.example.com', auth=okta) | `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_domain` | [FQDN](https://en.wikipedia.org/wiki/Fully_qualified_domain_name) to use in the redirect_uri when localhost is not allowed. | Optional | localhost | +| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://:/. | 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} | @@ -565,6 +642,10 @@ okta = OktaImplicitIdToken(instance='testserver.okta-emea.com', client_id='54239 requests.get('https://www.example.com', auth=okta) ``` +Note: +* You can persist tokens thanks to [the token cache](#managing-token-cache). +* You can tweak web browser interaction thanks to [the display settings](#managing-the-web-browser). + ###### Parameters | Name | Description | Mandatory | Default value | @@ -577,11 +658,10 @@ requests.get('https://www.example.com', auth=okta) | `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_domain` | [FQDN](https://en.wikipedia.org/wiki/Fully_qualified_domain_name) to use in the redirect_uri when localhost is not allowed. | Optional | localhost | +| `redirect_uri_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://:/. | 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} | @@ -597,11 +677,11 @@ Usual extra parameters are: 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. +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. +You need to provide the location of your token cache file. It can be a full or relative path (`str` or `pathlib.Path). -If the file already exists it will be used, if the file do not exists it will be created. +If the file already exists it will be used, if the file do not exist it will be created. ```python from requests_auth import OAuth2, JsonTokenFileCache @@ -609,6 +689,24 @@ from requests_auth import OAuth2, JsonTokenFileCache OAuth2.token_cache = JsonTokenFileCache('path/to/my_token_cache.json') ``` +### Managing the web browser + +You can configure the browser display settings thanks to `requests_auth.OAuth2.display` as in the following: +```python +from requests_auth import OAuth2, DisplaySettings + +OAuth2.display = DisplaySettings() +``` + +The following parameters can be provided to `DisplaySettings`: + +| Name | Description | Default value | +|:-----------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------| +| `success_display_time` | In case a code or token is successfully received, this is the maximum amount of milliseconds the success page will be displayed in your browser. | 1 | +| `success_html` | In case a code or token is successfully received, this is the success page that will be displayed in your browser. `{display_time}` is expected in this content. | | +| `failure_display_time` | In case received code or token is not valid, this is the maximum amount of milliseconds the failure page will be displayed in your browser. | 10_000 | +| `failure_html` | In case received code or token is not valid, this is the failure page that will be displayed in your browser. `{information}` and `{display_time}` are expected in this content. | | + ## API key in header You can send an API key inside the header of your request using `requests_auth.HeaderApiKey`. @@ -698,6 +796,17 @@ oauth2 = OAuth2Implicit('https://www.example.com') requests.get('https://www.example.com', auth=api_key + oauth2) ``` +This is supported on every authentication class exposed by `requests_auth`, but you can also enable it on your own authentication classes by using `requests_auth.SupportMultiAuth` as in the following sample: + +```python +from requests_auth import SupportMultiAuth +# TODO Import your own auth here +from my_package import MyAuth + +class MyMultiAuth(MyAuth, SupportMultiAuth): + pass +``` + ## Available pytest fixtures Testing the code using `requests_auth` authentication classes can be achieved using provided [`pytest`][6] fixtures. @@ -795,7 +904,7 @@ import datetime from requests_auth.testing import browser_mock, BrowserMock, create_token def test_something(browser_mock: BrowserMock): - token_expiry = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + token_expiry = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1) token = create_token(token_expiry) tab = browser_mock.add_response( opened_url="http://url_opened_by_browser?state=1234", @@ -804,9 +913,7 @@ def test_something(browser_mock: BrowserMock): # perform code using authentication - tab.assert_success( - "You are now authenticated on 1234 You may close this tab." - ) + tab.assert_success() ``` ## Endorsements diff --git a/pyproject.toml b/pyproject.toml index 71a1bdd..9e32c5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "requests_auth" description = "Authentication for Requests" readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.8" license = {file = "LICENSE"} authors = [ {name = "Colin Bounouar", email = "colin.bounouar.dev@gmail.com" } @@ -18,11 +18,9 @@ keywords = [ "authentication", "ntlm", "oauth2", - "azure-active-directory", - "azure-ad", "okta", - "apikey", - "multiple", + "aad", + "entra" ] classifiers=[ "Development Status :: 5 - Production/Stable", @@ -31,11 +29,11 @@ classifiers=[ "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Build Tools", ] dependencies = [ @@ -56,7 +54,7 @@ testing = [ # Used to mock requests "pytest-responses==0.5.*", # Used to check coverage - "pytest-cov==4.*", + "pytest-cov==5.*", ] [tool.setuptools.packages.find] diff --git a/requests_auth/__init__.py b/requests_auth/__init__.py index 09751e8..c19a25b 100644 --- a/requests_auth/__init__.py +++ b/requests_auth/__init__.py @@ -1,26 +1,38 @@ -from requests_auth.authentication import ( +from requests_auth._authentication import ( Basic, HeaderApiKey, QueryApiKey, NTLM, - Auths, - OAuth2, + SupportMultiAuth, +) +from requests_auth._oauth2.browser import DisplaySettings +from requests_auth._oauth2.common import OAuth2 +from requests_auth._oauth2.authorization_code import ( + OAuth2AuthorizationCode, + OktaAuthorizationCode, + WakaTimeAuthorizationCode, +) +from requests_auth._oauth2.authorization_code_pkce import ( OAuth2AuthorizationCodePKCE, OktaAuthorizationCodePKCE, +) +from requests_auth._oauth2.client_credentials import ( + OAuth2ClientCredentials, + OktaClientCredentials, +) +from requests_auth._oauth2.implicit import ( OAuth2Implicit, OktaImplicit, OktaImplicitIdToken, AzureActiveDirectoryImplicit, AzureActiveDirectoryImplicitIdToken, - OAuth2AuthorizationCode, - OktaAuthorizationCode, - OAuth2ClientCredentials, - OktaClientCredentials, +) +from requests_auth._oauth2.resource_owner_password import ( OAuth2ResourceOwnerPasswordCredentials, OktaResourceOwnerPasswordCredentials, ) -from requests_auth.oauth2_tokens import JsonTokenFileCache -from requests_auth.errors import ( +from requests_auth._oauth2.tokens import JsonTokenFileCache, TokenMemoryCache +from requests_auth._errors import ( GrantNotProvided, TimeoutOccurred, AuthenticationFailed, @@ -28,5 +40,41 @@ InvalidToken, TokenExpiryNotProvided, InvalidGrantRequest, + RequestsAuthException, ) from requests_auth.version import __version__ + +__all__ = [ + "Basic", + "HeaderApiKey", + "QueryApiKey", + "OAuth2", + "DisplaySettings", + "OAuth2AuthorizationCodePKCE", + "OktaAuthorizationCodePKCE", + "OAuth2Implicit", + "OktaImplicit", + "OktaImplicitIdToken", + "AzureActiveDirectoryImplicit", + "AzureActiveDirectoryImplicitIdToken", + "OAuth2AuthorizationCode", + "OktaAuthorizationCode", + "WakaTimeAuthorizationCode", + "OAuth2ClientCredentials", + "OktaClientCredentials", + "OAuth2ResourceOwnerPasswordCredentials", + "OktaResourceOwnerPasswordCredentials", + "NTLM", + "SupportMultiAuth", + "JsonTokenFileCache", + "TokenMemoryCache", + "RequestsAuthException", + "GrantNotProvided", + "TimeoutOccurred", + "AuthenticationFailed", + "StateNotProvided", + "InvalidToken", + "TokenExpiryNotProvided", + "InvalidGrantRequest", + "__version__", +] diff --git a/requests_auth/_authentication.py b/requests_auth/_authentication.py new file mode 100644 index 0000000..364b23a --- /dev/null +++ b/requests_auth/_authentication.py @@ -0,0 +1,145 @@ +from urllib.parse import parse_qs, urlsplit, urlunsplit, urlencode + +import requests +import requests.auth + + +class SupportMultiAuth: + """Inherit from this class to be able to use your class with requests_auth provided authentication classes.""" + + def __add__(self, other): + if isinstance(other, _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 HeaderApiKey(requests.auth.AuthBase, 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 __call__(self, r): + r.headers[self.header_name] = self.api_key + return r + + +class QueryApiKey(requests.auth.AuthBase, 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 __call__(self, r): + r.url = _add_parameters(r.url, {self.query_parameter_name: self.api_key}) + return r + + +class Basic(requests.auth.HTTPBasicAuth, SupportMultiAuth): + """Describes a basic requests authentication.""" + + def __init__(self, username: str, password: str): + requests.auth.HTTPBasicAuth.__init__(self, username, password) + + +class NTLM(requests.auth.AuthBase, SupportMultiAuth): + """Describes a NTLM requests authentication.""" + + def __init__(self, username: str = None, password: str = None): + """ + :param username: Mandatory if requests_negotiate_sspi module is not installed. + :param password: Mandatory if requests_negotiate_sspi module is not installed. + """ + self.username = username + self.password = password + if not username and not password: + try: + import requests_negotiate_sspi + + self.auth = requests_negotiate_sspi.HttpNegotiateAuth() + except ImportError: + raise Exception( + "NTLM authentication requires requests_negotiate_sspi module." + ) + else: + if not username: + raise Exception( + 'NTLM authentication requires "username" to be provided in security_details.' + ) + if not password: + raise Exception( + 'NTLM authentication requires "password" to be provided in security_details.' + ) + try: + import requests_ntlm + + self.auth = requests_ntlm.HttpNtlmAuth(username, password) + except ImportError: + raise Exception("NTLM authentication requires requests_ntlm module.") + + def __call__(self, r): + self.auth.__call__(r) + return r + + +class _MultiAuth(requests.auth.AuthBase): + """Authentication using multiple authentication methods.""" + + def __init__(self, *authentication_modes): + self.authentication_modes = authentication_modes + + def __call__(self, r): + for authentication_mode in self.authentication_modes: + authentication_mode.__call__(r) + return r + + 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) + + +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)) diff --git a/requests_auth/errors.py b/requests_auth/_errors.py similarity index 86% rename from requests_auth/errors.py rename to requests_auth/_errors.py index b8091b6..7cac1b3 100644 --- a/requests_auth/errors.py +++ b/requests_auth/_errors.py @@ -1,42 +1,45 @@ from json import JSONDecodeError from typing import Union -from requests import Response +from requests import Response, RequestException -class AuthenticationFailed(Exception): +class RequestsAuthException(RequestException): ... + + +class AuthenticationFailed(RequestsAuthException): """User was not authenticated.""" def __init__(self): - Exception.__init__(self, "User was not authenticated.") + RequestsAuthException.__init__(self, "User was not authenticated.") -class TimeoutOccurred(Exception): +class TimeoutOccurred(RequestsAuthException): """No response within timeout interval.""" def __init__(self, timeout: float): - Exception.__init__( + RequestsAuthException.__init__( self, f"User authentication was not received within {timeout} seconds." ) -class InvalidToken(Exception): +class InvalidToken(RequestsAuthException): """Token is invalid.""" def __init__(self, token_name: str): - Exception.__init__(self, f"{token_name} is invalid.") + RequestsAuthException.__init__(self, f"{token_name} is invalid.") -class GrantNotProvided(Exception): +class GrantNotProvided(RequestsAuthException): """Grant was not provided.""" def __init__(self, grant_name: str, dictionary_without_grant: dict): - Exception.__init__( + RequestsAuthException.__init__( self, f"{grant_name} not provided within {dictionary_without_grant}." ) -class InvalidGrantRequest(Exception): +class InvalidGrantRequest(RequestsAuthException): """ 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 """ @@ -64,7 +67,7 @@ class InvalidGrantRequest(Exception): } def __init__(self, response: Union[Response, dict]): - Exception.__init__(self, InvalidGrantRequest.to_message(response)) + RequestsAuthException.__init__(self, InvalidGrantRequest.to_message(response)) @staticmethod def to_message(response: Union[Response, dict]) -> str: @@ -114,17 +117,19 @@ def _pop(key: str) -> str: return message -class StateNotProvided(Exception): +class StateNotProvided(RequestsAuthException): """State was not provided.""" def __init__(self, dictionary_without_state: dict): - Exception.__init__( + RequestsAuthException.__init__( self, f"state not provided within {dictionary_without_state}." ) -class TokenExpiryNotProvided(Exception): +class TokenExpiryNotProvided(RequestsAuthException): """Token expiry was not provided.""" def __init__(self, token_body: dict): - Exception.__init__(self, f"Expiry (exp) is not provided in {token_body}.") + RequestsAuthException.__init__( + self, f"Expiry (exp) is not provided in {token_body}." + ) diff --git a/requests_auth/_oauth2/__init__.py b/requests_auth/_oauth2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/requests_auth/oauth2_authentication_responses_server.py b/requests_auth/_oauth2/authentication_responses_server.py similarity index 81% rename from requests_auth/oauth2_authentication_responses_server.py rename to requests_auth/_oauth2/authentication_responses_server.py index 7db4be0..1a49526 100644 --- a/requests_auth/oauth2_authentication_responses_server.py +++ b/requests_auth/_oauth2/authentication_responses_server.py @@ -5,7 +5,9 @@ from urllib.parse import parse_qs, urlparse from socket import socket -from requests_auth.errors import * +from requests_auth._oauth2.common import OAuth2 + +from requests_auth._errors import * logger = logging.getLogger(__name__) @@ -34,7 +36,10 @@ def do_GET(self): 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}") + OAuth2.display.failure_html.format( + display_time=OAuth2.display.failure_display_time, + information=str(e).replace("\n", "
"), + ) ) def do_POST(self): @@ -46,7 +51,10 @@ def do_POST(self): 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}") + OAuth2.display.failure_html.format( + display_time=OAuth2.display.failure_display_time, + information=str(e).replace("\n", "
"), + ) ) def _parse_grant(self, arguments: dict): @@ -65,15 +73,15 @@ def _parse_grant(self, arguments: dict): 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." + OAuth2.display.success_html.format( + display_time=OAuth2.display.success_display_time ) ) 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) + return parse_qs(body_str, keep_blank_values=True) def _get_params(self): return parse_qs(urlparse(self.path).query) @@ -85,28 +93,6 @@ def send_html(self, html_content: str): 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 @@ -137,15 +123,11 @@ def __init__( 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 @@ -182,7 +164,7 @@ def request_new_grant(grant_details: GrantDetails) -> (str, str): :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. + :raises StateNotProvided: If state is not provided in addition to the grant. """ logger.debug(f"Requesting new {grant_details.name}...") diff --git a/requests_auth/_oauth2/authorization_code.py b/requests_auth/_oauth2/authorization_code.py new file mode 100644 index 0000000..dd2262d --- /dev/null +++ b/requests_auth/_oauth2/authorization_code.py @@ -0,0 +1,284 @@ +from hashlib import sha512 +from typing import Union, Iterable + +import requests +import requests.auth + +from requests_auth._authentication import SupportMultiAuth, _add_parameters +from requests_auth._oauth2 import authentication_responses_server +from requests_auth._oauth2.browser import BrowserAuth +from requests_auth._oauth2.common import ( + _get_query_parameter, + _pop_parameter, + OAuth2, + request_new_grant_with_post, +) + + +class OAuth2AuthorizationCode(requests.auth.AuthBase, 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_domain: FQDN to use in the redirect_uri when localhost (default) is not allowed. + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://:/. 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 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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. + Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request + reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. + :param code_field_name: Field name containing the code. code by default. + :param username: User name in case basic authentication should be used to retrieve token. + :param password: User password in case basic authentication should be used to retrieve token. + :param session: requests.Session instance that will be used to request the token. + Use it to provide a custom proxying rule for instance. + :param kwargs: all additional authorization parameters that should be put as query parameter + in the authorization URL and as body parameters in the token URL. + Usual parameters are: + * 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" + self.early_expiry = float(kwargs.pop("early_expiry", None) or 30.0) + + username = kwargs.pop("username", None) + password = kwargs.pop("password", None) + self.auth = (username, password) if username and password else None + self.session = kwargs.pop("session", None) or requests.Session() + self.session.auth = self.auth + + # As described in https://tools.ietf.org/html/rfc6749#section-4.1.2 + code_field_name = kwargs.pop("code_field_name", "code") + 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 = authentication_responses_server.GrantDetails( + code_grant_url, + code_field_name, + self.timeout, + 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) + + # As described in https://tools.ietf.org/html/rfc6749#section-6 + self.refresh_data = {"grant_type": "refresh_token"} + self.refresh_data.update(kwargs) + + def __call__(self, r): + token = OAuth2.token_cache.get_token( + key=self.state, + early_expiry=self.early_expiry, + on_missing_token=self.request_new_token, + on_expired_token=self.refresh_token, + ) + r.headers[self.header_name] = self.header_value.format(token=token) + return r + + def request_new_token(self): + # Request code + state, code = 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, refresh_token = request_new_grant_with_post( + self.token_url, + self.token_data, + self.token_field_name, + self.timeout, + self.session, + ) + # Handle both Access and Bearer tokens + return ( + (self.state, token, expires_in, refresh_token) + if expires_in + else (self.state, token) + ) + + def refresh_token(self, refresh_token: str): + # As described in https://tools.ietf.org/html/rfc6749#section-6 + self.refresh_data["refresh_token"] = refresh_token + token, expires_in, refresh_token = request_new_grant_with_post( + self.token_url, + self.refresh_data, + self.token_field_name, + self.timeout, + self.session, + ) + return self.state, token, expires_in, refresh_token + + +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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. + Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request + reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. + :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_domain: FQDN to use in the redirect_uri when localhost (default) is not allowed. + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://:/. 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 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 session: requests.Session instance that will be used to request the token. + Use it to provide a custom proxying rule for instance. + :param kwargs: all additional authorization parameters that should be put as query parameter + in the authorization URL. + Usual parameters are: + * 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 WakaTimeAuthorizationCode(OAuth2AuthorizationCode): + """ + Describes a WakaTime (OAuth 2) "Access Token" authorization code flow requests authentication. + """ + + def __init__( + self, + client_id: str, + client_secret: str, + scope: Union[str, Iterable[str]], + **kwargs, + ): + """ + :param client_id: WakaTime Application Identifier (formatted as a Universal Unique Identifier) + :param client_secret: WakaTime Application Secret (formatted as waka_sec_ followed by a Universal Unique Identifier) + :param scope: Scope parameter sent in query. Can also be a list of scopes. + :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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. + Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request + reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. + :param nonce: Refer to http://openid.net/specs/openid-connect-core-1_0.html#IDToken for more details + (formatted as a Universal Unique Identifier - UUID). Use a newly generated UUID by default. + :param redirect_uri_domain: FQDN to use in the redirect_uri when localhost (default) is not allowed. + :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 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 session: requests.Session instance that will be used to request the token. + Use it to provide a custom proxying rule for instance. + :param kwargs: all additional authorization parameters that should be put as query parameter + in the authorization URL. + """ + if not scope: + raise Exception("Scope is mandatory.") + OAuth2AuthorizationCode.__init__( + self, + "https://wakatime.com/oauth/authorize", + "https://wakatime.com/oauth/token", + client_id=client_id, + client_secret=client_secret, + scope=",".join(scope) if isinstance(scope, list) else scope, + **kwargs, + ) diff --git a/requests_auth/_oauth2/authorization_code_pkce.py b/requests_auth/_oauth2/authorization_code_pkce.py new file mode 100644 index 0000000..b6c722b --- /dev/null +++ b/requests_auth/_oauth2/authorization_code_pkce.py @@ -0,0 +1,274 @@ +import base64 +import os +from hashlib import sha256, sha512 + +import requests +import requests.auth + +from requests_auth._authentication import SupportMultiAuth, _add_parameters +from requests_auth._oauth2 import authentication_responses_server +from requests_auth._oauth2.browser import BrowserAuth +from requests_auth._oauth2.common import ( + _pop_parameter, + OAuth2, + request_new_grant_with_post, +) + + +class OAuth2AuthorizationCodePKCE( + requests.auth.AuthBase, 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_domain: FQDN to use in the redirect_uri when localhost (default) is not allowed. + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://:/. 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 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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. + Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request + reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. + :param code_field_name: Field name containing the code. code by default. + :param session: requests.Session instance that will be used to request the token. + Use it to provide a custom proxying rule for instance. + :param kwargs: all additional authorization parameters that should be put as query parameter + in the authorization URL and as body parameters in the token URL. + Usual parameters are: + * 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.session = kwargs.pop("session", None) or requests.Session() + self.session.timeout = self.timeout + + self.header_name = kwargs.pop("header_name", None) or "Authorization" + self.header_value = kwargs.pop("header_value", None) or "Bearer {token}" + if "{token}" not in self.header_value: + raise Exception("header_value parameter must contains {token}.") + + self.token_field_name = kwargs.pop("token_field_name", None) or "access_token" + self.early_expiry = float(kwargs.pop("early_expiry", None) or 30.0) + + # 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 = authentication_responses_server.GrantDetails( + code_grant_url, + code_field_name, + self.timeout, + 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) + + # As described in https://tools.ietf.org/html/rfc6749#section-6 + self.refresh_data = {"grant_type": "refresh_token"} + self.refresh_data.update(kwargs) + + def __call__(self, r): + token = OAuth2.token_cache.get_token( + key=self.state, + early_expiry=self.early_expiry, + on_missing_token=self.request_new_token, + on_expired_token=self.refresh_token, + ) + r.headers[self.header_name] = self.header_value.format(token=token) + return r + + def request_new_token(self) -> tuple: + # Request code + state, code = 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, refresh_token = request_new_grant_with_post( + self.token_url, + self.token_data, + self.token_field_name, + self.timeout, + self.session, + ) + # Handle both Access and Bearer tokens + return ( + (self.state, token, expires_in, refresh_token) + if expires_in + else (self.state, token) + ) + + def refresh_token(self, refresh_token: str): + # As described in https://tools.ietf.org/html/rfc6749#section-6 + self.refresh_data["refresh_token"] = refresh_token + token, expires_in, refresh_token = request_new_grant_with_post( + self.token_url, + self.refresh_data, + self.token_field_name, + self.timeout, + self.session, + ) + return self.state, token, expires_in, refresh_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 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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. + Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request + reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. + :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_domain: FQDN to use in the redirect_uri when localhost (default) is not allowed. + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://:/. 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 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 session: requests.Session instance that will be used to request the token. + Use it to provide a custom proxying rule for instance. + :param kwargs: all additional authorization parameters that should be put as query parameter + in the authorization URL and as body parameters in the token URL. + Usual parameters are: + * 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, + ) diff --git a/requests_auth/_oauth2/browser.py b/requests_auth/_oauth2/browser.py new file mode 100644 index 0000000..2ee4c8d --- /dev/null +++ b/requests_auth/_oauth2/browser.py @@ -0,0 +1,152 @@ +class BrowserAuth: + def __init__(self, kwargs): + """ + :param redirect_uri_domain: FQDN to use in the redirect_uri when localhost (default) is not allowed. + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://:/. 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_domain = kwargs.pop("redirect_uri_domain", None) or "localhost" + 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://{redirect_uri_domain}:{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 DisplaySettings: + _default_template = """ + + + {title} + + + +
+

{title}

+

{information}

+
+ + +""" + _default_success = ( + _default_template.replace("{title}", "Authentication success") + .replace("{color}", "#32cd32") + .replace("{background_color}", "#f0fff0") + .replace("{information}", "You can close this tab") + ) + _default_failure = ( + _default_template.replace("{title}", "Authentication failed") + .replace("{color}", "#dc143c") + .replace("{background_color}", "#fffafa") + ) + + def __init__( + self, + *, + success_display_time: int = 1, + success_html: str = None, + failure_display_time: int = 10_000, + failure_html: str = None, + ): + """ + :param success_display_time: In case a code/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 success_html: In case a code or token is successfully received, + this is the success page that will be displayed in your browser. + `{display_time}` is expected in this content. + :param failure_display_time: In case received code/token is not valid, + this is the maximum amount of milliseconds the failure page will be displayed in your browser. + Display the page for 10 seconds by default. + :param failure_html: In case received code or token is not valid, + this is the failure page that will be displayed in your browser. + `{information}` and `{display_time}` are expected in this content. + """ + # Time is expressed in milliseconds + self.success_display_time = success_display_time + self.success_html = success_html or self._default_success + + # Time is expressed in milliseconds + self.failure_display_time = failure_display_time + self.failure_html = failure_html or self._default_failure diff --git a/requests_auth/_oauth2/client_credentials.py b/requests_auth/_oauth2/client_credentials.py new file mode 100644 index 0000000..7789807 --- /dev/null +++ b/requests_auth/_oauth2/client_credentials.py @@ -0,0 +1,145 @@ +from hashlib import sha512 +from typing import Union, Iterable + +import requests +import requests.auth + +from requests_auth._authentication import SupportMultiAuth, _add_parameters +from requests_auth._oauth2.common import OAuth2, request_new_grant_with_post + + +class OAuth2ClientCredentials(requests.auth.AuthBase, 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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. + Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request + reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. + :param session: requests.Session instance that will be used to request the token. + Use it to provide a custom proxying rule for instance. + :param kwargs: all additional authorization parameters that should be put as query parameter in the token URL. + """ + self.token_url = token_url + 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.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" + self.early_expiry = float(kwargs.pop("early_expiry", None) or 30.0) + + # Time is expressed in seconds + self.timeout = int(kwargs.pop("timeout", None) or 60) + + self.session = kwargs.pop("session", None) or requests.Session() + self.session.auth = (self.client_id, self.client_secret) + + # As described in https://tools.ietf.org/html/rfc6749#section-4.4.2 + self.data = {"grant_type": "client_credentials"} + scope = kwargs.pop("scope", None) + if scope: + self.data["scope"] = " ".join(scope) if isinstance(scope, list) else scope + self.data.update(kwargs) + # Refresh tokens are not supported, as described in https://tools.ietf.org/html/rfc6749#section-4.4.3 + + all_parameters_in_url = _add_parameters(self.token_url, self.data) + self.state = sha512(all_parameters_in_url.encode("unicode_escape")).hexdigest() + + def __call__(self, r): + token = OAuth2.token_cache.get_token( + key=self.state, + early_expiry=self.early_expiry, + on_missing_token=self.request_new_token, + ) + r.headers[self.header_name] = self.header_value.format(token=token) + return r + + 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, + self.session, + ) + # Handle both Access and Bearer tokens + return (self.state, token, expires_in) if expires_in else (self.state, token) + + +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, + *, + scope: Union[str, Iterable[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 scope: Scope parameter sent to token URL as body. Can also be a list of scopes. + :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 token_field_name: Field name containing the token. access_token by default. + :param early_expiry: Number of seconds before actual token expiry where token will be considered as expired. + Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request + reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. + :param session: requests.Session instance that will be used to request the token. + Use it to provide a custom proxying rule for instance. + :param kwargs: all additional authorization parameters that should be put as query parameter in the token URL. + """ + if not scope: + raise Exception("scope is mandatory.") + if not instance: + raise Exception("Okta instance is mandatory.") + authorization_server = kwargs.pop("authorization_server", None) or "default" + OAuth2ClientCredentials.__init__( + self, + f"https://{instance}/oauth2/{authorization_server}/v1/token", + client_id=client_id, + client_secret=client_secret, + scope=scope, + **kwargs, + ) diff --git a/requests_auth/_oauth2/common.py b/requests_auth/_oauth2/common.py new file mode 100644 index 0000000..8407430 --- /dev/null +++ b/requests_auth/_oauth2/common.py @@ -0,0 +1,66 @@ +from urllib.parse import parse_qs, urlsplit, urlunsplit, urlencode +from typing import Optional + +import requests.auth + +from requests_auth._errors import InvalidGrantRequest, GrantNotProvided +from requests_auth._oauth2.browser import DisplaySettings +from requests_auth._oauth2.tokens import TokenMemoryCache + + +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 _content_from_response(response: requests.Response) -> dict: + content_type = response.headers.get("content-type") + if content_type == "text/html; charset=utf-8": + return { + key_values[0]: key_values[1] + for key_value in response.text.split("&") + if (key_values := key_value.split("=")) and len(key_values) == 2 + } + return response.json() + + +def request_new_grant_with_post( + url: str, data, grant_name: str, timeout: float, session: requests.Session +) -> (str, int, str): + with session: + response = session.post(url, data=data, timeout=timeout) + if not response: + # As described in https://tools.ietf.org/html/rfc6749#section-5.2 + raise InvalidGrantRequest(response) + + content = _content_from_response(response) + token = content.get(grant_name) + if not token: + raise GrantNotProvided(grant_name, content) + return token, content.get("expires_in"), content.get("refresh_token") + + +class OAuth2: + token_cache = TokenMemoryCache() + display = DisplaySettings() diff --git a/requests_auth/_oauth2/implicit.py b/requests_auth/_oauth2/implicit.py new file mode 100644 index 0000000..bb3a3bb --- /dev/null +++ b/requests_auth/_oauth2/implicit.py @@ -0,0 +1,309 @@ +import uuid +from hashlib import sha512 + +import requests.auth + +from requests_auth._authentication import SupportMultiAuth, _add_parameters +from requests_auth._oauth2 import authentication_responses_server +from requests_auth._oauth2.browser import BrowserAuth +from requests_auth._oauth2.common import _get_query_parameter, _pop_parameter, OAuth2 + + +class OAuth2Implicit(requests.auth.AuthBase, 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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. + Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request + reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. + :param redirect_uri_domain: FQDN to use in the redirect_uri when localhost (default) is not allowed. + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://:/. 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 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" + ) + + self.early_expiry = float(kwargs.pop("early_expiry", None) or 30.0) + + 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 = authentication_responses_server.GrantDetails( + grant_url, + token_field_name, + self.timeout, + self.redirect_uri_port, + ) + + def __call__(self, r): + token = OAuth2.token_cache.get_token( + key=self.state, + early_expiry=self.early_expiry, + on_missing_token=self.request_new_token, + ) + r.headers[self.header_name] = self.header_value.format(token=token) + return r + + def request_new_token(self) -> tuple: + return authentication_responses_server.request_new_grant(self.grant_details) + + +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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. + Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request + reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. + :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_domain: FQDN to use in the redirect_uri when localhost (default) is not allowed. + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://:/. 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 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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. + Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request + reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. + :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_domain: FQDN to use in the redirect_uri when localhost (default) is not allowed. + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://:/. 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 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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. + Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request + reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. + :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_domain: FQDN to use in the redirect_uri when localhost (default) is not allowed. + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://:/. 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 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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. + Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request + reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. + :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_domain: FQDN to use in the redirect_uri when localhost (default) is not allowed. + :param redirect_uri_endpoint: Custom endpoint that will be used as redirect_uri the following way: + http://:/. 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 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, + ) diff --git a/requests_auth/_oauth2/resource_owner_password.py b/requests_auth/_oauth2/resource_owner_password.py new file mode 100644 index 0000000..51f1365 --- /dev/null +++ b/requests_auth/_oauth2/resource_owner_password.py @@ -0,0 +1,181 @@ +from hashlib import sha512 + +import requests +import requests.auth + +from requests_auth._authentication import SupportMultiAuth, _add_parameters +from requests_auth._oauth2.common import OAuth2, request_new_grant_with_post + + +class OAuth2ResourceOwnerPasswordCredentials(requests.auth.AuthBase, 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 session_auth: Client authentication if the client type is confidential + or the client was issued client credentials (or assigned other authentication requirements). + Can be a tuple or any requests authentication class instance. + :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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. + Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request + reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. + :param session: requests.Session instance that will be used to request the token. + Use it to provide a custom proxying rule for instance. + :param kwargs: all additional authorization parameters that should be put as body parameters in the token URL. + """ + self.token_url = token_url + 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.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" + self.early_expiry = float(kwargs.pop("early_expiry", None) or 30.0) + + # Time is expressed in seconds + self.timeout = int(kwargs.pop("timeout", None) or 60) + self.session = kwargs.pop("session", None) or requests.Session() + session_auth = kwargs.pop("session_auth", None) + if session_auth: + self.session.auth = session_auth + + # 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 = kwargs.pop("scope", None) + if scope: + self.data["scope"] = " ".join(scope) if isinstance(scope, list) else scope + self.data.update(kwargs) + + # As described in https://tools.ietf.org/html/rfc6749#section-6 + self.refresh_data = {"grant_type": "refresh_token"} + if scope: + self.refresh_data["scope"] = self.data["scope"] + self.refresh_data.update(kwargs) + + all_parameters_in_url = _add_parameters(self.token_url, self.data) + self.state = sha512(all_parameters_in_url.encode("unicode_escape")).hexdigest() + + def __call__(self, r): + token = OAuth2.token_cache.get_token( + key=self.state, + early_expiry=self.early_expiry, + on_missing_token=self.request_new_token, + on_expired_token=self.refresh_token, + ) + r.headers[self.header_name] = self.header_value.format(token=token) + return r + + def request_new_token(self): + # As described in https://tools.ietf.org/html/rfc6749#section-4.3.3 + token, expires_in, refresh_token = request_new_grant_with_post( + self.token_url, + self.data, + self.token_field_name, + self.timeout, + self.session, + ) + # Handle both Access and Bearer tokens + return ( + (self.state, token, expires_in, refresh_token) + if expires_in + else (self.state, token) + ) + + def refresh_token(self, refresh_token: str): + # As described in https://tools.ietf.org/html/rfc6749#section-6 + self.refresh_data["refresh_token"] = refresh_token + token, expires_in, refresh_token = request_new_grant_with_post( + self.token_url, + self.refresh_data, + self.token_field_name, + self.timeout, + self.session, + ) + return self.state, token, expires_in, refresh_token + + +class OktaResourceOwnerPasswordCredentials(OAuth2ResourceOwnerPasswordCredentials): + """ + Describes an Okta (OAuth 2) resource owner password credentials (also called password) flow requests authentication. + """ + + def __init__( + self, + instance: str, + username: str, + password: str, + client_id: str, + client_secret: str, + **kwargs, + ): + """ + :param instance: Okta instance (like "testserver.okta-emea.com") + :param username: Resource owner user name. + :param password: Resource owner password. + :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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. + Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request + reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. + :param session: requests.Session instance that will be used to request the token. + Use it to provide a custom proxying rule for instance. + :param kwargs: all additional authorization parameters that should be put as body parameters in the token URL. + """ + if not instance: + raise Exception("Instance is mandatory.") + if not client_id: + raise Exception("Client ID is mandatory.") + if not client_secret: + raise Exception("Client secret is mandatory.") + authorization_server = kwargs.pop("authorization_server", None) or "default" + scopes = kwargs.pop("scope", "openid") + kwargs["scope"] = " ".join(scopes) if isinstance(scopes, list) else scopes + OAuth2ResourceOwnerPasswordCredentials.__init__( + self, + f"https://{instance}/oauth2/{authorization_server}/v1/token", + username=username, + password=password, + session_auth=(client_id, client_secret), + **kwargs, + ) diff --git a/requests_auth/oauth2_tokens.py b/requests_auth/_oauth2/tokens.py similarity index 78% rename from requests_auth/oauth2_tokens.py rename to requests_auth/_oauth2/tokens.py index 6ac3ca4..5d65609 100644 --- a/requests_auth/oauth2_tokens.py +++ b/requests_auth/_oauth2/tokens.py @@ -4,7 +4,9 @@ import datetime import threading import logging -from requests_auth.errors import * +from pathlib import Path + +from requests_auth._errors import * logger = logging.getLogger(__name__) @@ -23,16 +25,15 @@ def _decode_base64(base64_encoded_string: str) -> str: def _is_expired(expiry: float, early_expiry: float) -> bool: - return ( - datetime.datetime.utcfromtimestamp(expiry - early_expiry) - < datetime.datetime.utcnow() - ) + return datetime.datetime.fromtimestamp( + expiry - early_expiry, datetime.timezone.utc + ) < datetime.datetime.now(datetime.timezone.utc) def _to_expiry(expires_in: Union[int, str]) -> float: - expiry = datetime.datetime.utcnow().replace( - tzinfo=datetime.timezone.utc - ) + datetime.timedelta(seconds=int(expires_in)) + expiry = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( + seconds=int(expires_in) + ) return expiry.timestamp() @@ -43,8 +44,8 @@ class TokenMemoryCache: def __init__(self): self.tokens = {} - self.forbid_concurrent_cache_access = threading.Lock() - self.forbid_concurrent_missing_token_function_call = threading.Lock() + 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): """ @@ -92,11 +93,11 @@ def _add_token( :param expiry: UTC timestamp of expiry :param refresh_token: refresh token value """ - with self.forbid_concurrent_cache_access: + with self._forbid_concurrent_cache_access: self.tokens[key] = token, expiry, refresh_token self._save_tokens() logger.debug( - f'Inserting token expiring on {datetime.datetime.utcfromtimestamp(expiry)} (UTC) with "{key}" key: {token}' + f'Inserting token expiring on {datetime.datetime.fromtimestamp(expiry, datetime.timezone.utc)} with "{key}" key.' ) def get_token( @@ -106,7 +107,6 @@ def get_token( early_expiry: float = 30.0, on_missing_token=None, on_expired_token=None, - **on_missing_token_kwargs, ) -> str: """ Return the bearer token. @@ -118,13 +118,12 @@ def get_token( expired 30 seconds before real expiry by default. :param on_missing_token: function to call when token is expired or missing (returning token and expiry tuple) :param on_expired_token: function to call to refresh the token when it is expired - :param on_missing_token_kwargs: arguments of the on_missing_token function (key-value arguments) :return: the token :raise AuthenticationFailed: in case token cannot be retrieved. """ logger.debug(f'Retrieving token with "{key}" key.') refresh_token = None - with self.forbid_concurrent_cache_access: + with self._forbid_concurrent_cache_access: self._load_tokens() if key in self.tokens: token = self.tokens[key] @@ -137,23 +136,23 @@ def get_token( del self.tokens[key] else: logger.debug( - f"Using already received authentication, will expire on {datetime.datetime.utcfromtimestamp(expiry)} (UTC)." + f"Using already received authentication, will expire on {datetime.datetime.fromtimestamp(expiry, datetime.timezone.utc)}." ) return bearer if refresh_token is not None and on_expired_token is not None: try: - with self.forbid_concurrent_missing_token_function_call: + with self._forbid_concurrent_missing_token_function_call: state, token, expires_in, refresh_token = on_expired_token( refresh_token ) self._add_access_token(state, token, expires_in, refresh_token) logger.debug(f"Refreshed token with key {key}.") - with self.forbid_concurrent_cache_access: + with self._forbid_concurrent_cache_access: if state in self.tokens: bearer, expiry, refresh_token = self.tokens[state] logger.debug( - f"Using newly refreshed token, expiring on {datetime.datetime.utcfromtimestamp(expiry)} (UTC)." + f"Using newly refreshed token, expiring on {datetime.datetime.fromtimestamp(expiry, datetime.timezone.utc)}." ) return bearer except (InvalidGrantRequest, GrantNotProvided): @@ -161,8 +160,8 @@ def get_token( 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_kwargs) + with self._forbid_concurrent_missing_token_function_call: + new_token = on_missing_token() if len(new_token) == 2: # Bearer token state, token = new_token self._add_bearer_token(state, token) @@ -176,21 +175,21 @@ def get_token( logger.warning( f"Using a token received on another key than expected. Expecting {key} but was {state}." ) - with self.forbid_concurrent_cache_access: + with self._forbid_concurrent_cache_access: if state in self.tokens: bearer, expiry, refresh_token = self.tokens[state] logger.debug( - f"Using newly received authentication, expiring on {datetime.datetime.utcfromtimestamp(expiry)} (UTC)." + f"Using newly received authentication, expiring on {datetime.datetime.fromtimestamp(expiry, datetime.timezone.utc)}." ) return bearer logger.debug( - f"User was not authenticated: key {key} cannot be found in {self.tokens}." + f"User was not authenticated: key {key} cannot be found in {list(self.tokens)}." ) raise AuthenticationFailed() def clear(self): - with self.forbid_concurrent_cache_access: + with self._forbid_concurrent_cache_access: logger.debug("Clearing token cache.") self.tokens = {} self._clear() @@ -210,36 +209,36 @@ class JsonTokenFileCache(TokenMemoryCache): Class to manage tokens using a cache file. """ - def __init__(self, tokens_path: str): + def __init__(self, tokens_path: Union[str, Path]): TokenMemoryCache.__init__(self) - self.tokens_path = tokens_path - self.last_save_time = 0 + self._tokens_path = Path(tokens_path) + self._last_save_time = 0 self._load_tokens() def _clear(self): - self.last_save_time = 0 + self._last_save_time = 0 try: - os.remove(self.tokens_path) + self._tokens_path.unlink(missing_ok=True) except: logger.debug("Cannot remove tokens file.") def _save_tokens(self): try: - with open(self.tokens_path, "w") as tokens_cache_file: + with self._tokens_path.open(mode="w") as tokens_cache_file: json.dump(self.tokens, tokens_cache_file) - self.last_save_time = os.path.getmtime(self.tokens_path) + 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): + if not self._tokens_path.exists(): 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: + 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 self._tokens_path.open(mode="r") as tokens_cache_file: self.tokens = json.load(tokens_cache_file) except: logger.exception("Cannot load tokens.") diff --git a/requests_auth/authentication.py b/requests_auth/authentication.py deleted file mode 100644 index bd0fbbb..0000000 --- a/requests_auth/authentication.py +++ /dev/null @@ -1,1364 +0,0 @@ -import base64 -import os -import uuid -from hashlib import sha256, sha512 -from urllib.parse import parse_qs, urlsplit, urlunsplit, urlencode -from typing import Optional - -import requests -import requests.auth -import warnings - -from requests_auth import oauth2_authentication_responses_server, oauth2_tokens -from requests_auth.errors import 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, session: requests.Session -) -> (str, int, str): - with session: - response = session.post(url, data=data, timeout=timeout) - if not response: - # As described in https://tools.ietf.org/html/rfc6749#section-5.2 - raise InvalidGrantRequest(response) - - content = response.json() - token = content.get(grant_name) - if not token: - raise GrantNotProvided(grant_name, content) - return token, content.get("expires_in"), content.get("refresh_token") - - -class OAuth2: - token_cache = oauth2_tokens.TokenMemoryCache() - - -class SupportMultiAuth: - """Inherit from this class to be able to use your class with requests_auth provided authentication classes.""" - - def __add__(self, other): - if isinstance(other, _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(requests.auth.AuthBase, 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 session_auth: Client authentication if the client type is confidential - or the client was issued client credentials (or assigned other authentication requirements). - Can be a tuple or any requests authentication class instance. - :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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. - Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request - reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. - :param session: requests.Session instance that will be used to request the token. - Use it to provide a custom proxying rule for instance. - :param kwargs: all additional authorization parameters that should be put as body parameters in the token URL. - """ - self.token_url = token_url - 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.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" - self.early_expiry = float(kwargs.pop("early_expiry", None) or 30.0) - - # Time is expressed in seconds - self.timeout = int(kwargs.pop("timeout", None) or 60) - self.session = kwargs.pop("session", None) or requests.Session() - session_auth = kwargs.pop("session_auth", None) - if session_auth: - self.session.auth = session_auth - - # 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 = kwargs.pop("scope", None) - if scope: - self.data["scope"] = " ".join(scope) if isinstance(scope, list) else scope - self.data.update(kwargs) - - # As described in https://tools.ietf.org/html/rfc6749#section-6 - self.refresh_data = {"grant_type": "refresh_token"} - if scope: - self.refresh_data["scope"] = self.data["scope"] - self.refresh_data.update(kwargs) - - all_parameters_in_url = _add_parameters(self.token_url, self.data) - self.state = sha512(all_parameters_in_url.encode("unicode_escape")).hexdigest() - - def __call__(self, r): - token = OAuth2.token_cache.get_token( - key=self.state, - early_expiry=self.early_expiry, - on_missing_token=self.request_new_token, - on_expired_token=self.refresh_token, - ) - r.headers[self.header_name] = self.header_value.format(token=token) - return r - - def request_new_token(self): - # As described in https://tools.ietf.org/html/rfc6749#section-4.3.3 - token, expires_in, refresh_token = request_new_grant_with_post( - self.token_url, - self.data, - self.token_field_name, - self.timeout, - self.session, - ) - # Handle both Access and Bearer tokens - return ( - (self.state, token, expires_in, refresh_token) - if expires_in - else (self.state, token) - ) - - def refresh_token(self, refresh_token: str): - # As described in https://tools.ietf.org/html/rfc6749#section-6 - self.refresh_data["refresh_token"] = refresh_token - token, expires_in, refresh_token = request_new_grant_with_post( - self.token_url, - self.refresh_data, - self.token_field_name, - self.timeout, - self.session, - ) - return self.state, token, expires_in, refresh_token - - -class OAuth2ClientCredentials(requests.auth.AuthBase, 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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. - Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request - reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. - :param session: requests.Session instance that will be used to request the token. - Use it to provide a custom proxying rule for instance. - :param kwargs: all additional authorization parameters that should be put as query parameter in the token URL. - """ - self.token_url = token_url - 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.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" - self.early_expiry = float(kwargs.pop("early_expiry", None) or 30.0) - - # Time is expressed in seconds - self.timeout = int(kwargs.pop("timeout", None) or 60) - - self.session = kwargs.pop("session", None) or requests.Session() - self.session.auth = (self.client_id, self.client_secret) - - # As described in https://tools.ietf.org/html/rfc6749#section-4.4.2 - self.data = {"grant_type": "client_credentials"} - scope = kwargs.pop("scope", None) - if scope: - self.data["scope"] = " ".join(scope) if isinstance(scope, list) else scope - self.data.update(kwargs) - # Refresh tokens are not supported, as described in https://tools.ietf.org/html/rfc6749#section-4.4.3 - - all_parameters_in_url = _add_parameters(self.token_url, self.data) - self.state = sha512(all_parameters_in_url.encode("unicode_escape")).hexdigest() - - def __call__(self, r): - token = OAuth2.token_cache.get_token( - key=self.state, - early_expiry=self.early_expiry, - on_missing_token=self.request_new_token, - ) - r.headers[self.header_name] = self.header_value.format(token=token) - return r - - 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, - self.session, - ) - # Handle both Access and Bearer tokens - return (self.state, token, expires_in) if expires_in else (self.state, token) - - -class OAuth2AuthorizationCode(requests.auth.AuthBase, 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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. - Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request - reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. - :param code_field_name: Field name containing the code. code by default. - :param username: User name in case basic authentication should be used to retrieve token. - :param password: User password in case basic authentication should be used to retrieve token. - :param session: requests.Session instance that will be used to request the token. - Use it to provide a custom proxying rule for instance. - :param kwargs: all additional authorization parameters that should be put as query parameter - in the authorization URL and as body parameters in the token URL. - Usual parameters are: - * 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" - self.early_expiry = float(kwargs.pop("early_expiry", None) or 30.0) - - username = kwargs.pop("username", None) - password = kwargs.pop("password", None) - self.auth = (username, password) if username and password else None - self.session = kwargs.pop("session", None) or requests.Session() - self.session.auth = self.auth - - # As described in https://tools.ietf.org/html/rfc6749#section-4.1.2 - code_field_name = kwargs.pop("code_field_name", "code") - 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) - - # As described in https://tools.ietf.org/html/rfc6749#section-6 - self.refresh_data = {"grant_type": "refresh_token"} - self.refresh_data.update(kwargs) - - def __call__(self, r): - token = OAuth2.token_cache.get_token( - key=self.state, - early_expiry=self.early_expiry, - on_missing_token=self.request_new_token, - on_expired_token=self.refresh_token, - ) - r.headers[self.header_name] = self.header_value.format(token=token) - return r - - 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, refresh_token = request_new_grant_with_post( - self.token_url, - self.token_data, - self.token_field_name, - self.timeout, - self.session, - ) - # Handle both Access and Bearer tokens - return ( - (self.state, token, expires_in, refresh_token) - if expires_in - else (self.state, token) - ) - - def refresh_token(self, refresh_token: str): - # As described in https://tools.ietf.org/html/rfc6749#section-6 - self.refresh_data["refresh_token"] = refresh_token - token, expires_in, refresh_token = request_new_grant_with_post( - self.token_url, - self.refresh_data, - self.token_field_name, - self.timeout, - self.session, - ) - return self.state, token, expires_in, refresh_token - - -class OAuth2AuthorizationCodePKCE( - requests.auth.AuthBase, 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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. - Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request - reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. - :param code_field_name: Field name containing the code. code by default. - :param session: requests.Session instance that will be used to request the token. - Use it to provide a custom proxying rule for instance. - :param kwargs: all additional authorization parameters that should be put as query parameter - in the authorization URL and as body parameters in the token URL. - Usual parameters are: - * 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.session = kwargs.pop("session", None) or requests.Session() - self.session.timeout = self.timeout - - self.header_name = kwargs.pop("header_name", None) or "Authorization" - self.header_value = kwargs.pop("header_value", None) or "Bearer {token}" - if "{token}" not in self.header_value: - raise Exception("header_value parameter must contains {token}.") - - self.token_field_name = kwargs.pop("token_field_name", None) or "access_token" - self.early_expiry = float(kwargs.pop("early_expiry", None) or 30.0) - - # 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) - - # As described in https://tools.ietf.org/html/rfc6749#section-6 - self.refresh_data = {"grant_type": "refresh_token"} - self.refresh_data.update(kwargs) - - def __call__(self, r): - token = OAuth2.token_cache.get_token( - key=self.state, - early_expiry=self.early_expiry, - on_missing_token=self.request_new_token, - on_expired_token=self.refresh_token, - ) - r.headers[self.header_name] = self.header_value.format(token=token) - return r - - 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, refresh_token = request_new_grant_with_post( - self.token_url, - self.token_data, - self.token_field_name, - self.timeout, - self.session, - ) - # Handle both Access and Bearer tokens - return ( - (self.state, token, expires_in, refresh_token) - if expires_in - else (self.state, token) - ) - - def refresh_token(self, refresh_token: str): - # As described in https://tools.ietf.org/html/rfc6749#section-6 - self.refresh_data["refresh_token"] = refresh_token - token, expires_in, refresh_token = request_new_grant_with_post( - self.token_url, - self.refresh_data, - self.token_field_name, - self.timeout, - self.session, - ) - return self.state, token, expires_in, refresh_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(requests.auth.AuthBase, 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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. - Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request - reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. - :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" - ) - - self.early_expiry = float(kwargs.pop("early_expiry", None) or 30.0) - - 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 __call__(self, r): - token = OAuth2.token_cache.get_token( - key=self.state, - early_expiry=self.early_expiry, - on_missing_token=oauth2_authentication_responses_server.request_new_grant, - grant_details=self.grant_details, - ) - r.headers[self.header_name] = self.header_value.format(token=token) - return r - - -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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. - Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request - reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. - :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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. - Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request - reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. - :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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. - Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request - reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. - :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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. - Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request - reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. - :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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. - Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request - reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. - :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 session: requests.Session instance that will be used to request the token. - Use it to provide a custom proxying rule for instance. - :param kwargs: all additional authorization parameters that should be put as query parameter - in the authorization URL. - Usual parameters are: - * 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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. - Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request - reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. - :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 session: requests.Session instance that will be used to request the token. - Use it to provide a custom proxying rule for instance. - :param kwargs: all additional authorization parameters that should be put as query parameter - in the authorization URL and as body parameters in the token URL. - Usual parameters are: - * 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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. - Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request - reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. - :param session: requests.Session instance that will be used to request the token. - Use it to provide a custom proxying rule for instance. - :param kwargs: all additional authorization parameters that should be put as query parameter in the token URL. - """ - authorization_server = kwargs.pop("authorization_server", None) or "default" - 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 OktaResourceOwnerPasswordCredentials(OAuth2ResourceOwnerPasswordCredentials): - """ - Describes an Okta (OAuth 2) resource owner password credentials (also called password) flow requests authentication. - """ - - def __init__( - self, - instance: str, - username: str, - password: str, - client_id: str, - client_secret: str, - **kwargs, - ): - """ - :param instance: Okta instance (like "testserver.okta-emea.com") - :param username: Resource owner user name. - :param password: Resource owner password. - :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 early_expiry: Number of seconds before actual token expiry where token will be considered as expired. - Default to 30 seconds to ensure token will not expire between the time of retrieval and the time the request - reaches the actual server. Set it to 0 to deactivate this feature and use the same token until actual expiry. - :param session: requests.Session instance that will be used to request the token. - Use it to provide a custom proxying rule for instance. - :param kwargs: all additional authorization parameters that should be put as body parameters in the token URL. - """ - if not instance: - raise Exception("Instance is mandatory.") - if not client_id: - raise Exception("Client ID is mandatory.") - if not client_secret: - raise Exception("Client secret is mandatory.") - authorization_server = kwargs.pop("authorization_server", None) or "default" - scopes = kwargs.pop("scope", "openid") - kwargs["scope"] = " ".join(scopes) if isinstance(scopes, list) else scopes - OAuth2ResourceOwnerPasswordCredentials.__init__( - self, - f"https://{instance}/oauth2/{authorization_server}/v1/token", - username=username, - password=password, - session_auth=(client_id, client_secret), - **kwargs, - ) - - -class HeaderApiKey(requests.auth.AuthBase, 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 __call__(self, r): - r.headers[self.header_name] = self.api_key - return r - - -class QueryApiKey(requests.auth.AuthBase, 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 __call__(self, r): - r.url = _add_parameters(r.url, {self.query_parameter_name: self.api_key}) - return r - - -class Basic(requests.auth.HTTPBasicAuth, SupportMultiAuth): - """Describes a basic requests authentication.""" - - def __init__(self, username: str, password: str): - requests.auth.HTTPBasicAuth.__init__(self, username, password) - - -class NTLM(requests.auth.AuthBase, SupportMultiAuth): - """Describes a NTLM requests authentication.""" - - def __init__(self, username: str = None, password: str = None): - """ - :param username: Mandatory if requests_negotiate_sspi module is not installed. - :param password: Mandatory if requests_negotiate_sspi module is not installed. - """ - self.username = username - self.password = password - if not username and not password: - try: - import requests_negotiate_sspi - - self.auth = requests_negotiate_sspi.HttpNegotiateAuth() - except ImportError: - raise Exception( - "NTLM authentication requires requests_negotiate_sspi module." - ) - else: - if not username: - raise Exception( - 'NTLM authentication requires "username" to be provided in security_details.' - ) - if not password: - raise Exception( - 'NTLM authentication requires "password" to be provided in security_details.' - ) - try: - import requests_ntlm - - self.auth = requests_ntlm.HttpNtlmAuth(username, password) - except ImportError: - raise Exception("NTLM authentication requires requests_ntlm module.") - - def __call__(self, r): - self.auth.__call__(r) - return r - - -class _MultiAuth(requests.auth.AuthBase): - """Authentication using multiple authentication methods.""" - - def __init__(self, *authentication_modes): - self.authentication_modes = authentication_modes - - def __call__(self, r): - for authentication_mode in self.authentication_modes: - authentication_mode.__call__(r) - return r - - 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/requests_auth/py.typed b/requests_auth/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/requests_auth/testing.py b/requests_auth/testing.py index fb5e844..ce70470 100644 --- a/requests_auth/testing.py +++ b/requests_auth/testing.py @@ -7,6 +7,7 @@ import pytest import requests_auth +import requests_auth._oauth2.authentication_responses_server def create_token(expiry: Optional[datetime.datetime]) -> str: @@ -19,7 +20,7 @@ def create_token(expiry: Optional[datetime.datetime]) -> str: @pytest.fixture -def token_cache(): +def token_cache() -> requests_auth.TokenMemoryCache: yield requests_auth.OAuth2.token_cache requests_auth.OAuth2.token_cache.clear() @@ -33,10 +34,177 @@ class Tab(threading.Thread): * assert the content sent to the browser """ - def __init__(self, reply_url: str, data: str): + def __init__( + self, + reply_url: str, + data: str, + displayed_html: Optional[str] = None, + ): self.reply_url = reply_url self.data = data.encode() if data is not None else None self.checked = False + self.success_html = ( + displayed_html + or """ + + + Authentication success + + + +
+

Authentication success

+

You can close this tab

+
+ + +""" + ) + self.failure_html = ( + displayed_html + or """ + + + Authentication failed + + + +
+

Authentication failed

+

{information}

+
+ + +""" + ) super().__init__() def run(self) -> None: @@ -49,7 +217,7 @@ def run(self) -> None: # Simulate a browser tab token redirect to the reply URL self.content = self._simulate_redirect().decode() - def _request_favicon(self): + def _request_favicon(self) -> None: 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." @@ -75,19 +243,15 @@ def _simulate_requests_auth_redirect(self) -> bytes: ) return urllib.request.urlopen(reply_url, data=self.data).read() - def assert_success(self, expected_message: str, timeout: int = 1): + def assert_success(self, timeout: int = 1) -> None: self.join() - assert ( - self.content - == f"\n
{expected_message}
\n " - ) + assert self.content == self.success_html.format(display_time=timeout) self.checked = True - def assert_failure(self, expected_message: str, timeout: int = 5000): + def assert_failure(self, expected_message: str, timeout: int = 10_000) -> None: self.join() - assert ( - self.content - == f"\n
{expected_message}
\n " + assert self.content == self.failure_html.format( + display_time=timeout, information=expected_message ) self.checked = True @@ -96,7 +260,7 @@ class BrowserMock: def __init__(self): self.tabs: Dict[str, Tab] = {} - def open(self, url: str, new: int): + def open(self, url: str, new: int) -> bool: 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 @@ -104,18 +268,23 @@ def open(self, url: str, new: int): return True def add_response( - self, opened_url: str, reply_url: Optional[str], data: str = None + self, + opened_url: str, + reply_url: Optional[str], + data: Optional[str] = None, + displayed_html: Optional[str] = None, ) -> Tab: """ :param opened_url: URL opened by requests_auth :param reply_url: The URL to send a response to, None to simulate the fact that there is no redirect. :param data: Body of the POST response to be sent. None to send a GET request. + :param displayed_html: Expected success/failure page. """ - tab = Tab(reply_url, data) + tab = Tab(reply_url, data, displayed_html) self.tabs[opened_url] = tab return tab - def assert_checked(self): + def assert_checked(self) -> None: for url, tab in self.tabs.items(): tab.join() assert tab.checked, f"Response received on {url} was not checked properly." @@ -124,13 +293,15 @@ def assert_checked(self): @pytest.fixture def browser_mock(monkeypatch) -> BrowserMock: mock = BrowserMock() - import requests_auth.oauth2_authentication_responses_server monkeypatch.setattr( - requests_auth.oauth2_authentication_responses_server.webbrowser, + requests_auth._oauth2.authentication_responses_server.webbrowser, "get", lambda *args: mock, ) + monkeypatch.setattr( + requests_auth.OAuth2, "display", requests_auth.DisplaySettings() + ) yield mock mock.assert_checked() diff --git a/requests_auth/version.py b/requests_auth/version.py index 799b272..e90c286 100644 --- a/requests_auth/version.py +++ b/requests_auth/version.py @@ -3,4 +3,4 @@ # Major should be incremented in case there is a breaking change. (eg: 2.5.8 -> 3.0.0) # Minor should be incremented in case there is an enhancement. (eg: 2.5.8 -> 2.6.0) # Patch should be incremented in case there is a bug fix. (eg: 2.5.8 -> 2.5.9) -__version__ = "7.0.0" +__version__ = "8.0.0" diff --git a/tests/auth_helper.py b/tests/auth_helper.py deleted file mode 100644 index 40b8567..0000000 --- a/tests/auth_helper.py +++ /dev/null @@ -1,31 +0,0 @@ -import requests -import requests.auth -import responses - - -def get_header(responses: responses.RequestsMock, auth: requests.auth.AuthBase) -> dict: - # Mock a dummy response - responses.add(responses.GET, "http://authorized_only") - # Send a request to this dummy URL with authentication - response = requests.get("http://authorized_only", auth=auth) - # Return headers received on this dummy URL - return response.request.headers - - -def get_query_args( - responses: responses.RequestsMock, auth: requests.auth.AuthBase -) -> str: - # Mock a dummy response - responses.add(responses.GET, "http://authorized_only") - # Send a request to this dummy URL with authentication - response = requests.get("http://authorized_only", auth=auth) - # Return headers received on this dummy URL - return response.request.path_url - - -def get_request(responses: responses.RequestsMock, url: str) -> responses.Response: - for call in responses.calls: - if call.request.url == url: - # Pop out verified request (to be able to check multiple requests) - responses.calls._calls.remove(call) - return call.request diff --git a/tests/features/__init__.py b/tests/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/features/multi_auth/__init__.py b/tests/features/multi_auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_add_operator.py b/tests/features/multi_auth/test_add_operator.py similarity index 60% rename from tests/test_add_operator.py rename to tests/features/multi_auth/test_add_operator.py index 291ae31..8a4f6bc 100644 --- a/tests/test_add_operator.py +++ b/tests/features/multi_auth/test_add_operator.py @@ -2,18 +2,36 @@ from responses import RequestsMock import requests +from responses.matchers import header_matcher, query_string_matcher import requests_auth -from requests_auth.testing import BrowserMock, create_token, token_cache, browser_mock -from tests.auth_helper import get_header +from requests_auth.testing import ( + BrowserMock, + create_token, + token_cache, + browser_mock, +) # noqa: F401 +import requests_auth._oauth2.authorization_code_pkce def test_basic_and_api_key_authentication_can_be_combined(responses: RequestsMock): basic_auth = requests_auth.Basic("test_user", "test_pwd") api_key_auth = requests_auth.HeaderApiKey("my_provided_api_key") - header = get_header(responses, basic_auth + api_key_auth) - assert header.get("Authorization") == "Basic dGVzdF91c2VyOnRlc3RfcHdk" - assert header.get("X-Api-Key") == "my_provided_api_key" + auth = basic_auth + api_key_auth + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Basic dGVzdF91c2VyOnRlc3RfcHdk", + "X-API-Key": "my_provided_api_key", + } + ) + ], + ) + + requests.get("http://authorized_only", auth=auth) def test_header_api_key_and_multiple_authentication_can_be_combined( @@ -26,10 +44,22 @@ def test_header_api_key_and_multiple_authentication_can_be_combined( api_key_auth3 = requests_auth.HeaderApiKey( "my_provided_api_key3", header_name="X-Api-Key3" ) - header = get_header(responses, 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" + auth = api_key_auth + (api_key_auth2 + api_key_auth3) + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "X-API-Key": "my_provided_api_key", + "X-Api-Key2": "my_provided_api_key2", + "X-Api-Key3": "my_provided_api_key3", + } + ) + ], + ) + + requests.get("http://authorized_only", auth=auth) def test_multiple_auth_and_header_api_key_can_be_combined( @@ -42,10 +72,22 @@ def test_multiple_auth_and_header_api_key_can_be_combined( api_key_auth3 = requests_auth.HeaderApiKey( "my_provided_api_key3", header_name="X-Api-Key3" ) - header = get_header(responses, (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" + auth = (api_key_auth + api_key_auth2) + api_key_auth3 + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "X-API-Key": "my_provided_api_key", + "X-Api-Key2": "my_provided_api_key2", + "X-Api-Key3": "my_provided_api_key3", + } + ) + ], + ) + + requests.get("http://authorized_only", auth=auth) def test_multiple_auth_and_multiple_auth_can_be_combined( @@ -61,13 +103,23 @@ def test_multiple_auth_and_multiple_auth_can_be_combined( api_key_auth4 = requests_auth.HeaderApiKey( "my_provided_api_key4", header_name="X-Api-Key4" ) - header = get_header( - responses, (api_key_auth + api_key_auth2) + (api_key_auth3 + api_key_auth4) + auth = (api_key_auth + api_key_auth2) + (api_key_auth3 + api_key_auth4) + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "X-API-Key": "my_provided_api_key", + "X-Api-Key2": "my_provided_api_key2", + "X-Api-Key3": "my_provided_api_key3", + "X-Api-Key4": "my_provided_api_key4", + } + ) + ], ) - 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" + + requests.get("http://authorized_only", auth=auth) def test_basic_and_multiple_authentication_can_be_combined( @@ -80,10 +132,22 @@ def test_basic_and_multiple_authentication_can_be_combined( api_key_auth3 = requests_auth.HeaderApiKey( "my_provided_api_key3", header_name="X-Api-Key3" ) - header = get_header(responses, 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" + auth = basic_auth + (api_key_auth2 + api_key_auth3) + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Basic dGVzdF91c2VyOnRlc3RfcHdk", + "X-Api-Key2": "my_provided_api_key2", + "X-Api-Key3": "my_provided_api_key3", + } + ) + ], + ) + + requests.get("http://authorized_only", auth=auth) def test_query_api_key_and_multiple_authentication_can_be_combined( @@ -96,19 +160,23 @@ def test_query_api_key_and_multiple_authentication_can_be_combined( api_key_auth3 = requests_auth.HeaderApiKey( "my_provided_api_key3", header_name="X-Api-Key3" ) + auth = api_key_auth + (api_key_auth2 + api_key_auth3) - # Mock a dummy response - responses.add(responses.GET, "http://authorized_only") - # Send a request to this dummy URL with authentication - response = requests.get( - "http://authorized_only", auth=api_key_auth + (api_key_auth2 + api_key_auth3) - ) - # Return headers received on this dummy URL - assert ( - response.request.path_url - == "/?api_key=my_provided_api_key&api_key2=my_provided_api_key2" + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "X-Api-Key3": "my_provided_api_key3", + } + ), + query_string_matcher( + "api_key=my_provided_api_key&api_key2=my_provided_api_key2" + ), + ], ) - assert response.request.headers.get("X-Api-Key3") == "my_provided_api_key3" + + requests.get("http://authorized_only", auth=auth) def test_oauth2_resource_owner_password_and_api_key_authentication_can_be_combined( @@ -117,8 +185,7 @@ def test_oauth2_resource_owner_password_and_api_key_authentication_can_be_combin resource_owner_password_auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -129,9 +196,21 @@ def test_oauth2_resource_owner_password_and_api_key_authentication_can_be_combin }, ) api_key_auth = requests_auth.HeaderApiKey("my_provided_api_key") - header = get_header(responses, resource_owner_password_auth + api_key_auth) - assert header.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" - assert header.get("X-Api-Key") == "my_provided_api_key" + auth = resource_owner_password_auth + api_key_auth + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA", + "X-API-Key": "my_provided_api_key", + } + ) + ], + ) + + requests.get("http://authorized_only", auth=auth) def test_oauth2_resource_owner_password_and_multiple_authentication_can_be_combined( @@ -140,8 +219,7 @@ def test_oauth2_resource_owner_password_and_multiple_authentication_can_be_combi resource_owner_password_auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -155,12 +233,22 @@ def test_oauth2_resource_owner_password_and_multiple_authentication_can_be_combi api_key_auth2 = requests_auth.HeaderApiKey( "my_provided_api_key2", header_name="X-Api-Key2" ) - header = get_header( - responses, resource_owner_password_auth + (api_key_auth + api_key_auth2) + auth = resource_owner_password_auth + (api_key_auth + api_key_auth2) + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA", + "X-API-Key": "my_provided_api_key", + "X-Api-Key2": "my_provided_api_key2", + } + ) + ], ) - 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" + + requests.get("http://authorized_only", auth=auth) def test_oauth2_client_credential_and_api_key_authentication_can_be_combined( @@ -169,8 +257,7 @@ def test_oauth2_client_credential_and_api_key_authentication_can_be_combined( resource_owner_password_auth = requests_auth.OAuth2ClientCredentials( "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -181,9 +268,21 @@ def test_oauth2_client_credential_and_api_key_authentication_can_be_combined( }, ) api_key_auth = requests_auth.HeaderApiKey("my_provided_api_key") - header = get_header(responses, resource_owner_password_auth + api_key_auth) - assert header.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" - assert header.get("X-Api-Key") == "my_provided_api_key" + auth = resource_owner_password_auth + api_key_auth + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA", + "X-API-Key": "my_provided_api_key", + } + ) + ], + ) + + requests.get("http://authorized_only", auth=auth) def test_oauth2_client_credential_and_multiple_authentication_can_be_combined( @@ -192,8 +291,7 @@ def test_oauth2_client_credential_and_multiple_authentication_can_be_combined( resource_owner_password_auth = requests_auth.OAuth2ClientCredentials( "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -207,12 +305,22 @@ def test_oauth2_client_credential_and_multiple_authentication_can_be_combined( api_key_auth2 = requests_auth.HeaderApiKey( "my_provided_api_key2", header_name="X-Api-Key2" ) - header = get_header( - responses, resource_owner_password_auth + (api_key_auth + api_key_auth2) + auth = resource_owner_password_auth + (api_key_auth + api_key_auth2) + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA", + "X-API-Key": "my_provided_api_key", + "X-Api-Key2": "my_provided_api_key2", + } + ) + ], ) - 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" + + requests.get("http://authorized_only", auth=auth) def test_oauth2_authorization_code_and_api_key_authentication_can_be_combined( @@ -225,8 +333,7 @@ def test_oauth2_authorization_code_and_api_key_authentication_can_be_combined( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -237,13 +344,24 @@ def test_oauth2_authorization_code_and_api_key_authentication_can_be_combined( }, ) api_key_auth = requests_auth.HeaderApiKey("my_provided_api_key") - header = get_header(responses, authorization_code_auth + api_key_auth) - assert header.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" - assert header.get("X-Api-Key") == "my_provided_api_key" - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + auth = authorization_code_auth + api_key_auth + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA", + "X-API-Key": "my_provided_api_key", + } + ) + ], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_oauth2_authorization_code_and_multiple_authentication_can_be_combined( token_cache, responses: RequestsMock, browser_mock: BrowserMock @@ -255,8 +373,7 @@ def test_oauth2_authorization_code_and_multiple_authentication_can_be_combined( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -270,21 +387,32 @@ def test_oauth2_authorization_code_and_multiple_authentication_can_be_combined( api_key_auth2 = requests_auth.HeaderApiKey( "my_provided_api_key2", header_name="X-Api-Key2" ) - header = get_header( - responses, 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." + auth = authorization_code_auth + (api_key_auth + api_key_auth2) + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA", + "X-API-Key": "my_provided_api_key", + "X-Api-Key2": "my_provided_api_key2", + } + ) + ], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_oauth2_pkce_and_api_key_authentication_can_be_combined( token_cache, responses: RequestsMock, browser_mock: BrowserMock, monkeypatch ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) pkce_auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) @@ -292,8 +420,7 @@ def test_oauth2_pkce_and_api_key_authentication_can_be_combined( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -304,18 +431,31 @@ def test_oauth2_pkce_and_api_key_authentication_can_be_combined( }, ) api_key_auth = requests_auth.HeaderApiKey("my_provided_api_key") - header = get_header(responses, pkce_auth + api_key_auth) - assert header.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" - assert header.get("X-Api-Key") == "my_provided_api_key" - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + auth = pkce_auth + api_key_auth + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA", + "X-API-Key": "my_provided_api_key", + } + ) + ], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_oauth2_pkce_and_multiple_authentication_can_be_combined( token_cache, responses: RequestsMock, browser_mock: BrowserMock, monkeypatch ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) pkce_auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) @@ -323,8 +463,7 @@ def test_oauth2_pkce_and_multiple_authentication_can_be_combined( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -338,20 +477,33 @@ def test_oauth2_pkce_and_multiple_authentication_can_be_combined( api_key_auth2 = requests_auth.HeaderApiKey( "my_provided_api_key2", header_name="X-Api-Key2" ) - header = get_header(responses, 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." + auth = pkce_auth + (api_key_auth + api_key_auth2) + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA", + "X-API-Key": "my_provided_api_key", + "X-Api-Key2": "my_provided_api_key2", + } + ) + ], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_oauth2_implicit_and_api_key_authentication_can_be_combined( token_cache, responses: RequestsMock, browser_mock: BrowserMock ): implicit_auth = requests_auth.OAuth2Implicit("http://provide_token") - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + 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", @@ -359,19 +511,32 @@ def test_oauth2_implicit_and_api_key_authentication_can_be_combined( data=f"access_token={token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", ) api_key_auth = requests_auth.HeaderApiKey("my_provided_api_key") - header = get_header(responses, implicit_auth + api_key_auth) - assert 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." + auth = implicit_auth + api_key_auth + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": f"Bearer {token}", + "X-API-Key": "my_provided_api_key", + } + ) + ], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_oauth2_implicit_and_multiple_authentication_can_be_combined( token_cache, responses: RequestsMock, browser_mock: BrowserMock ): implicit_auth = requests_auth.OAuth2Implicit("http://provide_token") - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + 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", @@ -382,10 +547,21 @@ def test_oauth2_implicit_and_multiple_authentication_can_be_combined( api_key_auth2 = requests_auth.HeaderApiKey( "my_provided_api_key2", header_name="X-Api-Key2" ) - header = get_header(responses, implicit_auth + (api_key_auth + api_key_auth2)) - assert 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." + auth = implicit_auth + (api_key_auth + api_key_auth2) + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": f"Bearer {token}", + "X-API-Key": "my_provided_api_key", + "X-Api-Key2": "my_provided_api_key2", + } + ) + ], ) + + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() diff --git a/tests/test_and_operator.py b/tests/features/multi_auth/test_and_operator.py similarity index 60% rename from tests/test_and_operator.py rename to tests/features/multi_auth/test_and_operator.py index d98ef2f..4abe2af 100644 --- a/tests/test_and_operator.py +++ b/tests/features/multi_auth/test_and_operator.py @@ -2,18 +2,36 @@ from responses import RequestsMock import requests +from responses.matchers import header_matcher, query_string_matcher import requests_auth -from requests_auth.testing import BrowserMock, create_token, token_cache, browser_mock -from tests.auth_helper import get_header +from requests_auth.testing import ( + BrowserMock, + create_token, + token_cache, + browser_mock, +) # noqa: F401 +import requests_auth._oauth2.authorization_code_pkce def test_basic_and_api_key_authentication_can_be_combined(responses: RequestsMock): basic_auth = requests_auth.Basic("test_user", "test_pwd") api_key_auth = requests_auth.HeaderApiKey("my_provided_api_key") - header = get_header(responses, basic_auth & api_key_auth) - assert header.get("Authorization") == "Basic dGVzdF91c2VyOnRlc3RfcHdk" - assert header.get("X-Api-Key") == "my_provided_api_key" + auth = basic_auth & api_key_auth + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Basic dGVzdF91c2VyOnRlc3RfcHdk", + "X-API-Key": "my_provided_api_key", + } + ) + ], + ) + + requests.get("http://authorized_only", auth=auth) def test_header_api_key_and_multiple_authentication_can_be_combined( @@ -26,10 +44,22 @@ def test_header_api_key_and_multiple_authentication_can_be_combined( api_key_auth3 = requests_auth.HeaderApiKey( "my_provided_api_key3", header_name="X-Api-Key3" ) - header = get_header(responses, 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" + auth = api_key_auth & (api_key_auth2 & api_key_auth3) + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "X-API-Key": "my_provided_api_key", + "X-Api-Key2": "my_provided_api_key2", + "X-Api-Key3": "my_provided_api_key3", + } + ) + ], + ) + + requests.get("http://authorized_only", auth=auth) def test_multiple_auth_and_header_api_key_can_be_combined( @@ -42,10 +72,22 @@ def test_multiple_auth_and_header_api_key_can_be_combined( api_key_auth3 = requests_auth.HeaderApiKey( "my_provided_api_key3", header_name="X-Api-Key3" ) - header = get_header(responses, (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" + auth = (api_key_auth & api_key_auth2) & api_key_auth3 + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "X-API-Key": "my_provided_api_key", + "X-Api-Key2": "my_provided_api_key2", + "X-Api-Key3": "my_provided_api_key3", + } + ) + ], + ) + + requests.get("http://authorized_only", auth=auth) def test_multiple_auth_and_multiple_auth_can_be_combined( @@ -61,13 +103,23 @@ def test_multiple_auth_and_multiple_auth_can_be_combined( api_key_auth4 = requests_auth.HeaderApiKey( "my_provided_api_key4", header_name="X-Api-Key4" ) - header = get_header( - responses, (api_key_auth & api_key_auth2) & (api_key_auth3 & api_key_auth4) + auth = (api_key_auth & api_key_auth2) & (api_key_auth3 & api_key_auth4) + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "X-API-Key": "my_provided_api_key", + "X-Api-Key2": "my_provided_api_key2", + "X-Api-Key3": "my_provided_api_key3", + "X-Api-Key4": "my_provided_api_key4", + } + ) + ], ) - 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" + + requests.get("http://authorized_only", auth=auth) def test_basic_and_multiple_authentication_can_be_combined( @@ -80,10 +132,22 @@ def test_basic_and_multiple_authentication_can_be_combined( api_key_auth3 = requests_auth.HeaderApiKey( "my_provided_api_key3", header_name="X-Api-Key3" ) - header = get_header(responses, 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" + auth = basic_auth & (api_key_auth2 & api_key_auth3) + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Basic dGVzdF91c2VyOnRlc3RfcHdk", + "X-Api-Key2": "my_provided_api_key2", + "X-Api-Key3": "my_provided_api_key3", + } + ) + ], + ) + + requests.get("http://authorized_only", auth=auth) def test_query_api_key_and_multiple_authentication_can_be_combined( @@ -96,19 +160,23 @@ def test_query_api_key_and_multiple_authentication_can_be_combined( api_key_auth3 = requests_auth.HeaderApiKey( "my_provided_api_key3", header_name="X-Api-Key3" ) + auth = api_key_auth & (api_key_auth2 & api_key_auth3) - # Mock a dummy response - responses.add(responses.GET, "http://authorized_only") - # Send a request to this dummy URL with authentication - response = requests.get( - "http://authorized_only", auth=api_key_auth & (api_key_auth2 & api_key_auth3) - ) - # Return headers received on this dummy URL - assert ( - response.request.path_url - == "/?api_key=my_provided_api_key&api_key2=my_provided_api_key2" + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "X-Api-Key3": "my_provided_api_key3", + } + ), + query_string_matcher( + "api_key=my_provided_api_key&api_key2=my_provided_api_key2" + ), + ], ) - assert response.request.headers.get("X-Api-Key3") == "my_provided_api_key3" + + requests.get("http://authorized_only", auth=auth) def test_oauth2_resource_owner_password_and_api_key_authentication_can_be_combined( @@ -117,8 +185,9 @@ def test_oauth2_resource_owner_password_and_api_key_authentication_can_be_combin resource_owner_password_auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) - responses.add( - responses.POST, + api_key_auth = requests_auth.HeaderApiKey("my_provided_api_key") + auth = resource_owner_password_auth & api_key_auth + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -128,10 +197,20 @@ def test_oauth2_resource_owner_password_and_api_key_authentication_can_be_combin "example_parameter": "example_value", }, ) - api_key_auth = requests_auth.HeaderApiKey("my_provided_api_key") - header = get_header(responses, resource_owner_password_auth & api_key_auth) - assert header.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" - assert header.get("X-Api-Key") == "my_provided_api_key" + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA", + "X-API-Key": "my_provided_api_key", + } + ), + ], + ) + + requests.get("http://authorized_only", auth=auth) def test_oauth2_resource_owner_password_and_multiple_authentication_can_be_combined( @@ -140,8 +219,7 @@ def test_oauth2_resource_owner_password_and_multiple_authentication_can_be_combi resource_owner_password_auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -155,12 +233,22 @@ def test_oauth2_resource_owner_password_and_multiple_authentication_can_be_combi api_key_auth2 = requests_auth.HeaderApiKey( "my_provided_api_key2", header_name="X-Api-Key2" ) - header = get_header( - responses, resource_owner_password_auth & (api_key_auth & api_key_auth2) + auth = resource_owner_password_auth & (api_key_auth & api_key_auth2) + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA", + "X-API-Key": "my_provided_api_key", + "X-Api-Key2": "my_provided_api_key2", + } + ), + ], ) - 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" + + requests.get("http://authorized_only", auth=auth) def test_oauth2_client_credential_and_api_key_authentication_can_be_combined( @@ -169,8 +257,7 @@ def test_oauth2_client_credential_and_api_key_authentication_can_be_combined( resource_owner_password_auth = requests_auth.OAuth2ClientCredentials( "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -181,9 +268,21 @@ def test_oauth2_client_credential_and_api_key_authentication_can_be_combined( }, ) api_key_auth = requests_auth.HeaderApiKey("my_provided_api_key") - header = get_header(responses, resource_owner_password_auth & api_key_auth) - assert header.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" - assert header.get("X-Api-Key") == "my_provided_api_key" + auth = resource_owner_password_auth & api_key_auth + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA", + "X-API-Key": "my_provided_api_key", + } + ), + ], + ) + + requests.get("http://authorized_only", auth=auth) def test_oauth2_client_credential_and_multiple_authentication_can_be_combined( @@ -192,8 +291,7 @@ def test_oauth2_client_credential_and_multiple_authentication_can_be_combined( resource_owner_password_auth = requests_auth.OAuth2ClientCredentials( "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -207,12 +305,22 @@ def test_oauth2_client_credential_and_multiple_authentication_can_be_combined( api_key_auth2 = requests_auth.HeaderApiKey( "my_provided_api_key2", header_name="X-Api-Key2" ) - header = get_header( - responses, resource_owner_password_auth & (api_key_auth & api_key_auth2) + auth = resource_owner_password_auth & (api_key_auth & api_key_auth2) + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA", + "X-API-Key": "my_provided_api_key", + "X-Api-Key2": "my_provided_api_key2", + } + ), + ], ) - 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" + + requests.get("http://authorized_only", auth=auth) def test_oauth2_authorization_code_and_api_key_authentication_can_be_combined( @@ -225,8 +333,7 @@ def test_oauth2_authorization_code_and_api_key_authentication_can_be_combined( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -237,13 +344,24 @@ def test_oauth2_authorization_code_and_api_key_authentication_can_be_combined( }, ) api_key_auth = requests_auth.HeaderApiKey("my_provided_api_key") - header = get_header(responses, authorization_code_auth & api_key_auth) - assert header.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" - assert header.get("X-Api-Key") == "my_provided_api_key" - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + auth = authorization_code_auth & api_key_auth + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA", + "X-API-Key": "my_provided_api_key", + } + ), + ], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_oauth2_authorization_code_and_multiple_authentication_can_be_combined( token_cache, responses: RequestsMock, browser_mock: BrowserMock @@ -255,8 +373,7 @@ def test_oauth2_authorization_code_and_multiple_authentication_can_be_combined( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -270,21 +387,32 @@ def test_oauth2_authorization_code_and_multiple_authentication_can_be_combined( api_key_auth2 = requests_auth.HeaderApiKey( "my_provided_api_key2", header_name="X-Api-Key2" ) - header = get_header( - responses, 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." + auth = authorization_code_auth & (api_key_auth & api_key_auth2) + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA", + "X-API-Key": "my_provided_api_key", + "X-Api-Key2": "my_provided_api_key2", + } + ), + ], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_oauth2_pkce_and_api_key_authentication_can_be_combined( token_cache, responses: RequestsMock, browser_mock: BrowserMock, monkeypatch ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) pkce_auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) @@ -292,8 +420,7 @@ def test_oauth2_pkce_and_api_key_authentication_can_be_combined( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -304,18 +431,31 @@ def test_oauth2_pkce_and_api_key_authentication_can_be_combined( }, ) api_key_auth = requests_auth.HeaderApiKey("my_provided_api_key") - header = get_header(responses, pkce_auth & api_key_auth) - assert header.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" - assert header.get("X-Api-Key") == "my_provided_api_key" - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + auth = pkce_auth & api_key_auth + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA", + "X-API-Key": "my_provided_api_key", + } + ), + ], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_oauth2_pkce_and_multiple_authentication_can_be_combined( token_cache, responses: RequestsMock, browser_mock: BrowserMock, monkeypatch ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) pkce_auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) @@ -323,8 +463,7 @@ def test_oauth2_pkce_and_multiple_authentication_can_be_combined( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -338,20 +477,33 @@ def test_oauth2_pkce_and_multiple_authentication_can_be_combined( api_key_auth2 = requests_auth.HeaderApiKey( "my_provided_api_key2", header_name="X-Api-Key2" ) - header = get_header(responses, 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." + auth = pkce_auth & (api_key_auth & api_key_auth2) + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA", + "X-API-Key": "my_provided_api_key", + "X-Api-Key2": "my_provided_api_key2", + } + ), + ], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_oauth2_implicit_and_api_key_authentication_can_be_combined( token_cache, responses: RequestsMock, browser_mock: BrowserMock ): implicit_auth = requests_auth.OAuth2Implicit("http://provide_token") - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + 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", @@ -359,19 +511,32 @@ def test_oauth2_implicit_and_api_key_authentication_can_be_combined( data=f"access_token={token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", ) api_key_auth = requests_auth.HeaderApiKey("my_provided_api_key") - header = get_header(responses, implicit_auth & api_key_auth) - assert 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." + auth = implicit_auth & api_key_auth + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": f"Bearer {token}", + "X-API-Key": "my_provided_api_key", + } + ), + ], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_oauth2_implicit_and_multiple_authentication_can_be_combined( token_cache, responses: RequestsMock, browser_mock: BrowserMock ): implicit_auth = requests_auth.OAuth2Implicit("http://provide_token") - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + 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", @@ -382,10 +547,21 @@ def test_oauth2_implicit_and_multiple_authentication_can_be_combined( api_key_auth2 = requests_auth.HeaderApiKey( "my_provided_api_key2", header_name="X-Api-Key2" ) - header = get_header(responses, implicit_auth & (api_key_auth & api_key_auth2)) - assert 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." + auth = implicit_auth & (api_key_auth & api_key_auth2) + + responses.get( + "http://authorized_only", + match=[ + header_matcher( + { + "Authorization": f"Bearer {token}", + "X-API-Key": "my_provided_api_key", + "X-Api-Key2": "my_provided_api_key2", + } + ), + ], ) + + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() diff --git a/tests/features/pytest_fixture/__init__.py b/tests/features/pytest_fixture/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/features/pytest_fixture/test_testing_token_mock.py b/tests/features/pytest_fixture/test_testing_token_mock.py new file mode 100644 index 0000000..9cfc04e --- /dev/null +++ b/tests/features/pytest_fixture/test_testing_token_mock.py @@ -0,0 +1,18 @@ +import requests +from responses import RequestsMock +from responses.matchers import header_matcher + +import requests_auth +from requests_auth.testing import token_cache_mock, token_mock # noqa: F401 + + +def test_token_mock(token_cache_mock, responses: RequestsMock): + auth = requests_auth.OAuth2Implicit("http://provide_token") + expected_token = requests_auth.OAuth2.token_cache.get_token("") + + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {expected_token}"})], + ) + + requests.get("http://authorized_only", auth=auth) diff --git a/tests/features/token_cache/__init__.py b/tests/features/token_cache/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/features/token_cache/test_json_token_file_cache.py b/tests/features/token_cache/test_json_token_file_cache.py new file mode 100644 index 0000000..b01b1e9 --- /dev/null +++ b/tests/features/token_cache/test_json_token_file_cache.py @@ -0,0 +1,162 @@ +import datetime +import logging +import pathlib + +import pytest +import jwt +import requests + +import requests_auth +import requests_auth._oauth2.tokens + + +@pytest.fixture +def token_cache(tmp_path) -> requests_auth.JsonTokenFileCache: + _token_cache = requests_auth.JsonTokenFileCache(tmp_path / "my_tokens.cache") + yield _token_cache + _token_cache.clear() + + +def test_add_bearer_tokens(token_cache): + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=1) + token1 = jwt.encode({"exp": expiry_in_1_hour}, "secret") + token_cache._add_bearer_token("key1", token1) + + expiry_in_2_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=2) + token2 = jwt.encode({"exp": expiry_in_2_hour}, "secret") + 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, tmp_path): + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=1) + token1 = jwt.encode({"exp": expiry_in_1_hour}, "secret") + token_cache._add_bearer_token("key1", token1) + + expiry_in_2_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=2) + token2 = jwt.encode({"exp": expiry_in_2_hour}, "secret") + token_cache._add_bearer_token("key2", token2) + + same_cache = requests_auth.JsonTokenFileCache(tmp_path / "my_tokens.cache") + assert same_cache.get_token("key1") == token1 + assert same_cache.get_token("key2") == token2 + + +def test_save_bearer_token_exception_handling( + token_cache, tmp_path, monkeypatch, caplog +): + def failing_dump(*args): + raise Exception("Failure") + + monkeypatch.setattr(requests_auth._oauth2.tokens.json, "dump", failing_dump) + + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=1) + token1 = jwt.encode({"exp": expiry_in_1_hour}, "secret") + + caplog.set_level(logging.DEBUG) + + # Assert that the exception is not thrown + token_cache._add_bearer_token("key1", token1) + + same_cache = requests_auth.JsonTokenFileCache(tmp_path / "my_tokens.cache") + with pytest.raises(requests_auth.AuthenticationFailed) as exception_info: + same_cache.get_token("key1") + assert str(exception_info.value) == "User was not authenticated." + assert isinstance(exception_info.value, requests_auth.RequestsAuthException) + assert isinstance(exception_info.value, requests.RequestException) + + assert caplog.messages == [ + "Cannot save tokens.", + f'Inserting token expiring on {expiry_in_1_hour:%Y-%m-%d %H:%M:%S+00:00} with "key1" key.', + "Cannot load tokens.", + 'Retrieving token with "key1" key.', + "Token cannot be found in cache.", + "User was not authenticated: key key1 cannot be found in [].", + ] + + +def test_missing_token_on_empty_cache(token_cache, caplog): + caplog.set_level(logging.DEBUG) + with pytest.raises(requests_auth.AuthenticationFailed): + token_cache.get_token("key1") + assert caplog.messages == [ + 'Retrieving token with "key1" key.', + "No token loaded. Token cache does not exists.", + "Token cannot be found in cache.", + "User was not authenticated: key key1 cannot be found in [].", + ] + + +def test_missing_token_on_non_empty_cache(token_cache, caplog): + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=1) + token1 = jwt.encode({"exp": expiry_in_1_hour}, "secret") + token_cache._add_bearer_token("key0", token1) + + caplog.set_level(logging.DEBUG) + with pytest.raises(requests_auth.AuthenticationFailed): + token_cache.get_token("key1") + assert caplog.messages == [ + 'Retrieving token with "key1" key.', + "Token cannot be found in cache.", + "User was not authenticated: key key1 cannot be found in ['key0'].", + ] + + +def test_missing_token_function(token_cache): + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=1) + token = jwt.encode({"exp": expiry_in_1_hour}, "secret") + retrieved_token = token_cache.get_token( + "key1", on_missing_token=lambda: ("key1", token) + ) + assert retrieved_token == token + + +def test_token_without_refresh_token(token_cache): + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=1) + # add token without refresh token + token = jwt.encode({"exp": expiry_in_1_hour}, "secret") + token_cache.tokens["key1"] = ( + token, + expiry_in_1_hour.timestamp(), + ) + token_cache._save_tokens() + + # try to retrieve it + retrieved_token = token_cache.get_token("key1") + assert token == retrieved_token + + +def test_unable_to_remove_cache(token_cache, tmp_path, monkeypatch, caplog): + def unlink_failure(*args): + raise PermissionError("You can create but can't delete") + + monkeypatch.setattr(pathlib.Path, "unlink", unlink_failure) + + caplog.set_level(logging.DEBUG) + # Assert that the exception is not thrown + token_cache.clear() + + assert caplog.messages == ["Clearing token cache.", "Cannot remove tokens file."] diff --git a/tests/test_testing_oauth2_authorization_code.py b/tests/features/token_cache/test_testing_oauth2_authorization_code.py similarity index 53% rename from tests/test_testing_oauth2_authorization_code.py rename to tests/features/token_cache/test_testing_oauth2_authorization_code.py index 5081ba6..06c50e5 100644 --- a/tests/test_testing_oauth2_authorization_code.py +++ b/tests/features/token_cache/test_testing_oauth2_authorization_code.py @@ -1,9 +1,10 @@ import pytest +import requests from responses import RequestsMock +from responses.matchers import header_matcher import requests_auth -from requests_auth.testing import token_cache_mock -from tests.auth_helper import get_header +from requests_auth.testing import token_cache_mock # noqa: F401 @pytest.fixture @@ -15,37 +16,49 @@ def test_oauth2_authorization_code_flow(token_cache_mock, responses: RequestsMoc auth = requests_auth.OAuth2AuthorizationCode( "http://provide_code", "http://provide_access_token" ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_okta_authorization_code_flow(token_cache_mock, responses: RequestsMock): auth = requests_auth.OktaAuthorizationCode( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_oauth2_authorization_code_pkce_flow(token_cache_mock, responses: RequestsMock): auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_okta_authorization_code_pkce_flow(token_cache_mock, responses: RequestsMock): auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + + requests.get("http://authorized_only", auth=auth) diff --git a/tests/test_testing_oauth2_implicit.py b/tests/features/token_cache/test_testing_oauth2_implicit.py similarity index 51% rename from tests/test_testing_oauth2_implicit.py rename to tests/features/token_cache/test_testing_oauth2_implicit.py index 4c435f7..427936b 100644 --- a/tests/test_testing_oauth2_implicit.py +++ b/tests/features/token_cache/test_testing_oauth2_implicit.py @@ -1,9 +1,10 @@ import pytest +import requests from responses import RequestsMock +from responses.matchers import header_matcher import requests_auth -from requests_auth.testing import token_cache_mock -from tests.auth_helper import get_header +from requests_auth.testing import token_cache_mock # noqa: F401 @pytest.fixture @@ -13,47 +14,62 @@ def token_mock() -> str: def test_oauth2_implicit_flow(token_cache_mock, responses: RequestsMock): auth = requests_auth.OAuth2Implicit("http://provide_token") - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_okta_implicit_flow(token_cache_mock, responses: RequestsMock): auth = requests_auth.OktaImplicit( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_aad_implicit_flow(token_cache_mock, responses: RequestsMock): auth = requests_auth.AzureActiveDirectoryImplicit( "45239d18-c68c-4c47-8bdd-ce71ea1d50cd", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_okta_implicit_id_token_flow(token_cache_mock, responses: RequestsMock): auth = requests_auth.OktaImplicitIdToken( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_aad_implicit_id_token_flow(token_cache_mock, responses: RequestsMock): auth = requests_auth.AzureActiveDirectoryImplicitIdToken( "45239d18-c68c-4c47-8bdd-ce71ea1d50cd", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + + requests.get("http://authorized_only", auth=auth) diff --git a/tests/oauth2/__init__.py b/tests/oauth2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/oauth2/authorization_code/__init__.py b/tests/oauth2/authorization_code/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_oauth2_authorization_code.py b/tests/oauth2/authorization_code/test_oauth2_authorization_code.py similarity index 76% rename from tests/test_oauth2_authorization_code.py rename to tests/oauth2/authorization_code/test_oauth2_authorization_code.py index 58c399d..368c9b6 100644 --- a/tests/test_oauth2_authorization_code.py +++ b/tests/oauth2/authorization_code/test_oauth2_authorization_code.py @@ -1,11 +1,10 @@ from responses import RequestsMock -from responses.matchers import urlencoded_params_matcher +from responses.matchers import urlencoded_params_matcher, header_matcher import pytest import requests import requests_auth -from requests_auth.testing import BrowserMock, browser_mock, token_cache -from tests.auth_helper import get_header, get_request +from requests_auth.testing import BrowserMock, browser_mock, token_cache # noqa: F401 def test_oauth2_authorization_code_flow_uses_provided_session( @@ -20,8 +19,7 @@ def test_oauth2_authorization_code_flow_uses_provided_session( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -30,34 +28,86 @@ def test_oauth2_authorization_code_flow_uses_provided_session( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + header_matcher({"x-test": "Test value"}), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) - request = get_request(responses, "http://provide_access_token/") - assert ( - request.body - == "grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" + + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + + +def test_oauth2_authorization_code_flow_uses_redirect_uri_domain( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", + "http://provide_access_token", + redirect_uri_domain="localhost.mycompany.com", + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost.mycompany.com%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", + ) + responses.post( + "http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost.mycompany.com:5000/", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], ) - assert request.headers["x-test"] == "Test value" - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) -def test_oauth2_authorization_code_flow_get_code_is_sent_in_authorization_header_by_default( + tab.assert_success() + + +def test_oauth2_authorization_code_flow_uses_custom_success( token_cache, responses: RequestsMock, browser_mock: BrowserMock ): auth = requests_auth.OAuth2AuthorizationCode( - "http://provide_code", "http://provide_access_token" + "http://provide_code", + "http://provide_access_token", + ) + requests_auth.OAuth2.display.success_html = ( + "
SUCCESS: {display_time}
" ) 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", + displayed_html="
SUCCESS: {display_time}
", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -66,19 +116,91 @@ def test_oauth2_authorization_code_flow_get_code_is_sent_in_authorization_header "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" + + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + + +def test_oauth2_authorization_code_flow_uses_custom_failure( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", + "http://provide_access_token", + ) + requests_auth.OAuth2.display.failure_html = "FAILURE: {display_time}\n{information}" + 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", + displayed_html="FAILURE: {display_time}\n{information}", + ) + + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("http://authorized_only", auth=auth) + + assert isinstance(exception_info.value, requests_auth.RequestsAuthException) + assert isinstance(exception_info.value, requests.RequestException) + + tab.assert_failure( + "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_oauth2_authorization_code_flow_get_code_is_sent_in_authorization_header_by_default( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2AuthorizationCode( + "http://provide_code", "http://provide_access_token" + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", + ) + responses.post( + "http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], ) - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_oauth2_authorization_code_flow_token_is_expired_after_30_seconds_by_default( token_cache, responses: RequestsMock, browser_mock: BrowserMock @@ -94,11 +216,10 @@ def test_oauth2_authorization_code_flow_token_is_expired_after_30_seconds_by_def token_cache._add_token( key="163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", token="2YotnFZFEjr1zCsicMWpAA", - expiry=requests_auth.oauth2_tokens._to_expiry(expires_in=29), + expiry=requests_auth._oauth2.tokens._to_expiry(expires_in=29), ) # Meaning a new one will be requested - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -107,19 +228,26 @@ def test_oauth2_authorization_code_flow_token_is_expired_after_30_seconds_by_def "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" - ) - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_oauth2_client_credentials_flow_token_custom_expiry( token_cache, responses: RequestsMock, browser_mock: BrowserMock @@ -133,13 +261,15 @@ def test_oauth2_client_credentials_flow_token_custom_expiry( token_cache._add_token( key="163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", token="2YotnFZFEjr1zCsicMWpAA", - expiry=requests_auth.oauth2_tokens._to_expiry(expires_in=29), + expiry=requests_auth._oauth2.tokens._to_expiry(expires_in=29), ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_refresh_token(token_cache, responses: RequestsMock, browser_mock: BrowserMock): auth = requests_auth.OAuth2AuthorizationCode( @@ -149,8 +279,7 @@ def test_refresh_token(token_cache, responses: RequestsMock, browser_mock: Brows opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -170,20 +299,17 @@ def test_refresh_token(token_cache, responses: RequestsMock, browser_mock: Brows ) ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" - ) - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + # response for refresh token grant - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "rVR7Syg5bjZtZYjbZIW", @@ -202,14 +328,13 @@ def test_refresh_token(token_cache, responses: RequestsMock, browser_mock: Brows ) ], ) - - response = requests.get("http://authorized_only", auth=auth) - assert response.request.headers.get("Authorization") == "Bearer rVR7Syg5bjZtZYjbZIW" - assert ( - get_request(responses, "http://provide_access_token/").body - == "grant_type=refresh_token&response_type=code&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer rVR7Syg5bjZtZYjbZIW"})], ) + requests.get("http://authorized_only", auth=auth) + def test_refresh_token_invalid( token_cache, responses: RequestsMock, browser_mock: BrowserMock @@ -221,8 +346,7 @@ def test_refresh_token_invalid( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -242,20 +366,16 @@ def test_refresh_token_invalid( ) ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" - ) - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() # response for refresh token grant - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_request"}, status=400, @@ -277,14 +397,14 @@ def test_refresh_token_invalid( ) # if refreshing the token fails, fallback to requesting a new token - response = requests.get("http://authorized_only", auth=auth) - assert ( - response.request.headers.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." - ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() def test_refresh_token_access_token_not_expired( @@ -297,8 +417,7 @@ def test_refresh_token_access_token_not_expired( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -318,24 +437,23 @@ def test_refresh_token_access_token_not_expired( ) ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" - ) - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + # expect Bearer token to remain the same - response = requests.get("http://authorized_only", auth=auth) - assert ( - response.request.headers.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_empty_token_is_invalid( token_cache, responses: RequestsMock, browser_mock: BrowserMock @@ -347,8 +465,7 @@ def test_empty_token_is_invalid( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "", @@ -364,9 +481,9 @@ def test_empty_token_is_invalid( str(exception_info.value) == "access_token not provided within {'access_token': '', 'token_type': 'example', 'expires_in': 3600, 'refresh_token': 'tGzv3JOkF0XG5Qx2TlKWIA', 'example_parameter': 'example_value'}." ) - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." - ) + assert isinstance(exception_info.value, requests_auth.RequestsAuthException) + assert isinstance(exception_info.value, requests.RequestException) + tab.assert_success() def test_with_invalid_grant_request_no_json( @@ -379,15 +496,11 @@ def test_with_invalid_grant_request_no_json( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, "http://provide_access_token", body="failure", status=400 - ) + responses.post("http://provide_access_token", body="failure", status=400) with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: requests.get("http://authorized_only", auth=auth) assert str(exception_info.value) == "failure" - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_request_error( @@ -400,8 +513,7 @@ def test_with_invalid_grant_request_invalid_request_error( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_request"}, status=400, @@ -415,9 +527,7 @@ def test_with_invalid_grant_request_invalid_request_error( "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." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_request_error_and_error_description( @@ -430,8 +540,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_request", "error_description": "desc of the error"}, status=400, @@ -439,9 +548,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description( with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: requests.get("http://authorized_only", auth=auth) assert str(exception_info.value) == "invalid_request: desc of the error" - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri( @@ -454,8 +561,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "error": "invalid_request", @@ -470,9 +576,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ 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." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri_and_other_fields( @@ -485,8 +589,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "error": "invalid_request", @@ -502,9 +605,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ 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." - ) + tab.assert_success() def test_with_invalid_grant_request_without_error( @@ -517,8 +618,7 @@ def test_with_invalid_grant_request_without_error( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"other": "other info"}, status=400, @@ -526,9 +626,7 @@ def test_with_invalid_grant_request_without_error( with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: requests.get("http://authorized_only", auth=auth) assert str(exception_info.value) == "{'other': 'other info'}" - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_client_error( @@ -541,8 +639,7 @@ def test_with_invalid_grant_request_invalid_client_error( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_client"}, status=400, @@ -560,9 +657,7 @@ def test_with_invalid_grant_request_invalid_client_error( '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." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_grant_error( @@ -575,8 +670,7 @@ def test_with_invalid_grant_request_invalid_grant_error( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_grant"}, status=400, @@ -590,9 +684,7 @@ def test_with_invalid_grant_request_invalid_grant_error( "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." - ) + tab.assert_success() def test_with_invalid_grant_request_unauthorized_client_error( @@ -605,8 +697,7 @@ def test_with_invalid_grant_request_unauthorized_client_error( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "unauthorized_client"}, status=400, @@ -618,9 +709,7 @@ def test_with_invalid_grant_request_unauthorized_client_error( == "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." - ) + tab.assert_success() def test_with_invalid_grant_request_unsupported_grant_type_error( @@ -633,8 +722,7 @@ def test_with_invalid_grant_request_unsupported_grant_type_error( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "unsupported_grant_type"}, status=400, @@ -646,9 +734,7 @@ def test_with_invalid_grant_request_unsupported_grant_type_error( == "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." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_scope_error( @@ -661,8 +747,7 @@ def test_with_invalid_grant_request_invalid_scope_error( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_scope"}, status=400, @@ -674,9 +759,7 @@ def test_with_invalid_grant_request_invalid_scope_error( == "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." - ) + tab.assert_success() def test_with_invalid_token_request_invalid_request_error( @@ -696,7 +779,7 @@ def test_with_invalid_token_request_invalid_request_error( == "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." + "invalid_request: The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed." ) @@ -713,9 +796,7 @@ def test_with_invalid_token_request_invalid_request_error_and_error_description( with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: requests.get("http://authorized_only", auth=auth) assert str(exception_info.value) == "invalid_request: desc" - tab.assert_failure( - "Unable to properly perform authentication: invalid_request: desc" - ) + tab.assert_failure("invalid_request: desc") def test_with_invalid_token_request_invalid_request_error_and_error_description_and_uri( @@ -735,7 +816,7 @@ def test_with_invalid_token_request_invalid_request_error_and_error_description_ == "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" + "invalid_request: desc
More information can be found on http://test_url" ) @@ -756,7 +837,7 @@ def test_with_invalid_token_request_invalid_request_error_and_error_description_ == "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']}" + "invalid_request: desc
More information can be found on http://test_url
Additional information: {'other': ['test']}" ) @@ -777,7 +858,7 @@ def test_with_invalid_token_request_unauthorized_client_error( == "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." + "unauthorized_client: The client is not authorized to request an authorization code or an access token using this method." ) @@ -798,7 +879,7 @@ def test_with_invalid_token_request_access_denied_error( == "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." + "access_denied: The resource owner or authorization server denied the request." ) @@ -819,7 +900,7 @@ def test_with_invalid_token_request_unsupported_response_type_error( == "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." + "unsupported_response_type: The authorization server does not support obtaining an authorization code or an access token using this method." ) @@ -840,7 +921,7 @@ def test_with_invalid_token_request_invalid_scope_error( == "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." + "invalid_scope: The requested scope is invalid, unknown, or malformed." ) @@ -861,7 +942,7 @@ def test_with_invalid_token_request_server_error_error( == "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.)" + "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.)" ) @@ -882,7 +963,7 @@ def test_with_invalid_token_request_temporarily_unavailable_error( == "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.)" + "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.)" ) @@ -896,8 +977,7 @@ def test_nonce_is_sent_if_provided_in_authorization_url( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -906,19 +986,26 @@ def test_nonce_is_sent_if_provided_in_authorization_url( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" - ) - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_response_type_can_be_provided_in_url( token_cache, responses: RequestsMock, browser_mock: BrowserMock @@ -932,8 +1019,7 @@ def test_response_type_can_be_provided_in_url( opened_url="http://provide_code?response_type=my_code&state=49b67a19e70f692c3fc09dd124e5782b41a86f4f4931e1cc938ccbb466eecf1b730edb9eb01e42005de77ce3dd5a016418f8e780f30c4477d71102fe03e39e62&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=49b67a19e70f692c3fc09dd124e5782b41a86f4f4931e1cc938ccbb466eecf1b730edb9eb01e42005de77ce3dd5a016418f8e780f30c4477d71102fe03e39e62", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -942,19 +1028,25 @@ def test_response_type_can_be_provided_in_url( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code=SplxlOBeZQQYbYS6WxSbIA" - ) - tab.assert_success( - "You are now authenticated on 49b67a19e70f692c3fc09dd124e5782b41a86f4f4931e1cc938ccbb466eecf1b730edb9eb01e42005de77ce3dd5a016418f8e780f30c4477d71102fe03e39e62. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_authorization_url_is_mandatory(): with pytest.raises(Exception) as exception_info: diff --git a/tests/test_oauth2_authorization_code_okta.py b/tests/oauth2/authorization_code/test_oauth2_authorization_code_okta.py similarity index 77% rename from tests/test_oauth2_authorization_code_okta.py rename to tests/oauth2/authorization_code/test_oauth2_authorization_code_okta.py index c1551c4..2b24009 100644 --- a/tests/test_oauth2_authorization_code_okta.py +++ b/tests/oauth2/authorization_code/test_oauth2_authorization_code_okta.py @@ -1,10 +1,10 @@ from responses import RequestsMock +from responses.matchers import urlencoded_params_matcher, header_matcher import pytest import requests import requests_auth -from requests_auth.testing import BrowserMock, browser_mock, token_cache -from tests.auth_helper import get_header, get_request +from requests_auth.testing import BrowserMock, browser_mock, token_cache # noqa: F401 def test_oauth2_authorization_code_flow_uses_provided_session( @@ -21,8 +21,7 @@ def test_oauth2_authorization_code_flow_uses_provided_session( opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -31,36 +30,90 @@ def test_oauth2_authorization_code_flow_uses_provided_session( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "client_id": "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + "scope": "openid", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + header_matcher({"x-test": "Test value"}), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) - request = get_request( - responses, "https://testserver.okta-emea.com/oauth2/default/v1/token" + + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + + +def test_oauth2_authorization_code_flow_uses_redirect_uri_domain( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", + "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + redirect_uri_domain="localhost.mycompany.com", ) - assert ( - request.body - == "grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" + 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.mycompany.com%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", + ) + responses.post( + "https://testserver.okta-emea.com/oauth2/default/v1/token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost.mycompany.com:5000/", + "client_id": "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + "scope": "openid", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], ) - assert request.headers["x-test"] == "Test value" - tab.assert_success( - "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) -def test_okta_authorization_code_flow_get_code_is_sent_in_authorization_header_by_default( + tab.assert_success() + + +def test_oauth2_authorization_code_flow_uses_custom_success( token_cache, responses: RequestsMock, browser_mock: BrowserMock ): auth = requests_auth.OktaAuthorizationCode( - "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + "testserver.okta-emea.com", + "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + ) + requests_auth.OAuth2.display.success_html = ( + "
SUCCESS: {display_time}
" ) 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", + displayed_html="
SUCCESS: {display_time}
", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -69,21 +122,92 @@ def test_okta_authorization_code_flow_get_code_is_sent_in_authorization_header_b "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "client_id": "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + "scope": "openid", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) - assert ( - get_request( - responses, "https://testserver.okta-emea.com/oauth2/default/v1/token" - ).body - == "grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" + + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + + +def test_oauth2_authorization_code_flow_uses_custom_failure( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.OktaAuthorizationCode( + "testserver.okta-emea.com", + "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + ) + requests_auth.OAuth2.display.failure_html = "FAILURE: {display_time}\n{information}" + 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", + displayed_html="FAILURE: {display_time}\n{information}", + ) + + with pytest.raises(requests_auth.InvalidGrantRequest): + requests.get("http://authorized_only", auth=auth) + + tab.assert_failure( + "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_okta_authorization_code_flow_get_code_is_sent_in_authorization_header_by_default( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_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", + ) + responses.post( + "https://testserver.okta-emea.com/oauth2/default/v1/token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "client_id": "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + "scope": "openid", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], ) - tab.assert_success( - "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_okta_authorization_code_flow_token_is_expired_after_30_seconds_by_default( token_cache, responses: RequestsMock, browser_mock: BrowserMock @@ -99,11 +223,10 @@ def test_okta_authorization_code_flow_token_is_expired_after_30_seconds_by_defau token_cache._add_token( key="5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", token="2YotnFZFEjr1zCsicMWpAA", - expiry=requests_auth.oauth2_tokens._to_expiry(expires_in=29), + expiry=requests_auth._oauth2.tokens._to_expiry(expires_in=29), ) # Meaning a new one will be requested - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -112,21 +235,28 @@ def test_okta_authorization_code_flow_token_is_expired_after_30_seconds_by_defau "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "client_id": "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + "scope": "openid", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request( - responses, "https://testserver.okta-emea.com/oauth2/default/v1/token" - ).body - == "grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" - ) - tab.assert_success( - "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_okta_authorization_code_flow_token_custom_expiry( token_cache, responses: RequestsMock, browser_mock: BrowserMock @@ -140,13 +270,15 @@ def test_okta_authorization_code_flow_token_custom_expiry( token_cache._add_token( key="5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", token="2YotnFZFEjr1zCsicMWpAA", - expiry=requests_auth.oauth2_tokens._to_expiry(expires_in=29), + expiry=requests_auth._oauth2.tokens._to_expiry(expires_in=29), ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_empty_token_is_invalid( token_cache, responses: RequestsMock, browser_mock: BrowserMock @@ -158,8 +290,7 @@ def test_empty_token_is_invalid( opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "access_token": "", @@ -175,9 +306,7 @@ def test_empty_token_is_invalid( str(exception_info.value) == "access_token not provided within {'access_token': '', 'token_type': 'example', 'expires_in': 3600, 'refresh_token': 'tGzv3JOkF0XG5Qx2TlKWIA', 'example_parameter': 'example_value'}." ) - tab.assert_success( - "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." - ) + tab.assert_success() def test_with_invalid_grant_request_no_json( @@ -190,8 +319,7 @@ def test_with_invalid_grant_request_no_json( opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", body="failure", status=400, @@ -199,9 +327,7 @@ def test_with_invalid_grant_request_no_json( with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: requests.get("http://authorized_only", auth=auth) assert str(exception_info.value) == "failure" - tab.assert_success( - "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_request_error( @@ -214,8 +340,7 @@ def test_with_invalid_grant_request_invalid_request_error( opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "invalid_request"}, status=400, @@ -229,9 +354,7 @@ def test_with_invalid_grant_request_invalid_request_error( "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." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_request_error_and_error_description( @@ -244,8 +367,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description( opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "invalid_request", "error_description": "desc of the error"}, status=400, @@ -253,9 +375,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description( with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: requests.get("http://authorized_only", auth=auth) assert str(exception_info.value) == "invalid_request: desc of the error" - tab.assert_success( - "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri( @@ -268,8 +388,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "error": "invalid_request", @@ -284,9 +403,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ 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." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri_and_other_fields( @@ -299,8 +416,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "error": "invalid_request", @@ -316,9 +432,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ 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." - ) + tab.assert_success() def test_with_invalid_grant_request_without_error( @@ -331,8 +445,7 @@ def test_with_invalid_grant_request_without_error( opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"other": "other info"}, status=400, @@ -340,9 +453,7 @@ def test_with_invalid_grant_request_without_error( with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: requests.get("http://authorized_only", auth=auth) assert str(exception_info.value) == "{'other': 'other info'}" - tab.assert_success( - "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_client_error( @@ -355,8 +466,7 @@ def test_with_invalid_grant_request_invalid_client_error( opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "invalid_client"}, status=400, @@ -374,9 +484,7 @@ def test_with_invalid_grant_request_invalid_client_error( '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." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_grant_error( @@ -389,8 +497,7 @@ def test_with_invalid_grant_request_invalid_grant_error( opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "invalid_grant"}, status=400, @@ -404,9 +511,7 @@ def test_with_invalid_grant_request_invalid_grant_error( "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." - ) + tab.assert_success() def test_with_invalid_grant_request_unauthorized_client_error( @@ -419,8 +524,7 @@ def test_with_invalid_grant_request_unauthorized_client_error( opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "unauthorized_client"}, status=400, @@ -432,9 +536,7 @@ def test_with_invalid_grant_request_unauthorized_client_error( == "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." - ) + tab.assert_success() def test_with_invalid_grant_request_unsupported_grant_type_error( @@ -447,8 +549,7 @@ def test_with_invalid_grant_request_unsupported_grant_type_error( opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "unsupported_grant_type"}, status=400, @@ -460,9 +561,7 @@ def test_with_invalid_grant_request_unsupported_grant_type_error( == "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." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_scope_error( @@ -475,8 +574,7 @@ def test_with_invalid_grant_request_invalid_scope_error( opened_url="https://testserver.okta-emea.com/oauth2/default/v1/authorize?client_id=54239d18-c68c-4c47-8bdd-ce71ea1d50cd&scope=openid&response_type=code&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "invalid_scope"}, status=400, @@ -488,9 +586,7 @@ def test_with_invalid_grant_request_invalid_scope_error( == "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." - ) + tab.assert_success() def test_with_invalid_token_request_invalid_request_error( @@ -510,7 +606,7 @@ def test_with_invalid_token_request_invalid_request_error( == "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." + "invalid_request: The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed." ) @@ -527,9 +623,7 @@ def test_with_invalid_token_request_invalid_request_error_and_error_description( with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: requests.get("http://authorized_only", auth=auth) assert str(exception_info.value) == "invalid_request: desc" - tab.assert_failure( - "Unable to properly perform authentication: invalid_request: desc" - ) + tab.assert_failure("invalid_request: desc") def test_with_invalid_token_request_invalid_request_error_and_error_description_and_uri( @@ -549,7 +643,7 @@ def test_with_invalid_token_request_invalid_request_error_and_error_description_ == "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" + "invalid_request: desc
More information can be found on http://test_url" ) @@ -570,7 +664,7 @@ def test_with_invalid_token_request_invalid_request_error_and_error_description_ == "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']}" + "invalid_request: desc
More information can be found on http://test_url
Additional information: {'other': ['test']}" ) @@ -591,7 +685,7 @@ def test_with_invalid_token_request_unauthorized_client_error( == "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." + "unauthorized_client: The client is not authorized to request an authorization code or an access token using this method." ) @@ -612,7 +706,7 @@ def test_with_invalid_token_request_access_denied_error( == "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." + "access_denied: The resource owner or authorization server denied the request." ) @@ -633,7 +727,7 @@ def test_with_invalid_token_request_unsupported_response_type_error( == "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." + "unsupported_response_type: The authorization server does not support obtaining an authorization code or an access token using this method." ) @@ -654,7 +748,7 @@ def test_with_invalid_token_request_invalid_scope_error( == "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." + "invalid_scope: The requested scope is invalid, unknown, or malformed." ) @@ -675,7 +769,7 @@ def test_with_invalid_token_request_server_error_error( == "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.)" + "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.)" ) @@ -696,7 +790,7 @@ def test_with_invalid_token_request_temporarily_unavailable_error( == "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.)" + "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.)" ) diff --git a/tests/oauth2/authorization_code/test_oauth2_authorization_code_wakatime.py b/tests/oauth2/authorization_code/test_oauth2_authorization_code_wakatime.py new file mode 100644 index 0000000..4f78cbe --- /dev/null +++ b/tests/oauth2/authorization_code/test_oauth2_authorization_code_wakatime.py @@ -0,0 +1,1182 @@ +from responses import RequestsMock +from responses.matchers import urlencoded_params_matcher, header_matcher +import pytest +import requests + +import requests_auth +from requests_auth.testing import BrowserMock, browser_mock, token_cache # noqa: F401 +from requests_auth._oauth2.tokens import _to_expiry + + +def test_oauth2_authorization_code_flow_uses_provided_client( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + session = requests.Session() + session.headers = {"x-test": "Test value"} + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + session=session, + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + body="access_token=waka_tok_12345&token_type=bearer&expires_in=3600&refresh_token=waka_ref_12345&scope=email&example_parameter=example_value", + content_type="text/html; charset=utf-8", + match=[ + header_matcher({"x-test": "Test value"}), + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "client_id": "jPJQV0op6Pu3b66MWDi8b1wD", + "client_secret": "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + "scope": "email", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], + ) + responses.get( + "https://authorized_only", + match=[header_matcher({"Authorization": "Bearer waka_tok_12345"})], + ) + + requests.get("https://authorized_only", auth=auth) + + tab.assert_success() + + +def test_oauth2_authorization_code_flow_uses_redirect_uri_domain( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + redirect_uri_domain="localhost.mycompany.com", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost.mycompany.com%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + body="access_token=waka_tok_12345&token_type=bearer&expires_in=3600&refresh_token=waka_ref_12345&scope=email&example_parameter=example_value", + content_type="text/html; charset=utf-8", + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost.mycompany.com:5000/", + "client_id": "jPJQV0op6Pu3b66MWDi8b1wD", + "client_secret": "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + "scope": "email", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ) + ], + ) + responses.get( + "https://authorized_only", + match=[header_matcher({"Authorization": "Bearer waka_tok_12345"})], + ) + + requests.get("https://authorized_only", auth=auth) + + tab.assert_success() + + +def test_oauth2_authorization_code_flow_uses_custom_success( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + requests_auth.OAuth2.display.success_html = ( + "
SUCCESS: {display_time}
" + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + displayed_html="
SUCCESS: {display_time}
", + ) + responses.post( + "https://wakatime.com/oauth/token", + body="access_token=waka_tok_12345&token_type=bearer&expires_in=3600&refresh_token=waka_ref_12345&scope=email&example_parameter=example_value", + content_type="text/html; charset=utf-8", + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "client_id": "jPJQV0op6Pu3b66MWDi8b1wD", + "client_secret": "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + "scope": "email", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ) + ], + ) + responses.get( + "https://authorized_only", + match=[header_matcher({"Authorization": "Bearer waka_tok_12345"})], + ) + + requests.get("https://authorized_only", auth=auth) + + tab.assert_success() + + +def test_oauth2_authorization_code_flow_uses_custom_failure( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + requests_auth.OAuth2.display.failure_html = "FAILURE: {display_time}\n{information}" + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=invalid_request", + displayed_html="FAILURE: {display_time}\n{information}", + ) + + with pytest.raises(requests_auth.InvalidGrantRequest): + requests.get("http://authorized_only", auth=auth) + + tab.assert_failure( + "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_multiple_scopes_are_comma_separated( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope=["email", "read_stats"], + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email%2Cread_stats&response_type=code&state=34f21f9ea8be7b1dfd3dd1673a9aea7c3a1737228b4f08bc11ebacb88449afaa658811f8022e9962927a0ec42805c0e3cc5e6b0d9185308216b298a686001a1f&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=34f21f9ea8be7b1dfd3dd1673a9aea7c3a1737228b4f08bc11ebacb88449afaa658811f8022e9962927a0ec42805c0e3cc5e6b0d9185308216b298a686001a1f", + ) + responses.post( + "https://wakatime.com/oauth/token", + body="access_token=waka_tok_12345&token_type=bearer&expires_in=3600&refresh_token=waka_ref_12345&scope=email&example_parameter=example_value", + content_type="text/html; charset=utf-8", + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "client_id": "jPJQV0op6Pu3b66MWDi8b1wD", + "client_secret": "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + "scope": "email,read_stats", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ) + ], + ) + responses.get( + "https://authorized_only", + match=[header_matcher({"Authorization": "Bearer waka_tok_12345"})], + ) + + requests.get("https://authorized_only", auth=auth) + + tab.assert_success() + + +def test_oauth2_authorization_code_flow_get_code_is_sent_in_authorization_header_by_default( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + body="access_token=waka_tok_12345&token_type=bearer&expires_in=3600&refresh_token=waka_ref_12345&scope=email&example_parameter=example_value", + content_type="text/html; charset=utf-8", + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "client_id": "jPJQV0op6Pu3b66MWDi8b1wD", + "client_secret": "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + "scope": "email", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ) + ], + ) + responses.get( + "https://authorized_only", + match=[header_matcher({"Authorization": "Bearer waka_tok_12345"})], + ) + + requests.get("https://authorized_only", auth=auth) + + tab.assert_success() + + +def test_json_response_is_handled_even_if_unused( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + json={ + "access_token": "waka_tok_12345", + "token_type": "bearer", + "expires_in": 3600, + "refresh_token": "waka_ref_12345", + "scope": "email", + "example_parameter": "example_value", + }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "client_id": "jPJQV0op6Pu3b66MWDi8b1wD", + "client_secret": "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + "scope": "email", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ) + ], + ) + responses.get( + "https://authorized_only", + match=[header_matcher({"Authorization": "Bearer waka_tok_12345"})], + ) + + requests.get("https://authorized_only", auth=auth) + + tab.assert_success() + + +def test_oauth2_authorization_code_flow_get_code_is_expired_after_30_seconds_by_default( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + # Add a token that expires in 29 seconds, so should be considered as expired when issuing the request + token_cache._add_token( + key="5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + token="2YotnFZFEjr1zCsicMWpAA", + expiry=_to_expiry(expires_in=29), + ) + # Meaning a new one will be requested + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + body="access_token=waka_tok_12345&token_type=bearer&expires_in=3600&refresh_token=waka_ref_12345&scope=email&example_parameter=example_value", + content_type="text/html; charset=utf-8", + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "client_id": "jPJQV0op6Pu3b66MWDi8b1wD", + "client_secret": "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + "scope": "email", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ) + ], + ) + responses.get( + "https://authorized_only", + match=[header_matcher({"Authorization": "Bearer waka_tok_12345"})], + ) + + requests.get("https://authorized_only", auth=auth) + + tab.assert_success() + + +def test_oauth2_authorization_code_flow_get_code_custom_expiry( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + early_expiry=28, + ) + # Add a token that expires in 29 seconds, so should be considered as not expired when issuing the request + token_cache._add_token( + key="5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + token="waka_tok_12345", + expiry=_to_expiry(expires_in=29), + ) + responses.get( + "https://authorized_only", + match=[header_matcher({"Authorization": "Bearer waka_tok_12345"})], + ) + + requests.get("https://authorized_only", auth=auth) + + +def test_oauth2_authorization_code_flow_refresh_token( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + body="access_token=waka_tok_12345&token_type=bearer&expires_in=0&refresh_token=waka_ref_12345&scope=email&example_parameter=example_value", + content_type="text/html; charset=utf-8", + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "client_id": "jPJQV0op6Pu3b66MWDi8b1wD", + "client_secret": "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + "scope": "email", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ) + ], + ) + responses.get( + "https://authorized_only", + match=[header_matcher({"Authorization": "Bearer waka_tok_12345"})], + ) + + requests.get("https://authorized_only", auth=auth) + + tab.assert_success() + + # response for refresh token grant + responses.post( + "https://wakatime.com/oauth/token", + body="access_token=waka_tok_67890&token_type=bearer&expires_in=3600&refresh_token=waka_ref_12345&scope=email&example_parameter=example_value", + content_type="text/html; charset=utf-8", + match=[ + urlencoded_params_matcher( + { + "grant_type": "refresh_token", + "refresh_token": "waka_ref_12345", + "client_id": "jPJQV0op6Pu3b66MWDi8b1wD", + "client_secret": "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + "scope": "email", + "response_type": "code", + } + ) + ], + ) + responses.get( + "https://authorized_only", + match=[header_matcher({"Authorization": "Bearer waka_tok_67890"})], + ) + + requests.get("https://authorized_only", auth=auth) + + +def test_oauth2_authorization_code_flow_refresh_token_invalid( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + body="access_token=waka_tok_12345&token_type=bearer&expires_in=0&refresh_token=waka_ref_12345&scope=email&example_parameter=example_value", + content_type="text/html; charset=utf-8", + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "client_id": "jPJQV0op6Pu3b66MWDi8b1wD", + "client_secret": "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + "scope": "email", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ) + ], + ) + responses.get( + "https://authorized_only", + match=[header_matcher({"Authorization": "Bearer waka_tok_12345"})], + ) + + requests.get("https://authorized_only", auth=auth) + + tab.assert_success() + + # response for refresh token grant + responses.post( + "https://wakatime.com/oauth/token", + body="error=invalid_request", + content_type="text/html; charset=utf-8", + status=400, + match=[ + urlencoded_params_matcher( + { + "grant_type": "refresh_token", + "refresh_token": "waka_ref_12345", + "client_id": "jPJQV0op6Pu3b66MWDi8b1wD", + "client_secret": "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + "scope": "email", + "response_type": "code", + } + ) + ], + ) + + # initialize tab again because a thread can only be started once + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + + responses.get( + "https://authorized_only", + match=[header_matcher({"Authorization": "Bearer waka_tok_12345"})], + ) + + requests.get("https://authorized_only", auth=auth) + + tab.assert_success() + + +def test_oauth2_authorization_code_flow_refresh_token_access_token_not_expired( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + body="access_token=waka_tok_12345&token_type=bearer&expires_in=3600&refresh_token=waka_ref_12345&scope=email&example_parameter=example_value", + content_type="text/html; charset=utf-8", + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "client_id": "jPJQV0op6Pu3b66MWDi8b1wD", + "client_secret": "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + "scope": "email", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ) + ], + ) + responses.get( + "https://authorized_only", + match=[header_matcher({"Authorization": "Bearer waka_tok_12345"})], + ) + + requests.get("https://authorized_only", auth=auth) + + tab.assert_success() + + # expect Bearer token to remain the same + requests.get("https://authorized_only", auth=auth) + + +def test_empty_token_is_invalid( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + body="access_token=&token_type=bearer&expires_in=3600&refresh_token=waka_ref_12345&scope=email&example_parameter=example_value", + content_type="text/html; charset=utf-8", + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "client_id": "jPJQV0op6Pu3b66MWDi8b1wD", + "client_secret": "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + "scope": "email", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ) + ], + ) + + with pytest.raises(requests_auth.GrantNotProvided) as exception_info: + requests.get("https://authorized_only", auth=auth) + + assert ( + str(exception_info.value) + == "access_token not provided within {'access_token': '', 'token_type': 'bearer', 'expires_in': '3600', 'refresh_token': 'waka_ref_12345', 'scope': 'email', 'example_parameter': 'example_value'}." + ) + tab.assert_success() + + +def test_with_invalid_grant_request_no_json( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + body="failure", + content_type="text/plain", + status=400, + ) + + with pytest.raises(requests_auth.InvalidGrantRequest, match="failure"): + requests.get("https://authorized_only", auth=auth) + + tab.assert_success() + + +def test_with_invalid_grant_request_invalid_request_error( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + json={"error": "invalid_request"}, + status=400, + ) + + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("https://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() + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + json={"error": "invalid_request", "error_description": "desc of the error"}, + status=400, + ) + + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("https://authorized_only", auth=auth) + + assert str(exception_info.value) == "invalid_request: desc of the error" + tab.assert_success() + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "https://test_url", + }, + status=400, + ) + + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("https://authorized_only", auth=auth) + + assert ( + str(exception_info.value) + == f"invalid_request: desc of the error\nMore information can be found on https://test_url" + ) + tab.assert_success() + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri_and_other_fields( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "https://test_url", + "other": "other info", + }, + status=400, + ) + + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("https://authorized_only", auth=auth) + + assert ( + str(exception_info.value) + == "invalid_request: desc of the error\nMore information can be found on https://test_url\nAdditional information: {'other': 'other info'}" + ) + tab.assert_success() + + +def test_with_invalid_grant_request_without_error( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + json={"other": "other info"}, + status=400, + ) + + with pytest.raises( + requests_auth.InvalidGrantRequest, match="{'other': 'other info'}" + ): + requests.get("https://authorized_only", auth=auth) + + tab.assert_success() + + +def test_with_invalid_grant_request_invalid_client_error( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + json={"error": "invalid_client"}, + status=400, + ) + + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("https://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() + + +def test_with_invalid_grant_request_invalid_grant_error( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + json={"error": "invalid_grant"}, + status=400, + ) + + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("https://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() + + +def test_with_invalid_grant_request_unauthorized_client_error( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + json={"error": "unauthorized_client"}, + status=400, + ) + + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("https://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() + + +def test_with_invalid_grant_request_unsupported_grant_type_error( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + json={"error": "unsupported_grant_type"}, + status=400, + ) + + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("https://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() + + +def test_with_invalid_grant_request_invalid_scope_error( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a", + ) + responses.post( + "https://wakatime.com/oauth/token", + json={"error": "invalid_scope"}, + status=400, + ) + + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("https://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() + + +def test_with_invalid_token_request_invalid_request_error( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=invalid_request", + ) + + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("https://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( + "invalid_request: The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed." + ) + + +def test_with_invalid_token_request_invalid_request_error_and_error_description( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=invalid_request&error_description=desc", + ) + + with pytest.raises( + requests_auth.InvalidGrantRequest, match="invalid_request: desc" + ): + requests.get("https://authorized_only", auth=auth) + + tab.assert_failure("invalid_request: desc") + + +def test_with_invalid_token_request_invalid_request_error_and_error_description_and_uri( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=invalid_request&error_description=desc&error_uri=https://test_url", + ) + + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("https://authorized_only", auth=auth) + + assert ( + str(exception_info.value) + == "invalid_request: desc\nMore information can be found on https://test_url" + ) + tab.assert_failure( + "invalid_request: desc
More information can be found on https://test_url" + ) + + +def test_with_invalid_token_request_invalid_request_error_and_error_description_and_uri_and_other_fields( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=invalid_request&error_description=desc&error_uri=https://test_url&other=test", + ) + + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("https://authorized_only", auth=auth) + + assert ( + str(exception_info.value) + == "invalid_request: desc\nMore information can be found on https://test_url\nAdditional information: {'other': ['test']}" + ) + tab.assert_failure( + "invalid_request: desc
More information can be found on https://test_url
Additional information: {'other': ['test']}" + ) + + +def test_with_invalid_token_request_unauthorized_client_error( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=unauthorized_client", + ) + + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("https://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( + "unauthorized_client: The client is not authorized to request an authorization code or an access token using this method." + ) + + +def test_with_invalid_token_request_access_denied_error( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=access_denied", + ) + + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("https://authorized_only", auth=auth) + + assert ( + str(exception_info.value) + == "access_denied: The resource owner or authorization server denied the request." + ) + tab.assert_failure( + "access_denied: The resource owner or authorization server denied the request." + ) + + +def test_with_invalid_token_request_unsupported_response_type_error( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=unsupported_response_type", + ) + + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("https://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( + "unsupported_response_type: The authorization server does not support obtaining an authorization code or an access token using this method." + ) + + +def test_with_invalid_token_request_invalid_scope_error( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=invalid_scope", + ) + + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("https://authorized_only", auth=auth) + + assert ( + str(exception_info.value) + == "invalid_scope: The requested scope is invalid, unknown, or malformed." + ) + tab.assert_failure( + "invalid_scope: The requested scope is invalid, unknown, or malformed." + ) + + +def test_with_invalid_token_request_server_error_error( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=server_error", + ) + + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("https://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( + "server_error: The authorization server encountered an unexpected condition that prevented it from fulfilling the request. (This error code is needed because a 500 Internal Server Error HTTP status code cannot be returned to the client via an HTTP redirect.)" + ) + + +def test_with_invalid_token_request_temporarily_unavailable_error( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + ) + tab = browser_mock.add_response( + opened_url="https://wakatime.com/oauth/authorize?client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&state=5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#error=temporarily_unavailable", + ) + + with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: + requests.get("https://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( + "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: + requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + header_value="Bearer token", + ) + assert str(exception_info.value) == "header_value parameter must contains {token}." + + +def test_empty_scope_is_invalid(): + with pytest.raises(Exception) as exception_info: + requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="", + ) + assert str(exception_info.value) == "Scope is mandatory." + + +def test_scope_is_mandatory(): + with pytest.raises(Exception) as exception_info: + requests_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope=None, + ) + assert str(exception_info.value) == "Scope is mandatory." diff --git a/tests/oauth2/authorization_code_pkce/__init__.py b/tests/oauth2/authorization_code_pkce/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_oauth2_authorization_code_pkce.py b/tests/oauth2/authorization_code_pkce/test_oauth2_authorization_code_pkce.py similarity index 71% rename from tests/test_oauth2_authorization_code_pkce.py rename to tests/oauth2/authorization_code_pkce/test_oauth2_authorization_code_pkce.py index 0bcdc7f..7bfb9f2 100644 --- a/tests/test_oauth2_authorization_code_pkce.py +++ b/tests/oauth2/authorization_code_pkce/test_oauth2_authorization_code_pkce.py @@ -1,11 +1,11 @@ from responses import RequestsMock -from responses.matchers import urlencoded_params_matcher +from responses.matchers import urlencoded_params_matcher, header_matcher import pytest import requests import requests_auth -from tests.auth_helper import get_header, get_request -from requests_auth.testing import BrowserMock, browser_mock, token_cache +import requests_auth._oauth2.authorization_code_pkce +from requests_auth.testing import BrowserMock, browser_mock, token_cache # noqa: F401 def test_oauth2_pkce_flow_uses_provided_session( @@ -13,7 +13,9 @@ def test_oauth2_pkce_flow_uses_provided_session( ): session = requests.Session() session.headers.update({"x-test": "Test value"}) - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token", session=session ) @@ -21,8 +23,7 @@ def test_oauth2_pkce_flow_uses_provided_session( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -31,35 +32,94 @@ def test_oauth2_pkce_flow_uses_provided_session( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "code_verifier": "MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx", + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + header_matcher({"x-test": "Test value"}), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) - request = get_request(responses, "http://provide_access_token/") - assert ( - request.body - == "code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" + + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + + +def test_oauth2_pkce_flow_uses_redirect_uri_domain( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code", + "http://provide_access_token", + redirect_uri_domain="localhost.mycompany.com", + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost.mycompany.com%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", + ) + responses.post( + "http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match=[ + urlencoded_params_matcher( + { + "code_verifier": "MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx", + "grant_type": "authorization_code", + "redirect_uri": "http://localhost.mycompany.com:5000/", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], ) - assert request.headers["x-test"] == "Test value" - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) -def test_oauth2_pkce_flow_get_code_is_sent_in_authorization_header_by_default( + tab.assert_success() + + +def test_oauth2_pkce_flow_uses_custom_success( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( - "http://provide_code", "http://provide_access_token" + "http://provide_code", + "http://provide_access_token", + ) + requests_auth.OAuth2.display.success_html = ( + "
SUCCESS: {display_time}
" ) 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", + displayed_html="
SUCCESS: {display_time}
", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -68,24 +128,103 @@ def test_oauth2_pkce_flow_get_code_is_sent_in_authorization_header_by_default( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "code_verifier": "MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx", + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" + + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + + +def test_oauth2_pkce_flow_uses_custom_failure( + token_cache, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code", + "http://provide_access_token", + ) + requests_auth.OAuth2.display.failure_html = "FAILURE: {display_time}\n{information}" + 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", + displayed_html="FAILURE: {display_time}\n{information}", ) - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + + with pytest.raises(requests_auth.InvalidGrantRequest): + requests.get("http://authorized_only", auth=auth) + + tab.assert_failure( + "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_oauth2_pkce_flow_get_code_is_sent_in_authorization_header_by_default( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) + auth = requests_auth.OAuth2AuthorizationCodePKCE( + "http://provide_code", "http://provide_access_token" + ) + tab = browser_mock.add_response( + opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", + ) + responses.post( + "http://provide_access_token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match=[ + urlencoded_params_matcher( + { + "code_verifier": "MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx", + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], + ) + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], + ) + + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + + def test_oauth2_pkce_flow_token_is_expired_after_30_seconds_by_default( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) @@ -97,11 +236,10 @@ def test_oauth2_pkce_flow_token_is_expired_after_30_seconds_by_default( token_cache._add_token( key="163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", token="2YotnFZFEjr1zCsicMWpAA", - expiry=requests_auth.oauth2_tokens._to_expiry(expires_in=29), + expiry=requests_auth._oauth2.tokens._to_expiry(expires_in=29), ) # Meaning a new one will be requested - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -110,24 +248,34 @@ def test_oauth2_pkce_flow_token_is_expired_after_30_seconds_by_default( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "code_verifier": "MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx", + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" - ) - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_oauth2_client_credentials_flow_token_custom_expiry( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token", @@ -137,18 +285,22 @@ def test_oauth2_client_credentials_flow_token_custom_expiry( token_cache._add_token( key="163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", token="2YotnFZFEjr1zCsicMWpAA", - expiry=requests_auth.oauth2_tokens._to_expiry(expires_in=29), + expiry=requests_auth._oauth2.tokens._to_expiry(expires_in=29), ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_expires_in_sent_as_str( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) @@ -156,8 +308,7 @@ def test_expires_in_sent_as_str( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -166,24 +317,34 @@ def test_expires_in_sent_as_str( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "code_verifier": "MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx", + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" - ) - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_refresh_token( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) @@ -191,8 +352,7 @@ def test_refresh_token( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -213,20 +373,16 @@ def test_refresh_token( ) ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" - ) - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() # response for refresh token grant - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "rVR7Syg5bjZtZYjbZIW", @@ -245,19 +401,20 @@ def test_refresh_token( ) ], ) - - response = requests.get("http://authorized_only", auth=auth) - assert response.request.headers.get("Authorization") == "Bearer rVR7Syg5bjZtZYjbZIW" - assert ( - get_request(responses, "http://provide_access_token/").body - == "grant_type=refresh_token&response_type=code&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer rVR7Syg5bjZtZYjbZIW"})], ) + requests.get("http://authorized_only", auth=auth) + def test_refresh_token_invalid( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) @@ -265,8 +422,7 @@ def test_refresh_token_invalid( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -287,20 +443,16 @@ def test_refresh_token_invalid( ) ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" - ) - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() # response for refresh token grant - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_request"}, status=400, @@ -322,20 +474,22 @@ def test_refresh_token_invalid( ) # if refreshing the token fails, fallback to requesting a new token - response = requests.get("http://authorized_only", auth=auth) - assert ( - response.request.headers.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." - ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() def test_refresh_token_access_token_not_expired( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) @@ -343,8 +497,7 @@ def test_refresh_token_access_token_not_expired( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -365,18 +518,15 @@ def test_refresh_token_access_token_not_expired( ) ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" - ) - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + # expect Bearer token to remain the same response = requests.get("http://authorized_only", auth=auth) assert ( @@ -387,7 +537,9 @@ def test_refresh_token_access_token_not_expired( def test_nonce_is_sent_if_provided_in_authorization_url( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code?nonce=123456", "http://provide_access_token" ) @@ -395,8 +547,7 @@ def test_nonce_is_sent_if_provided_in_authorization_url( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -405,24 +556,34 @@ def test_nonce_is_sent_if_provided_in_authorization_url( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "code_verifier": "MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx", + "redirect_uri": "http://localhost:5000/", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ) + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA" - ) - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_with_invalid_grant_request_no_json( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code?nonce=123456", "http://provide_access_token" ) @@ -430,21 +591,19 @@ def test_with_invalid_grant_request_no_json( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, "http://provide_access_token", body="failure", status=400 - ) + responses.post("http://provide_access_token", body="failure", status=400) with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: requests.get("http://authorized_only", auth=auth) assert str(exception_info.value) == "failure" - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_request_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code?nonce=123456", "http://provide_access_token" ) @@ -452,8 +611,7 @@ def test_with_invalid_grant_request_invalid_request_error( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_request"}, status=400, @@ -467,15 +625,15 @@ def test_with_invalid_grant_request_invalid_request_error( "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." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_request_error_and_error_description( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code?nonce=123456", "http://provide_access_token" ) @@ -483,8 +641,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_request", "error_description": "desc of the error"}, status=400, @@ -492,15 +649,15 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description( with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: requests.get("http://authorized_only", auth=auth) assert str(exception_info.value) == "invalid_request: desc of the error" - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code?nonce=123456", "http://provide_access_token" ) @@ -508,8 +665,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "error": "invalid_request", @@ -524,15 +680,15 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ 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." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri_and_other_fields( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code?nonce=123456", "http://provide_access_token" ) @@ -540,8 +696,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "error": "invalid_request", @@ -557,15 +712,15 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ 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." - ) + tab.assert_success() def test_with_invalid_grant_request_without_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code?nonce=123456", "http://provide_access_token" ) @@ -573,8 +728,7 @@ def test_with_invalid_grant_request_without_error( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"other": "other info"}, status=400, @@ -582,15 +736,15 @@ def test_with_invalid_grant_request_without_error( with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: requests.get("http://authorized_only", auth=auth) assert str(exception_info.value) == "{'other': 'other info'}" - tab.assert_success( - "You are now authenticated on 163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de. You may close this tab." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_client_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code?nonce=123456", "http://provide_access_token" ) @@ -598,8 +752,7 @@ def test_with_invalid_grant_request_invalid_client_error( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_client"}, status=400, @@ -617,15 +770,15 @@ def test_with_invalid_grant_request_invalid_client_error( '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." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_grant_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code?nonce=123456", "http://provide_access_token" ) @@ -633,8 +786,7 @@ def test_with_invalid_grant_request_invalid_grant_error( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_grant"}, status=400, @@ -648,15 +800,15 @@ def test_with_invalid_grant_request_invalid_grant_error( "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." - ) + tab.assert_success() def test_with_invalid_grant_request_unauthorized_client_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code?nonce=123456", "http://provide_access_token" ) @@ -664,8 +816,7 @@ def test_with_invalid_grant_request_unauthorized_client_error( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "unauthorized_client"}, status=400, @@ -677,15 +828,15 @@ def test_with_invalid_grant_request_unauthorized_client_error( == "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." - ) + tab.assert_success() def test_with_invalid_grant_request_unsupported_grant_type_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code?nonce=123456", "http://provide_access_token" ) @@ -693,8 +844,7 @@ def test_with_invalid_grant_request_unsupported_grant_type_error( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "unsupported_grant_type"}, status=400, @@ -706,15 +856,15 @@ def test_with_invalid_grant_request_unsupported_grant_type_error( == "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." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_scope_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code?nonce=123456", "http://provide_access_token" ) @@ -722,8 +872,7 @@ def test_with_invalid_grant_request_invalid_scope_error( opened_url="http://provide_code?response_type=code&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%27123456%27%5D&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=163f0455b3e9cad3ca04254e5a0169553100d3aa0756c7964d897da316a695ffed5b4f46ef305094fd0a88cfe4b55ff257652015e4aa8f87b97513dba440f8de", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_scope"}, status=400, @@ -735,15 +884,15 @@ def test_with_invalid_grant_request_invalid_scope_error( == "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." - ) + tab.assert_success() def test_with_invalid_token_request_invalid_request_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) @@ -758,14 +907,16 @@ def test_with_invalid_token_request_invalid_request_error( == "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." + "invalid_request: The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed." ) def test_with_invalid_token_request_invalid_request_error_and_error_description( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) @@ -776,15 +927,15 @@ def test_with_invalid_token_request_invalid_request_error_and_error_description( with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: requests.get("http://authorized_only", auth=auth) assert str(exception_info.value) == "invalid_request: desc" - tab.assert_failure( - "Unable to properly perform authentication: invalid_request: desc" - ) + tab.assert_failure("invalid_request: desc") def test_with_invalid_token_request_invalid_request_error_and_error_description_and_uri( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) @@ -799,14 +950,16 @@ def test_with_invalid_token_request_invalid_request_error_and_error_description_ == "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" + "invalid_request: desc
More information can be found on http://test_url" ) def test_with_invalid_token_request_invalid_request_error_and_error_description_and_uri_and_other_fields( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) @@ -821,14 +974,16 @@ def test_with_invalid_token_request_invalid_request_error_and_error_description_ == "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']}" + "invalid_request: desc
More information can be found on http://test_url
Additional information: {'other': ['test']}" ) def test_with_invalid_token_request_unauthorized_client_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) @@ -843,14 +998,16 @@ def test_with_invalid_token_request_unauthorized_client_error( == "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." + "unauthorized_client: The client is not authorized to request an authorization code or an access token using this method." ) def test_with_invalid_token_request_access_denied_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) @@ -865,14 +1022,16 @@ def test_with_invalid_token_request_access_denied_error( == "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." + "access_denied: The resource owner or authorization server denied the request." ) def test_with_invalid_token_request_unsupported_response_type_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) @@ -887,14 +1046,16 @@ def test_with_invalid_token_request_unsupported_response_type_error( == "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." + "unsupported_response_type: The authorization server does not support obtaining an authorization code or an access token using this method." ) def test_with_invalid_token_request_invalid_scope_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) @@ -909,14 +1070,16 @@ def test_with_invalid_token_request_invalid_scope_error( == "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." + "invalid_scope: The requested scope is invalid, unknown, or malformed." ) def test_with_invalid_token_request_server_error_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) @@ -931,14 +1094,16 @@ def test_with_invalid_token_request_server_error_error( == "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.)" + "server_error: The authorization server encountered an unexpected condition that prevented it from fulfilling the request. (This error code is needed because a 500 Internal Server Error HTTP status code cannot be returned to the client via an HTTP redirect.)" ) def test_with_invalid_token_request_temporarily_unavailable_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code", "http://provide_access_token" ) @@ -953,14 +1118,16 @@ def test_with_invalid_token_request_temporarily_unavailable_error( == "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.)" + "temporarily_unavailable: The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server. (This error code is needed because a 503 Service Unavailable HTTP status code cannot be returned to the client via an HTTP redirect.)" ) def test_response_type_can_be_provided_in_url( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OAuth2AuthorizationCodePKCE( "http://provide_code?response_type=my_code", "http://provide_access_token", @@ -970,8 +1137,7 @@ def test_response_type_can_be_provided_in_url( opened_url="http://provide_code?response_type=%5B%27my_code%27%5D&state=b32e05720bd3722e0ac87bf72897a78b669a0810adf8da46b675793dcfe0f41a40f7d7fdda952bd73ea533a2462907d805adf8c1a162d51b99b2ddec0d411feb&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=b32e05720bd3722e0ac87bf72897a78b669a0810adf8da46b675793dcfe0f41a40f7d7fdda952bd73ea533a2462907d805adf8c1a162d51b99b2ddec0d411feb", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -980,19 +1146,27 @@ def test_response_type_can_be_provided_in_url( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "authorization_code", + "code_verifier": "MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx", + "redirect_uri": "http://localhost:5000/", + "response_type": "my_code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ) + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "code_verifier=MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=my_code&code=SplxlOBeZQQYbYS6WxSbIA" - ) - tab.assert_success( - "You are now authenticated on b32e05720bd3722e0ac87bf72897a78b669a0810adf8da46b675793dcfe0f41a40f7d7fdda952bd73ea533a2462907d805adf8c1a162d51b99b2ddec0d411feb. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_authorization_url_is_mandatory(): with pytest.raises(Exception) as exception_info: diff --git a/tests/test_oauth2_authorization_code_pkce_okta.py b/tests/oauth2/authorization_code_pkce/test_oauth2_authorization_code_pkce_okta.py similarity index 70% rename from tests/test_oauth2_authorization_code_pkce_okta.py rename to tests/oauth2/authorization_code_pkce/test_oauth2_authorization_code_pkce_okta.py index b3af18c..e3e8634 100644 --- a/tests/test_oauth2_authorization_code_pkce_okta.py +++ b/tests/oauth2/authorization_code_pkce/test_oauth2_authorization_code_pkce_okta.py @@ -1,10 +1,11 @@ from responses import RequestsMock +from responses.matchers import urlencoded_params_matcher, header_matcher import pytest import requests import requests_auth -from tests.auth_helper import get_header, get_request -from requests_auth.testing import BrowserMock, browser_mock, token_cache +import requests_auth._oauth2.authorization_code_pkce +from requests_auth.testing import BrowserMock, browser_mock, token_cache # noqa: F401 def test_oauth2_pkce_flow_uses_provided_session( @@ -12,7 +13,9 @@ def test_oauth2_pkce_flow_uses_provided_session( ): session = requests.Session() session.headers.update({"x-test": "Test value"}) - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", @@ -22,8 +25,7 @@ def test_oauth2_pkce_flow_uses_provided_session( 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", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -32,37 +34,98 @@ def test_oauth2_pkce_flow_uses_provided_session( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "code_verifier": "MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx", + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "client_id": "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + "scope": "openid", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + header_matcher({"x-test": "Test value"}), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], + ) + + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + + +def test_oauth2_pkce_flow_uses_redirect_uri_domain( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) + auth = requests_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", + "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + redirect_uri_domain="localhost.mycompany.com", ) - request = get_request( - responses, "https://testserver.okta-emea.com/oauth2/default/v1/token" + 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.mycompany.com%3A5000%2F&code_challenge=5C_ph_KZ3DstYUc965SiqmKAA-ShvKF4Ut7daKd3fjc&code_challenge_method=S256", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", ) - assert ( - request.body - == "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" + responses.post( + "https://testserver.okta-emea.com/oauth2/default/v1/token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match=[ + urlencoded_params_matcher( + { + "code_verifier": "MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx", + "grant_type": "authorization_code", + "redirect_uri": "http://localhost.mycompany.com:5000/", + "client_id": "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + "scope": "openid", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], ) - assert request.headers["x-test"] == "Test value" - tab.assert_success( - "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) -def test_oauth2_pkce_flow_get_code_is_sent_in_authorization_header_by_default( + tab.assert_success() + + +def test_oauth2_pkce_flow_uses_custom_success( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( - "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" + "testserver.okta-emea.com", + "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + ) + requests_auth.OAuth2.display.success_html = ( + "
SUCCESS: {display_time}
" ) 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", + displayed_html="
SUCCESS: {display_time}
", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -71,26 +134,107 @@ def test_oauth2_pkce_flow_get_code_is_sent_in_authorization_header_by_default( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "code_verifier": "MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx", + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "client_id": "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + "scope": "openid", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) - assert ( - get_request( - responses, "https://testserver.okta-emea.com/oauth2/default/v1/token" - ).body - == "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" + + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + + +def test_oauth2_pkce_flow_uses_custom_failure( + token_cache, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) + auth = requests_auth.OktaAuthorizationCodePKCE( + "testserver.okta-emea.com", + "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + ) + requests_auth.OAuth2.display.failure_html = "FAILURE: {display_time}\n{information}" + 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", + displayed_html="FAILURE: {display_time}\n{information}", ) - tab.assert_success( - "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + + with pytest.raises(requests_auth.InvalidGrantRequest): + requests.get("http://authorized_only", auth=auth) + + tab.assert_failure( + "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_oauth2_pkce_flow_get_code_is_sent_in_authorization_header_by_default( + token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock +): + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) + auth = requests_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", + ) + responses.post( + "https://testserver.okta-emea.com/oauth2/default/v1/token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match=[ + urlencoded_params_matcher( + { + "code_verifier": "MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx", + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "client_id": "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + "scope": "openid", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], + ) + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], + ) + + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + + def test_okta_pkce_flow_token_is_expired_after_30_seconds_by_default( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -102,11 +246,10 @@ def test_okta_pkce_flow_token_is_expired_after_30_seconds_by_default( token_cache._add_token( key="5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", token="2YotnFZFEjr1zCsicMWpAA", - expiry=requests_auth.oauth2_tokens._to_expiry(expires_in=29), + expiry=requests_auth._oauth2.tokens._to_expiry(expires_in=29), ) # Meaning a new one will be requested - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -115,26 +258,36 @@ def test_okta_pkce_flow_token_is_expired_after_30_seconds_by_default( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "code_verifier": "MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx", + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "client_id": "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + "scope": "openid", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request( - responses, "https://testserver.okta-emea.com/oauth2/default/v1/token" - ).body - == "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" - ) - tab.assert_success( - "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_okta_pkce_flow_token_custom_expiry( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", @@ -144,18 +297,22 @@ def test_okta_pkce_flow_token_custom_expiry( token_cache._add_token( key="5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b", token="2YotnFZFEjr1zCsicMWpAA", - expiry=requests_auth.oauth2_tokens._to_expiry(expires_in=29), + expiry=requests_auth._oauth2.tokens._to_expiry(expires_in=29), ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_expires_in_sent_as_str( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -163,8 +320,7 @@ def test_expires_in_sent_as_str( 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", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -173,26 +329,36 @@ def test_expires_in_sent_as_str( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "code_verifier": "MTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTEx", + "grant_type": "authorization_code", + "redirect_uri": "http://localhost:5000/", + "client_id": "54239d18-c68c-4c47-8bdd-ce71ea1d50cd", + "scope": "openid", + "response_type": "code", + "code": "SplxlOBeZQQYbYS6WxSbIA", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request( - responses, "https://testserver.okta-emea.com/oauth2/default/v1/token" - ).body - == "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" - ) - tab.assert_success( - "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + + tab.assert_success() + def test_with_invalid_grant_request_no_json( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -200,8 +366,7 @@ def test_with_invalid_grant_request_no_json( 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", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", body="failure", status=400, @@ -209,15 +374,15 @@ def test_with_invalid_grant_request_no_json( with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: requests.get("http://authorized_only", auth=auth) assert str(exception_info.value) == "failure" - tab.assert_success( - "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_request_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -225,8 +390,7 @@ def test_with_invalid_grant_request_invalid_request_error( 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", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "invalid_request"}, status=400, @@ -240,15 +404,15 @@ def test_with_invalid_grant_request_invalid_request_error( "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." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_request_error_and_error_description( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -256,8 +420,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description( 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", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "invalid_request", "error_description": "desc of the error"}, status=400, @@ -265,15 +428,15 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description( with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: requests.get("http://authorized_only", auth=auth) assert str(exception_info.value) == "invalid_request: desc of the error" - tab.assert_success( - "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -281,8 +444,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ 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", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "error": "invalid_request", @@ -297,15 +459,15 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ 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." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri_and_other_fields( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -313,8 +475,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ 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", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "error": "invalid_request", @@ -330,15 +491,15 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ 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." - ) + tab.assert_success() def test_with_invalid_grant_request_without_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -346,8 +507,7 @@ def test_with_invalid_grant_request_without_error( 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", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"other": "other info"}, status=400, @@ -355,15 +515,15 @@ def test_with_invalid_grant_request_without_error( with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: requests.get("http://authorized_only", auth=auth) assert str(exception_info.value) == "{'other': 'other info'}" - tab.assert_success( - "You are now authenticated on 5264d11c8b268ccf911ce564ca42fd75cea68c4a3c1ec3ac1ab20243891ab7cd5250ad4c2d002017c6e8ac2ba34954293baa5e0e4fd00bb9ffd4a39c45f1960b. You may close this tab." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_client_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -371,8 +531,7 @@ def test_with_invalid_grant_request_invalid_client_error( 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", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "invalid_client"}, status=400, @@ -390,15 +549,15 @@ def test_with_invalid_grant_request_invalid_client_error( '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." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_grant_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -406,8 +565,7 @@ def test_with_invalid_grant_request_invalid_grant_error( 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", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "invalid_grant"}, status=400, @@ -421,15 +579,15 @@ def test_with_invalid_grant_request_invalid_grant_error( "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." - ) + tab.assert_success() def test_with_invalid_grant_request_unauthorized_client_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -437,8 +595,7 @@ def test_with_invalid_grant_request_unauthorized_client_error( 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", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "unauthorized_client"}, status=400, @@ -450,15 +607,15 @@ def test_with_invalid_grant_request_unauthorized_client_error( == "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." - ) + tab.assert_success() def test_with_invalid_grant_request_unsupported_grant_type_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -466,8 +623,7 @@ def test_with_invalid_grant_request_unsupported_grant_type_error( 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", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "unsupported_grant_type"}, status=400, @@ -479,15 +635,15 @@ def test_with_invalid_grant_request_unsupported_grant_type_error( == "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." - ) + tab.assert_success() def test_with_invalid_grant_request_invalid_scope_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -495,8 +651,7 @@ def test_with_invalid_grant_request_invalid_scope_error( 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", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "invalid_scope"}, status=400, @@ -508,15 +663,15 @@ def test_with_invalid_grant_request_invalid_scope_error( == "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." - ) + tab.assert_success() def test_with_invalid_token_request_invalid_request_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -531,14 +686,16 @@ def test_with_invalid_token_request_invalid_request_error( == "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." + "invalid_request: The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed." ) def test_with_invalid_token_request_invalid_request_error_and_error_description( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -549,15 +706,15 @@ def test_with_invalid_token_request_invalid_request_error_and_error_description( with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: requests.get("http://authorized_only", auth=auth) assert str(exception_info.value) == "invalid_request: desc" - tab.assert_failure( - "Unable to properly perform authentication: invalid_request: desc" - ) + tab.assert_failure("invalid_request: desc") def test_with_invalid_token_request_invalid_request_error_and_error_description_and_uri( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -572,14 +729,16 @@ def test_with_invalid_token_request_invalid_request_error_and_error_description_ == "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" + "invalid_request: desc
More information can be found on http://test_url" ) def test_with_invalid_token_request_invalid_request_error_and_error_description_and_uri_and_other_fields( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -594,14 +753,16 @@ def test_with_invalid_token_request_invalid_request_error_and_error_description_ == "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']}" + "invalid_request: desc
More information can be found on http://test_url
Additional information: {'other': ['test']}" ) def test_with_invalid_token_request_unauthorized_client_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -616,14 +777,16 @@ def test_with_invalid_token_request_unauthorized_client_error( == "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." + "unauthorized_client: The client is not authorized to request an authorization code or an access token using this method." ) def test_with_invalid_token_request_access_denied_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -638,14 +801,16 @@ def test_with_invalid_token_request_access_denied_error( == "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." + "access_denied: The resource owner or authorization server denied the request." ) def test_with_invalid_token_request_unsupported_response_type_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -660,14 +825,16 @@ def test_with_invalid_token_request_unsupported_response_type_error( == "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." + "unsupported_response_type: The authorization server does not support obtaining an authorization code or an access token using this method." ) def test_with_invalid_token_request_invalid_scope_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -682,14 +849,16 @@ def test_with_invalid_token_request_invalid_scope_error( == "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." + "invalid_scope: The requested scope is invalid, unknown, or malformed." ) def test_with_invalid_token_request_server_error_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -704,14 +873,16 @@ def test_with_invalid_token_request_server_error_error( == "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.)" + "server_error: The authorization server encountered an unexpected condition that prevented it from fulfilling the request. (This error code is needed because a 500 Internal Server Error HTTP status code cannot be returned to the client via an HTTP redirect.)" ) def test_with_invalid_token_request_temporarily_unavailable_error( token_cache, responses: RequestsMock, monkeypatch, browser_mock: BrowserMock ): - monkeypatch.setattr(requests_auth.authentication.os, "urandom", lambda x: b"1" * 63) + monkeypatch.setattr( + requests_auth._oauth2.authorization_code_pkce.os, "urandom", lambda x: b"1" * 63 + ) auth = requests_auth.OktaAuthorizationCodePKCE( "testserver.okta-emea.com", "54239d18-c68c-4c47-8bdd-ce71ea1d50cd" ) @@ -726,7 +897,7 @@ def test_with_invalid_token_request_temporarily_unavailable_error( == "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.)" + "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.)" ) diff --git a/tests/oauth2/client_credential/__init__.py b/tests/oauth2/client_credential/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_oauth2_client_credential.py b/tests/oauth2/client_credential/test_oauth2_client_credential.py similarity index 88% rename from tests/test_oauth2_client_credential.py rename to tests/oauth2/client_credential/test_oauth2_client_credential.py index 54a94d9..1464a8f 100644 --- a/tests/test_oauth2_client_credential.py +++ b/tests/oauth2/client_credential/test_oauth2_client_credential.py @@ -1,10 +1,10 @@ from responses import RequestsMock +from responses.matchers import header_matcher import pytest import requests import requests_auth -from tests.auth_helper import get_header, get_request -from requests_auth.testing import token_cache +from requests_auth.testing import token_cache # noqa: F401 def test_oauth2_client_credentials_flow_uses_provided_session( @@ -18,8 +18,7 @@ def test_oauth2_client_credentials_flow_uses_provided_session( client_secret="test_pwd", session=session, ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -28,13 +27,16 @@ def test_oauth2_client_credentials_flow_uses_provided_session( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + header_matcher({"x-test": "Test value"}), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) - request = get_request(responses, "http://provide_access_token/") - assert request.headers["x-test"] == "Test value" + + requests.get("http://authorized_only", auth=auth) def test_oauth2_client_credentials_flow_token_is_sent_in_authorization_header_by_default( @@ -43,8 +45,7 @@ def test_oauth2_client_credentials_flow_token_is_sent_in_authorization_header_by auth = requests_auth.OAuth2ClientCredentials( "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -54,11 +55,13 @@ def test_oauth2_client_credentials_flow_token_is_sent_in_authorization_header_by "example_parameter": "example_value", }, ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_oauth2_client_credentials_flow_token_is_expired_after_30_seconds_by_default( token_cache, responses: RequestsMock @@ -70,11 +73,10 @@ def test_oauth2_client_credentials_flow_token_is_expired_after_30_seconds_by_def token_cache._add_token( key="a8a1c17ded24b3710524306819084310b08f97e151c79f4f1979202c541f3e8506c93176f7ee816bfcd2b2f6de9c5c3e16aaff220f1ad8f08d31ee086e8618da", token="2YotnFZFEjr1zCsicMWpAA", - expiry=requests_auth.oauth2_tokens._to_expiry(expires_in=29), + expiry=requests_auth._oauth2.tokens._to_expiry(expires_in=29), ) # Meaning a new one will be requested - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -84,11 +86,13 @@ def test_oauth2_client_credentials_flow_token_is_expired_after_30_seconds_by_def "example_parameter": "example_value", }, ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_oauth2_client_credentials_flow_token_custom_expiry( token_cache, responses: RequestsMock @@ -103,20 +107,21 @@ def test_oauth2_client_credentials_flow_token_custom_expiry( token_cache._add_token( key="a8a1c17ded24b3710524306819084310b08f97e151c79f4f1979202c541f3e8506c93176f7ee816bfcd2b2f6de9c5c3e16aaff220f1ad8f08d31ee086e8618da", token="2YotnFZFEjr1zCsicMWpAA", - expiry=requests_auth.oauth2_tokens._to_expiry(expires_in=29), + expiry=requests_auth._oauth2.tokens._to_expiry(expires_in=29), ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_expires_in_sent_as_str(token_cache, responses: RequestsMock): auth = requests_auth.OAuth2ClientCredentials( "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -126,19 +131,19 @@ def test_expires_in_sent_as_str(token_cache, responses: RequestsMock): "example_parameter": "example_value", }, ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_with_invalid_grant_request_no_json(token_cache, responses: RequestsMock): auth = requests_auth.OAuth2ClientCredentials( "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) - responses.add( - responses.POST, "http://provide_access_token", body="failure", status=400 - ) + responses.post("http://provide_access_token", body="failure", status=400) with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: requests.get("http://authorized_only", auth=auth) assert str(exception_info.value) == "failure" @@ -150,8 +155,7 @@ def test_with_invalid_grant_request_invalid_request_error( auth = requests_auth.OAuth2ClientCredentials( "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_request"}, status=400, @@ -173,8 +177,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description( auth = requests_auth.OAuth2ClientCredentials( "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_request", "error_description": "desc of the error"}, status=400, @@ -190,8 +193,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ auth = requests_auth.OAuth2ClientCredentials( "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "error": "invalid_request", @@ -214,8 +216,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ auth = requests_auth.OAuth2ClientCredentials( "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "error": "invalid_request", @@ -237,8 +238,7 @@ def test_with_invalid_grant_request_without_error(token_cache, responses: Reques auth = requests_auth.OAuth2ClientCredentials( "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"other": "other info"}, status=400, @@ -254,8 +254,7 @@ def test_with_invalid_grant_request_invalid_client_error( auth = requests_auth.OAuth2ClientCredentials( "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_client"}, status=400, @@ -281,8 +280,7 @@ def test_with_invalid_grant_request_invalid_grant_error( auth = requests_auth.OAuth2ClientCredentials( "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_grant"}, status=400, @@ -304,8 +302,7 @@ def test_with_invalid_grant_request_unauthorized_client_error( auth = requests_auth.OAuth2ClientCredentials( "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "unauthorized_client"}, status=400, @@ -325,8 +322,7 @@ def test_with_invalid_grant_request_unsupported_grant_type_error( auth = requests_auth.OAuth2ClientCredentials( "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "unsupported_grant_type"}, status=400, @@ -346,8 +342,7 @@ def test_with_invalid_grant_request_invalid_scope_error( auth = requests_auth.OAuth2ClientCredentials( "http://provide_access_token", client_id="test_user", client_secret="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_scope"}, status=400, diff --git a/tests/oauth2/client_credential/test_oauth2_client_credential_okta.py b/tests/oauth2/client_credential/test_oauth2_client_credential_okta.py new file mode 100644 index 0000000..b4b328f --- /dev/null +++ b/tests/oauth2/client_credential/test_oauth2_client_credential_okta.py @@ -0,0 +1,197 @@ +import pytest +import requests +from responses import RequestsMock +from responses.matchers import header_matcher, urlencoded_params_matcher + +import requests_auth +from requests_auth.testing import token_cache # noqa: F401 + + +def test_okta_client_credentials_flow_uses_provided_session( + token_cache, responses: RequestsMock +): + session = requests.Session() + session.headers.update({"x-test": "Test value"}) + auth = requests_auth.OktaClientCredentials( + "test_okta", + client_id="test_user", + client_secret="test_pwd", + scope="dummy", + session=session, + ) + responses.post( + "https://test_okta/oauth2/default/v1/token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match=[ + header_matcher({"x-test": "Test value"}), + urlencoded_params_matcher( + {"grant_type": "client_credentials", "scope": "dummy"} + ), + ], + ) + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], + ) + + requests.get("http://authorized_only", auth=auth) + + +def test_okta_client_credentials_flow_token_is_sent_in_authorization_header_by_default( + token_cache, responses: RequestsMock +): + auth = requests_auth.OktaClientCredentials( + "test_okta", client_id="test_user", client_secret="test_pwd", scope="dummy" + ) + responses.post( + "https://test_okta/oauth2/default/v1/token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match=[ + urlencoded_params_matcher( + {"grant_type": "client_credentials", "scope": "dummy"} + ), + ], + ) + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], + ) + + requests.get("http://authorized_only", auth=auth) + + +def test_okta_client_credentials_flow_token_is_expired_after_30_seconds_by_default( + token_cache, responses: RequestsMock +): + auth = requests_auth.OktaClientCredentials( + "test_okta", client_id="test_user", client_secret="test_pwd", scope="dummy" + ) + # Add a token that expires in 29 seconds, so should be considered as expired when issuing the request + token_cache._add_token( + key="7830dd38bb95d4ac6273bd1a208c3db2097ac2715c6d3fb646ef3ccd48877109dd4cba292cef535559747cf6c4f497bf0804994dfb1c31bb293d2774889c2cfb", + token="2YotnFZFEjr1zCsicMWpAA", + expiry=requests_auth._oauth2.tokens._to_expiry(expires_in=29), + ) + # Meaning a new one will be requested + responses.post( + "https://test_okta/oauth2/default/v1/token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": 3600, + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match=[ + urlencoded_params_matcher( + {"grant_type": "client_credentials", "scope": "dummy"} + ), + ], + ) + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], + ) + + requests.get("http://authorized_only", auth=auth) + + +def test_okta_client_credentials_flow_token_custom_expiry( + token_cache, responses: RequestsMock +): + auth = requests_auth.OktaClientCredentials( + "test_okta", + client_id="test_user", + client_secret="test_pwd", + scope="dummy", + early_expiry=28, + ) + # Add a token that expires in 29 seconds, so should be considered as not expired when issuing the request + token_cache._add_token( + key="7830dd38bb95d4ac6273bd1a208c3db2097ac2715c6d3fb646ef3ccd48877109dd4cba292cef535559747cf6c4f497bf0804994dfb1c31bb293d2774889c2cfb", + token="2YotnFZFEjr1zCsicMWpAA", + expiry=requests_auth._oauth2.tokens._to_expiry(expires_in=29), + ) + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], + ) + + requests.get("http://authorized_only", auth=auth) + + +def test_expires_in_sent_as_str(token_cache, responses: RequestsMock): + auth = requests_auth.OktaClientCredentials( + "test_okta", client_id="test_user", client_secret="test_pwd", scope="dummy" + ) + responses.post( + "https://test_okta/oauth2/default/v1/token", + json={ + "access_token": "2YotnFZFEjr1zCsicMWpAA", + "token_type": "example", + "expires_in": "3600", + "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter": "example_value", + }, + match=[ + urlencoded_params_matcher( + {"grant_type": "client_credentials", "scope": "dummy"} + ), + ], + ) + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], + ) + + requests.get("http://authorized_only", auth=auth) + + +def test_scope_is_mandatory(): + with pytest.raises(Exception) as exception_info: + requests_auth.OktaClientCredentials( + "test_url", "test_user", "test_pwd", scope="" + ) + assert str(exception_info.value) == "scope is mandatory." + + +def test_instance_is_mandatory(): + with pytest.raises(Exception) as exception_info: + requests_auth.OktaClientCredentials("", "test_user", "test_pwd", scope="dummy") + assert str(exception_info.value) == "Okta instance is mandatory." + + +def test_client_id_is_mandatory(): + with pytest.raises(Exception) as exception_info: + requests_auth.OktaClientCredentials("test_url", "", "test_pwd", scope="dummy") + assert str(exception_info.value) == "client_id is mandatory." + + +def test_client_secret_is_mandatory(): + with pytest.raises(Exception) as exception_info: + requests_auth.OktaClientCredentials("test_url", "test_user", "", scope="dummy") + assert str(exception_info.value) == "client_secret is mandatory." + + +def test_header_value_must_contains_token(): + with pytest.raises(Exception) as exception_info: + requests_auth.OktaClientCredentials( + "test_url", + "test_user", + "test_pwd", + scope="dummy", + header_value="Bearer token", + ) + assert str(exception_info.value) == "header_value parameter must contains {token}." diff --git a/tests/oauth2/implicit/__init__.py b/tests/oauth2/implicit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_oauth2_implicit.py b/tests/oauth2/implicit/test_oauth2_implicit.py similarity index 71% rename from tests/test_oauth2_implicit.py rename to tests/oauth2/implicit/test_oauth2_implicit.py index 9fb5330..d924ddf 100644 --- a/tests/test_oauth2_implicit.py +++ b/tests/oauth2/implicit/test_oauth2_implicit.py @@ -4,9 +4,14 @@ import requests import pytest from responses import RequestsMock - -from requests_auth.testing import BrowserMock, create_token, token_cache, browser_mock -from tests.auth_helper import get_header +from responses.matchers import header_matcher + +from requests_auth.testing import ( + BrowserMock, + create_token, + browser_mock, # noqa: F401 + token_cache, # noqa: F401 +) import requests_auth @@ -29,20 +34,26 @@ def test_oauth2_implicit_flow_token_is_not_reused_if_a_url_parameter_is_changing "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) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + 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", ) + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {first_token}"})], + ) - assert get_header(responses, auth1).get("Authorization") == f"Bearer {first_token}" + requests.get("http://authorized_only", auth=auth1) # Ensure that the new token is different than previous one - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta( - hours=1, seconds=1 - ) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=1, seconds=1) auth2 = requests_auth.OAuth2Implicit( "http://provide_token?response_type=custom_token&fake_param=2", @@ -54,16 +65,16 @@ def test_oauth2_implicit_flow_token_is_not_reused_if_a_url_parameter_is_changing reply_url="http://localhost:5000", data=f"custom_token={second_token}&state=5c3940ccf78ac6e7d6d8d06782d9fd95a533aa5425b616eaa38dc3ec9508fbd55152c58a0d8dd8a087e76b77902559285819a41cb78ce8713e5a3b974bf07ce9", ) - response = requests.get("http://authorized_only", auth=auth2) - # Return headers received on this dummy URL - 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." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {second_token}"})], ) + requests.get("http://authorized_only", auth=auth2) + + tab1.assert_success() + tab2.assert_success() + def test_oauth2_implicit_flow_token_is_reused_if_only_nonce_differs( token_cache, responses: RequestsMock, browser_mock: BrowserMock @@ -72,26 +83,34 @@ def test_oauth2_implicit_flow_token_is_reused_if_only_nonce_differs( "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) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=1) token = create_token(expiry_in_1_hour) tab = browser_mock.add_response( opened_url="http://provide_token?response_type=custom_token&state=67b95d2c7555751d1d72c97c7cd9ad6630c8395e0eaa51ee86ac7e451211ded9cd98a7190848789fe93632d8960425710e93f1f5549c6c6bc328bf3865a85ff2&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&nonce=%5B%271%27%5D", reply_url="http://localhost:5000", data=f"custom_token={token}&state=67b95d2c7555751d1d72c97c7cd9ad6630c8395e0eaa51ee86ac7e451211ded9cd98a7190848789fe93632d8960425710e93f1f5549c6c6bc328bf3865a85ff2", ) - assert get_header(responses, auth1).get("Authorization") == f"Bearer {token}" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {token}"})], + ) + + requests.get("http://authorized_only", auth=auth1) auth2 = requests_auth.OAuth2Implicit( "http://provide_token?response_type=custom_token&nonce=2", token_field_name="custom_token", ) - response = requests.get("http://authorized_only", auth=auth2) - # Return headers received on this dummy URL - assert response.request.headers.get("Authorization") == f"Bearer {token}" - tab.assert_success( - "You are now authenticated on 67b95d2c7555751d1d72c97c7cd9ad6630c8395e0eaa51ee86ac7e451211ded9cd98a7190848789fe93632d8960425710e93f1f5549c6c6bc328bf3865a85ff2. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {token}"})], ) + requests.get("http://authorized_only", auth=auth2) + tab.assert_success() + def test_oauth2_implicit_flow_token_can_be_requested_on_a_custom_server_port( token_cache, responses: RequestsMock, browser_mock: BrowserMock @@ -101,16 +120,90 @@ def test_oauth2_implicit_flow_token_can_be_requested_on_a_custom_server_port( auth = requests_auth.OAuth2Implicit( "http://provide_token", redirect_uri_port=available_port ) - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=1) token = create_token(expiry_in_1_hour) tab = browser_mock.add_response( opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5002%2F", reply_url="http://localhost:5002", data=f"access_token={token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", ) - assert get_header(responses, auth).get("Authorization") == f"Bearer {token}" - tab.assert_success( - "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {token}"})], + ) + + requests.get("http://authorized_only", auth=auth) + tab.assert_success() + + +def test_oauth2_implicit_flow_uses_redirect_uri_domain( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2Implicit( + "http://provide_token", redirect_uri_domain="localhost.mycompany.com" + ) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + 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.mycompany.com%3A5000%2F", + reply_url="http://localhost:5000", + data=f"access_token={token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", + ) + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {token}"})], + ) + + requests.get("http://authorized_only", auth=auth) + tab.assert_success() + + +def test_oauth2_implicit_flow_uses_custom_success( + token_cache, responses: RequestsMock, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2Implicit("http://provide_token") + requests_auth.OAuth2.display.success_html = ( + "
SUCCESS: {display_time}
" + ) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + 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", + displayed_html="
SUCCESS: {display_time}
", + data=f"access_token={token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", + ) + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {token}"})], + ) + + requests.get("http://authorized_only", auth=auth) + tab.assert_success() + + +def test_oauth2_implicit_flow_uses_custom_failure( + token_cache, browser_mock: BrowserMock +): + auth = requests_auth.OAuth2Implicit("http://provide_token") + requests_auth.OAuth2.display.failure_html = "FAILURE: {display_time}\n{information}" + 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", + displayed_html="FAILURE: {display_time}\n{information}", + ) + + with pytest.raises(requests_auth.InvalidGrantRequest): + requests.get("http://authorized_only", auth=auth) + + tab.assert_failure( + "invalid_request: The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed." ) @@ -118,61 +211,80 @@ def test_oauth2_implicit_flow_post_token_is_sent_in_authorization_header_by_defa token_cache, responses: RequestsMock, browser_mock: BrowserMock ): auth = requests_auth.OAuth2Implicit("http://provide_token") - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=1) token = create_token(expiry_in_1_hour) tab = browser_mock.add_response( opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000", data=f"access_token={token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", ) - assert get_header(responses, auth).get("Authorization") == f"Bearer {token}" - tab.assert_success( - "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {token}"})], ) + requests.get("http://authorized_only", auth=auth) + tab.assert_success() + def test_oauth2_implicit_flow_token_is_expired_after_30_seconds_by_default( token_cache, responses: RequestsMock, browser_mock: BrowserMock ): auth = requests_auth.OAuth2Implicit("http://provide_token") # Add a token that expires in 29 seconds, so should be considered as expired when issuing the request - expiry_in_29_seconds = datetime.datetime.utcnow() + datetime.timedelta(seconds=29) + expiry_in_29_seconds = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(seconds=29) token_cache._add_token( key="42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", token=create_token(expiry_in_29_seconds), - expiry=requests_auth.oauth2_tokens._to_expiry(expires_in=29), + expiry=requests_auth._oauth2.tokens._to_expiry(expires_in=29), ) # Meaning a new one will be requested - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=1) token = create_token(expiry_in_1_hour) tab = browser_mock.add_response( opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000", data=f"access_token={token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", ) - assert get_header(responses, auth).get("Authorization") == f"Bearer {token}" - tab.assert_success( - "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {token}"})], ) + requests.get("http://authorized_only", auth=auth) + tab.assert_success() + def test_oauth2_implicit_flow_token_custom_expiry( token_cache, responses: RequestsMock, browser_mock: BrowserMock ): auth = requests_auth.OAuth2Implicit("http://provide_token", early_expiry=28) # Add a token that expires in 29 seconds, so should be considered as not expired when issuing the request - expiry_in_29_seconds = datetime.datetime.utcnow() + datetime.timedelta(seconds=29) + expiry_in_29_seconds = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(seconds=29) token_cache._add_token( key="42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", token=create_token(expiry_in_29_seconds), - expiry=requests_auth.oauth2_tokens._to_expiry(expires_in=29), + expiry=requests_auth._oauth2.tokens._to_expiry(expires_in=29), ) token = create_token(expiry_in_29_seconds) - assert get_header(responses, auth).get("Authorization") == f"Bearer {token}" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {token}"})], + ) + + requests.get("http://authorized_only", auth=auth) def test_browser_opening_failure(token_cache, responses: RequestsMock, monkeypatch): - import requests_auth.oauth2_authentication_responses_server + import requests_auth._oauth2.authentication_responses_server auth = requests_auth.OAuth2Implicit("http://provide_token", timeout=0.1) @@ -181,7 +293,7 @@ def open(self, url, new): return False monkeypatch.setattr( - requests_auth.oauth2_authentication_responses_server.webbrowser, + requests_auth._oauth2.authentication_responses_server.webbrowser, "get", lambda *args: FakeBrowser(), ) @@ -196,10 +308,12 @@ def open(self, url, new): str(exception_info.value) == "User authentication was not received within 0.1 seconds." ) + assert isinstance(exception_info.value, requests_auth.RequestsAuthException) + assert isinstance(exception_info.value, requests.RequestException) def test_browser_error(token_cache, responses: RequestsMock, monkeypatch): - import requests_auth.oauth2_authentication_responses_server + import requests_auth._oauth2.authentication_responses_server auth = requests_auth.OAuth2Implicit("http://provide_token", timeout=0.1) @@ -210,7 +324,7 @@ def open(self, url, new): raise webbrowser.Error("Failure") monkeypatch.setattr( - requests_auth.oauth2_authentication_responses_server.webbrowser, + requests_auth._oauth2.authentication_responses_server.webbrowser, "get", lambda *args: FakeBrowser(), ) @@ -229,15 +343,22 @@ def open(self, url, new): def test_state_change(token_cache, responses: RequestsMock, browser_mock: BrowserMock): auth = requests_auth.OAuth2Implicit("http://provide_token") - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + 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(responses, auth).get("Authorization") == f"Bearer {token}" - tab.assert_success("You are now authenticated on 123456. You may close this tab.") + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {token}"})], + ) + + requests.get("http://authorized_only", auth=auth) + tab.assert_success() def test_empty_token_is_invalid(token_cache, browser_mock: BrowserMock): @@ -252,9 +373,9 @@ def test_empty_token_is_invalid(token_cache, browser_mock: BrowserMock): auth=requests_auth.OAuth2Implicit("http://provide_token"), ) assert str(exception_info.value) == " is invalid." - tab.assert_success( - "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." - ) + assert isinstance(exception_info.value, requests_auth.RequestsAuthException) + assert isinstance(exception_info.value, requests.RequestException) + tab.assert_success() def test_token_without_expiry_is_invalid(token_cache, browser_mock: BrowserMock): @@ -269,26 +390,31 @@ def test_token_without_expiry_is_invalid(token_cache, browser_mock: BrowserMock) auth=requests_auth.OAuth2Implicit("http://provide_token"), ) assert str(exception_info.value) == "Expiry (exp) is not provided in None." - tab.assert_success( - "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." - ) + assert isinstance(exception_info.value, requests_auth.RequestsAuthException) + assert isinstance(exception_info.value, requests.RequestException) + tab.assert_success() def test_oauth2_implicit_flow_get_token_is_sent_in_authorization_header_by_default( token_cache, responses: RequestsMock, browser_mock: BrowserMock ): auth = requests_auth.OAuth2Implicit("http://provide_token") - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=1) token = create_token(expiry_in_1_hour) tab = browser_mock.add_response( opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url=f"http://localhost:5000#access_token={token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", ) - assert get_header(responses, auth).get("Authorization") == f"Bearer {token}" - tab.assert_success( - "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {token}"})], ) + requests.get("http://authorized_only", auth=auth) + tab.assert_success() + def test_oauth2_implicit_flow_token_is_sent_in_requested_field( token_cache, responses: RequestsMock, browser_mock: BrowserMock @@ -296,18 +422,23 @@ def test_oauth2_implicit_flow_token_is_sent_in_requested_field( auth = requests_auth.OAuth2Implicit( "http://provide_token", header_name="Bearer", header_value="{token}" ) - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=1) token = create_token(expiry_in_1_hour) tab = browser_mock.add_response( opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000", data=f"access_token={token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", ) - assert get_header(responses, auth).get("Bearer") == token - tab.assert_success( - "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Bearer": token})], ) + requests.get("http://authorized_only", auth=auth) + tab.assert_success() + def test_oauth2_implicit_flow_can_send_a_custom_response_type_and_expects_token_to_be_received_with_this_name( token_cache, responses: RequestsMock, browser_mock: BrowserMock @@ -317,18 +448,23 @@ def test_oauth2_implicit_flow_can_send_a_custom_response_type_and_expects_token_ response_type="custom_token", token_field_name="custom_token", ) - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=1) token = create_token(expiry_in_1_hour) tab = browser_mock.add_response( opened_url="http://provide_token?response_type=custom_token&state=67b95d2c7555751d1d72c97c7cd9ad6630c8395e0eaa51ee86ac7e451211ded9cd98a7190848789fe93632d8960425710e93f1f5549c6c6bc328bf3865a85ff2&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000", data=f"custom_token={token}&state=67b95d2c7555751d1d72c97c7cd9ad6630c8395e0eaa51ee86ac7e451211ded9cd98a7190848789fe93632d8960425710e93f1f5549c6c6bc328bf3865a85ff2", ) - assert get_header(responses, auth).get("Authorization") == f"Bearer {token}" - tab.assert_success( - "You are now authenticated on 67b95d2c7555751d1d72c97c7cd9ad6630c8395e0eaa51ee86ac7e451211ded9cd98a7190848789fe93632d8960425710e93f1f5549c6c6bc328bf3865a85ff2. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {token}"})], ) + requests.get("http://authorized_only", auth=auth) + tab.assert_success() + def test_oauth2_implicit_flow_expects_token_in_id_token_if_response_type_is_id_token( token_cache, responses: RequestsMock, browser_mock: BrowserMock @@ -336,74 +472,97 @@ def test_oauth2_implicit_flow_expects_token_in_id_token_if_response_type_is_id_t auth = requests_auth.OAuth2Implicit( "http://provide_token", response_type="id_token" ) - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=1) token = create_token(expiry_in_1_hour) tab = browser_mock.add_response( opened_url="http://provide_token?response_type=id_token&state=87c4108ec0eb03599335333a40434a36674269690b6957fef684bfb6c5a849ce660ef7031aa874c44d67cd3eada8febdfce41efb1ed3bc53a0a7e716cbba025a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000", data=f"id_token={token}&state=87c4108ec0eb03599335333a40434a36674269690b6957fef684bfb6c5a849ce660ef7031aa874c44d67cd3eada8febdfce41efb1ed3bc53a0a7e716cbba025a", ) - assert get_header(responses, auth).get("Authorization") == f"Bearer {token}" - tab.assert_success( - "You are now authenticated on 87c4108ec0eb03599335333a40434a36674269690b6957fef684bfb6c5a849ce660ef7031aa874c44d67cd3eada8febdfce41efb1ed3bc53a0a7e716cbba025a. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {token}"})], ) + requests.get("http://authorized_only", auth=auth) + tab.assert_success() + def test_oauth2_implicit_flow_expects_token_in_id_token_if_response_type_in_url_is_id_token( token_cache, responses: RequestsMock, browser_mock: BrowserMock ): auth = requests_auth.OAuth2Implicit("http://provide_token?response_type=id_token") - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=1) token = create_token(expiry_in_1_hour) tab = browser_mock.add_response( opened_url="http://provide_token?response_type=id_token&state=87c4108ec0eb03599335333a40434a36674269690b6957fef684bfb6c5a849ce660ef7031aa874c44d67cd3eada8febdfce41efb1ed3bc53a0a7e716cbba025a&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000", data=f"id_token={token}&state=87c4108ec0eb03599335333a40434a36674269690b6957fef684bfb6c5a849ce660ef7031aa874c44d67cd3eada8febdfce41efb1ed3bc53a0a7e716cbba025a", ) - assert get_header(responses, auth).get("Authorization") == f"Bearer {token}" - tab.assert_success( - "You are now authenticated on 87c4108ec0eb03599335333a40434a36674269690b6957fef684bfb6c5a849ce660ef7031aa874c44d67cd3eada8febdfce41efb1ed3bc53a0a7e716cbba025a. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {token}"})], ) + requests.get("http://authorized_only", auth=auth) + tab.assert_success() + def test_oauth2_implicit_flow_expects_token_to_be_stored_in_access_token_by_default( token_cache, responses: RequestsMock, browser_mock: BrowserMock ): auth = requests_auth.OAuth2Implicit("http://provide_token") - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=1) token = create_token(expiry_in_1_hour) tab = browser_mock.add_response( opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000", data=f"access_token={token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", ) - assert get_header(responses, auth).get("Authorization") == f"Bearer {token}" - tab.assert_success( - "You are now authenticated on 42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521. You may close this tab." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {token}"})], ) + requests.get("http://authorized_only", auth=auth) + tab.assert_success() + def test_oauth2_implicit_flow_token_is_reused_if_not_expired( token_cache, responses: RequestsMock, browser_mock: BrowserMock ): auth1 = requests_auth.OAuth2Implicit("http://provide_token") - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=1) token = create_token(expiry_in_1_hour) tab = browser_mock.add_response( opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000", data=f"access_token={token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", ) - assert get_header(responses, auth1).get("Authorization") == f"Bearer {token}" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {token}"})], + ) + + requests.get("http://authorized_only", auth=auth1) oauth2 = requests_auth.OAuth2Implicit("http://provide_token") - response = requests.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." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {token}"})], ) + requests.get("http://authorized_only", auth=oauth2) + tab.assert_success() + def test_oauth2_implicit_flow_post_failure_if_token_is_not_provided( token_cache, browser_mock: BrowserMock @@ -419,9 +578,7 @@ def test_oauth2_implicit_flow_post_failure_if_token_is_not_provided( auth=requests_auth.OAuth2Implicit("http://provide_token"), ) assert str(exception_info.value) == "access_token not provided within {}." - tab.assert_failure( - "Unable to properly perform authentication: access_token not provided within {}." - ) + tab.assert_failure("access_token not provided within {}.") def test_oauth2_implicit_flow_get_failure_if_token_is_not_provided( @@ -437,15 +594,15 @@ def test_oauth2_implicit_flow_get_failure_if_token_is_not_provided( auth=requests_auth.OAuth2Implicit("http://provide_token"), ) assert str(exception_info.value) == "access_token not provided within {}." - tab.assert_failure( - "Unable to properly perform authentication: access_token not provided within {}." - ) + tab.assert_failure("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) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + 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", @@ -461,15 +618,17 @@ def test_oauth2_implicit_flow_post_failure_if_state_is_not_provided( 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}']}}." - ) + assert isinstance(exception_info.value, requests_auth.RequestsAuthException) + assert isinstance(exception_info.value, requests.RequestException) + tab.assert_failure(f"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) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + 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", @@ -485,7 +644,7 @@ def test_oauth2_implicit_flow_get_failure_if_state_is_not_provided( == f"state not provided within {{'access_token': ['{token}'], 'requests_auth_redirect': ['1']}}." ) tab.assert_failure( - f"Unable to properly perform authentication: state not provided within {{'access_token': ['{token}'], 'requests_auth_redirect': ['1']}}." + f"state not provided within {{'access_token': ['{token}'], 'requests_auth_redirect': ['1']}}." ) @@ -506,7 +665,7 @@ def test_with_invalid_token_request_invalid_request_error( == "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." + "invalid_request: The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed." ) @@ -523,9 +682,7 @@ def test_with_invalid_token_request_invalid_request_error_and_error_description( auth=requests_auth.OAuth2Implicit("http://provide_token"), ) assert str(exception_info.value) == "invalid_request: desc" - tab.assert_failure( - "Unable to properly perform authentication: invalid_request: desc" - ) + tab.assert_failure("invalid_request: desc") def test_with_invalid_token_request_invalid_request_error_and_error_description_and_uri( @@ -545,7 +702,7 @@ def test_with_invalid_token_request_invalid_request_error_and_error_description_ == "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" + "invalid_request: desc
More information can be found on http://test_url" ) @@ -566,7 +723,7 @@ def test_with_invalid_token_request_invalid_request_error_and_error_description_ == "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']}" + "invalid_request: desc
More information can be found on http://test_url
Additional information: {'other': ['test']}" ) @@ -587,7 +744,7 @@ def test_with_invalid_token_request_unauthorized_client_error( == "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." + "unauthorized_client: The client is not authorized to request an authorization code or an access token using this method." ) @@ -608,7 +765,7 @@ def test_with_invalid_token_request_access_denied_error( == "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." + "access_denied: The resource owner or authorization server denied the request." ) @@ -629,7 +786,7 @@ def test_with_invalid_token_request_unsupported_response_type_error( == "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." + "unsupported_response_type: The authorization server does not support obtaining an authorization code or an access token using this method." ) @@ -650,7 +807,7 @@ def test_with_invalid_token_request_invalid_scope_error( == "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." + "invalid_scope: The requested scope is invalid, unknown, or malformed." ) @@ -671,7 +828,7 @@ def test_with_invalid_token_request_server_error_error( == "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.)" + "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.)" ) @@ -692,7 +849,7 @@ def test_with_invalid_token_request_temporarily_unavailable_error( == "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.)" + "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.)" ) @@ -720,34 +877,40 @@ def test_oauth2_implicit_flow_token_is_requested_again_if_expired( ): auth = requests_auth.OAuth2Implicit("http://provide_token") # This token will expires in 100 milliseconds - expiry_in_1_second = datetime.datetime.utcnow() + datetime.timedelta( - milliseconds=100 - ) + expiry_in_1_second = datetime.datetime.now( + datetime.timezone.utc + ) + 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(responses, auth).get("Authorization") == f"Bearer {first_token}" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {first_token}"})], + ) + + requests.get("http://authorized_only", auth=auth) # 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) + expiry_in_1_hour = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(hours=1) second_token = create_token(expiry_in_1_hour) tab2 = browser_mock.add_response( opened_url="http://provide_token?response_type=token&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", reply_url="http://localhost:5000", data=f"access_token={second_token}&state=42a85b271b7a652ca3cc4c398cfd3f01b9ad36bf9c945ba823b023e8f8b95c4638576a0e3dcc96838b838bec33ec6c0ee2609d62ed82480b3b8114ca494c0521", ) - response = requests.get("http://authorized_only", auth=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." + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": f"Bearer {second_token}"})], ) + + requests.get("http://authorized_only", auth=auth) + tab1.assert_success() + tab2.assert_success() diff --git a/tests/test_oauth2_implicit_azure_active_directory.py b/tests/oauth2/implicit/test_oauth2_implicit_azure_active_directory.py similarity index 92% rename from tests/test_oauth2_implicit_azure_active_directory.py rename to tests/oauth2/implicit/test_oauth2_implicit_azure_active_directory.py index 6e4248a..84d1483 100644 --- a/tests/test_oauth2_implicit_azure_active_directory.py +++ b/tests/oauth2/implicit/test_oauth2_implicit_azure_active_directory.py @@ -1,9 +1,10 @@ import requests_auth +import requests_auth._oauth2.implicit def test_corresponding_oauth2_implicit_flow_instance(monkeypatch): monkeypatch.setattr( - requests_auth.authentication.uuid, + requests_auth._oauth2.implicit.uuid, "uuid4", lambda *args: "27ddfeed4e-854b-4361-8e7a-eab371c9bc91", ) diff --git a/tests/test_oauth2_implicit_id_token_azure_active_directory.py b/tests/oauth2/implicit/test_oauth2_implicit_id_token_azure_active_directory.py similarity index 92% rename from tests/test_oauth2_implicit_id_token_azure_active_directory.py rename to tests/oauth2/implicit/test_oauth2_implicit_id_token_azure_active_directory.py index 433d600..0727da4 100644 --- a/tests/test_oauth2_implicit_id_token_azure_active_directory.py +++ b/tests/oauth2/implicit/test_oauth2_implicit_id_token_azure_active_directory.py @@ -1,9 +1,10 @@ import requests_auth +import requests_auth._oauth2.implicit def test_corresponding_oauth2_implicit_flow_id_token_instance(monkeypatch): monkeypatch.setattr( - requests_auth.authentication.uuid, + requests_auth._oauth2.implicit.uuid, "uuid4", lambda *args: "27ddfeed4e-854b-4361-8e7a-eab371c9bc91", ) diff --git a/tests/test_oauth2_implicit_id_token_okta.py b/tests/oauth2/implicit/test_oauth2_implicit_id_token_okta.py similarity index 92% rename from tests/test_oauth2_implicit_id_token_okta.py rename to tests/oauth2/implicit/test_oauth2_implicit_id_token_okta.py index 1dc52e9..9d88bc9 100644 --- a/tests/test_oauth2_implicit_id_token_okta.py +++ b/tests/oauth2/implicit/test_oauth2_implicit_id_token_okta.py @@ -1,9 +1,10 @@ import requests_auth +import requests_auth._oauth2.implicit def test_corresponding_oauth2_implicit_flow_id_token_instance(monkeypatch): monkeypatch.setattr( - requests_auth.authentication.uuid, + requests_auth._oauth2.implicit.uuid, "uuid4", lambda *args: "27ddfeed4e-854b-4361-8e7a-eab371c9bc91", ) diff --git a/tests/test_oauth2_implicit_okta.py b/tests/oauth2/implicit/test_oauth2_implicit_okta.py similarity index 92% rename from tests/test_oauth2_implicit_okta.py rename to tests/oauth2/implicit/test_oauth2_implicit_okta.py index f621ba7..34621f1 100644 --- a/tests/test_oauth2_implicit_okta.py +++ b/tests/oauth2/implicit/test_oauth2_implicit_okta.py @@ -1,9 +1,10 @@ import requests_auth +import requests_auth._oauth2.implicit def test_corresponding_oauth2_implicit_flow_instance(monkeypatch): monkeypatch.setattr( - requests_auth.authentication.uuid, + requests_auth._oauth2.implicit.uuid, "uuid4", lambda *args: "27ddfeed4e-854b-4361-8e7a-eab371c9bc91", ) diff --git a/tests/oauth2/resource_owner_password/__init__.py b/tests/oauth2/resource_owner_password/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_oauth2_resource_owner_password.py b/tests/oauth2/resource_owner_password/test_oauth2_resource_owner_password.py similarity index 76% rename from tests/test_oauth2_resource_owner_password.py rename to tests/oauth2/resource_owner_password/test_oauth2_resource_owner_password.py index 0d81967..06d88d1 100644 --- a/tests/test_oauth2_resource_owner_password.py +++ b/tests/oauth2/resource_owner_password/test_oauth2_resource_owner_password.py @@ -1,11 +1,18 @@ -from responses import RequestsMock -from responses.matchers import urlencoded_params_matcher +from responses import RequestsMock, Response +from responses.matchers import header_matcher, urlencoded_params_matcher import pytest import requests import requests_auth -from tests.auth_helper import get_header, get_request -from requests_auth.testing import token_cache +from requests_auth.testing import token_cache # noqa: F401 + + +def get_request(responses: RequestsMock, url: str) -> Response: + for call in responses.calls: + if call.request.url == url: + # Pop out verified request (to be able to check multiple requests) + responses.calls._calls.remove(call) + return call.request def test_oauth2_password_credentials_flow_uses_provided_session( @@ -19,8 +26,7 @@ def test_oauth2_password_credentials_flow_uses_provided_session( password="test_pwd", session=session, ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -29,14 +35,23 @@ def test_oauth2_password_credentials_flow_uses_provided_session( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "password", + "username": "test_user", + "password": "test_pwd", + } + ), + header_matcher({"x-test": "Test value"}), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) - request = get_request(responses, "http://provide_access_token/") - assert request.body == "grant_type=password&username=test_user&password=test_pwd" - assert request.headers["x-test"] == "Test value" + + requests.get("http://authorized_only", auth=auth) def test_oauth2_password_credentials_flow_token_is_sent_in_authorization_header_by_default( @@ -45,8 +60,7 @@ def test_oauth2_password_credentials_flow_token_is_sent_in_authorization_header_ auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -55,16 +69,23 @@ def test_oauth2_password_credentials_flow_token_is_sent_in_authorization_header_ "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "password", + "username": "test_user", + "password": "test_pwd", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "grant_type=password&username=test_user&password=test_pwd" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_oauth2_password_credentials_flow_does_not_authenticate_by_default( token_cache, responses: RequestsMock @@ -72,8 +93,7 @@ def test_oauth2_password_credentials_flow_does_not_authenticate_by_default( auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -82,15 +102,24 @@ def test_oauth2_password_credentials_flow_does_not_authenticate_by_default( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "password", + "username": "test_user", + "password": "test_pwd", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + + requests.get("http://authorized_only", auth=auth) + token_request = get_request(responses, "http://provide_access_token/") - assert ( - token_request.body == "grant_type=password&username=test_user&password=test_pwd" - ) assert "Authorization" not in token_request.headers @@ -103,8 +132,7 @@ def test_oauth2_password_credentials_flow_authentication( password="test_pwd", session_auth=("test_user2", "test_pwd2"), ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -113,19 +141,24 @@ def test_oauth2_password_credentials_flow_authentication( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "password", + "username": "test_user", + "password": "test_pwd", + } + ), + header_matcher({"Authorization": "Basic dGVzdF91c2VyMjp0ZXN0X3B3ZDI="}), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - token_request = get_request(responses, "http://provide_access_token/") - assert ( - token_request.body == "grant_type=password&username=test_user&password=test_pwd" - ) - assert ( - "Basic dGVzdF91c2VyMjp0ZXN0X3B3ZDI=" == token_request.headers["Authorization"] + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_oauth2_password_credentials_flow_token_is_expired_after_30_seconds_by_default( token_cache, responses: RequestsMock @@ -137,11 +170,10 @@ def test_oauth2_password_credentials_flow_token_is_expired_after_30_seconds_by_d token_cache._add_token( key="db2be9203dd2718c7285319dde1270056808482fbf7fffa6a9362d092d1cf799b393dd15140ea13e4d76d1603e56390a6222ff7063736a1b686d317706b2c001", token="2YotnFZFEjr1zCsicMWpAA", - expiry=requests_auth.oauth2_tokens._to_expiry(expires_in=29), + expiry=requests_auth._oauth2.tokens._to_expiry(expires_in=29), ) # Meaning a new one will be requested - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -150,16 +182,23 @@ def test_oauth2_password_credentials_flow_token_is_expired_after_30_seconds_by_d "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "password", + "username": "test_user", + "password": "test_pwd", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "grant_type=password&username=test_user&password=test_pwd" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_oauth2_password_credentials_flow_token_custom_expiry( token_cache, responses: RequestsMock @@ -174,20 +213,21 @@ def test_oauth2_password_credentials_flow_token_custom_expiry( token_cache._add_token( key="db2be9203dd2718c7285319dde1270056808482fbf7fffa6a9362d092d1cf799b393dd15140ea13e4d76d1603e56390a6222ff7063736a1b686d317706b2c001", token="2YotnFZFEjr1zCsicMWpAA", - expiry=requests_auth.oauth2_tokens._to_expiry(expires_in=29), + expiry=requests_auth._oauth2.tokens._to_expiry(expires_in=29), ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_expires_in_sent_as_str(token_cache, responses: RequestsMock): auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -196,24 +236,30 @@ def test_expires_in_sent_as_str(token_cache, responses: RequestsMock): "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "password", + "username": "test_user", + "password": "test_pwd", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "grant_type=password&username=test_user&password=test_pwd" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_refresh_token(token_cache, responses: RequestsMock): auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) # response for password grant - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -232,19 +278,15 @@ def test_refresh_token(token_cache, responses: RequestsMock): ) ], ) - - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "grant_type=password&username=test_user&password=test_pwd" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + # response for refresh token grant - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "rVR7Syg5bjZtZYjbZIW", @@ -262,22 +304,20 @@ def test_refresh_token(token_cache, responses: RequestsMock): ) ], ) - - response = requests.get("http://authorized_only", auth=auth) - assert response.request.headers.get("Authorization") == "Bearer rVR7Syg5bjZtZYjbZIW" - assert ( - get_request(responses, "http://provide_access_token/").body - == "grant_type=refresh_token&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer rVR7Syg5bjZtZYjbZIW"})], ) + requests.get("http://authorized_only", auth=auth) + def test_refresh_token_invalid(token_cache, responses: RequestsMock): auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) # response for password grant - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -296,19 +336,15 @@ def test_refresh_token_invalid(token_cache, responses: RequestsMock): ) ], ) - - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "grant_type=password&username=test_user&password=test_pwd" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + # response for refresh token grant - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_request"}, status=400, @@ -323,19 +359,20 @@ def test_refresh_token_invalid(token_cache, responses: RequestsMock): ) # if refreshing the token fails, fallback to requesting a new token - response = requests.get("http://authorized_only", auth=auth) - assert ( - response.request.headers.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_refresh_token_access_token_not_expired(token_cache, responses: RequestsMock): auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) # response for password grant - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -354,22 +391,21 @@ def test_refresh_token_access_token_not_expired(token_cache, responses: Requests ) ], ) - - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "grant_type=password&username=test_user&password=test_pwd" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + # expect Bearer token to remain the same - response = requests.get("http://authorized_only", auth=auth) - assert ( - response.request.headers.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_scope_is_sent_as_is_when_provided_as_str(token_cache, responses: RequestsMock): auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( @@ -378,8 +414,7 @@ def test_scope_is_sent_as_is_when_provided_as_str(token_cache, responses: Reques password="test_pwd", scope="my_scope+my_other_scope", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -388,16 +423,24 @@ def test_scope_is_sent_as_is_when_provided_as_str(token_cache, responses: Reques "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "password", + "username": "test_user", + "password": "test_pwd", + "scope": "my_scope+my_other_scope", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "grant_type=password&username=test_user&password=test_pwd&scope=my_scope%2Bmy_other_scope" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_scope_is_sent_as_str_when_provided_as_list( token_cache, responses: RequestsMock @@ -408,8 +451,7 @@ def test_scope_is_sent_as_str_when_provided_as_list( password="test_pwd", scope=["my_scope", "my_other_scope"], ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -418,24 +460,30 @@ def test_scope_is_sent_as_str_when_provided_as_list( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "password", + "username": "test_user", + "password": "test_pwd", + "scope": "my_scope my_other_scope", + } + ) + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request(responses, "http://provide_access_token/").body - == "grant_type=password&username=test_user&password=test_pwd&scope=my_scope+my_other_scope" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_with_invalid_grant_request_no_json(token_cache, responses: RequestsMock): auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) - responses.add( - responses.POST, "http://provide_access_token", body="failure", status=400 - ) + responses.post("http://provide_access_token", body="failure", status=400) with pytest.raises(requests_auth.InvalidGrantRequest) as exception_info: requests.get("http://authorized_only", auth=auth) assert str(exception_info.value) == "failure" @@ -447,8 +495,7 @@ def test_with_invalid_grant_request_invalid_request_error( auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_request"}, status=400, @@ -470,8 +517,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description( auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_request", "error_description": "desc of the error"}, status=400, @@ -487,8 +533,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "error": "invalid_request", @@ -511,8 +556,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "error": "invalid_request", @@ -534,8 +578,7 @@ def test_with_invalid_grant_request_without_error(token_cache, responses: Reques auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"other": "other info"}, status=400, @@ -551,8 +594,7 @@ def test_with_invalid_grant_request_invalid_client_error( auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_client"}, status=400, @@ -578,8 +620,7 @@ def test_with_invalid_grant_request_invalid_grant_error( auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_grant"}, status=400, @@ -601,8 +642,7 @@ def test_with_invalid_grant_request_unauthorized_client_error( auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "unauthorized_client"}, status=400, @@ -622,8 +662,7 @@ def test_with_invalid_grant_request_unsupported_grant_type_error( auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "unsupported_grant_type"}, status=400, @@ -643,8 +682,7 @@ def test_with_invalid_grant_request_invalid_scope_error( auth = requests_auth.OAuth2ResourceOwnerPasswordCredentials( "http://provide_access_token", username="test_user", password="test_pwd" ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={"error": "invalid_scope"}, status=400, @@ -665,8 +703,7 @@ def test_without_expected_token(token_cache, responses: RequestsMock): password="test_pwd", token_field_name="not_provided", ) - responses.add( - responses.POST, + responses.post( "http://provide_access_token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", diff --git a/tests/test_oauth2_resource_owner_password_okta.py b/tests/oauth2/resource_owner_password/test_oauth2_resource_owner_password_okta.py similarity index 81% rename from tests/test_oauth2_resource_owner_password_okta.py rename to tests/oauth2/resource_owner_password/test_oauth2_resource_owner_password_okta.py index 48f132f..e757a35 100644 --- a/tests/test_oauth2_resource_owner_password_okta.py +++ b/tests/oauth2/resource_owner_password/test_oauth2_resource_owner_password_okta.py @@ -1,11 +1,10 @@ from responses import RequestsMock -from responses.matchers import urlencoded_params_matcher +from responses.matchers import header_matcher, urlencoded_params_matcher import pytest import requests import requests_auth -from tests.auth_helper import get_header, get_request -from requests_auth.testing import token_cache +from requests_auth.testing import token_cache # noqa: F401 def test_oauth2_password_credentials_flow_uses_provided_session( @@ -21,8 +20,7 @@ def test_oauth2_password_credentials_flow_uses_provided_session( client_secret="test_pwd2", session=session, ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -31,19 +29,24 @@ def test_oauth2_password_credentials_flow_uses_provided_session( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "password", + "username": "test_user", + "password": "test_pwd", + "scope": "openid", + } + ), + header_matcher({"x-test": "Test value"}), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - request = get_request( - responses, "https://testserver.okta-emea.com/oauth2/default/v1/token" - ) - assert ( - request.body - == "grant_type=password&username=test_user&password=test_pwd&scope=openid" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) - assert request.headers["x-test"] == "Test value" + + requests.get("http://authorized_only", auth=auth) def test_oauth2_password_credentials_flow_token_is_sent_in_authorization_header_by_default( @@ -56,8 +59,7 @@ def test_oauth2_password_credentials_flow_token_is_sent_in_authorization_header_ client_id="test_user2", client_secret="test_pwd2", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -66,22 +68,25 @@ def test_oauth2_password_credentials_flow_token_is_sent_in_authorization_header_ "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "password", + "username": "test_user", + "password": "test_pwd", + "scope": "openid", + } + ), + header_matcher({"Authorization": "Basic dGVzdF91c2VyMjp0ZXN0X3B3ZDI="}), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - token_request = get_request( - responses, "https://testserver.okta-emea.com/oauth2/default/v1/token" - ) - assert ( - token_request.body - == "grant_type=password&username=test_user&password=test_pwd&scope=openid" - ) - assert ( - "Basic dGVzdF91c2VyMjp0ZXN0X3B3ZDI=" == token_request.headers["Authorization"] + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_oauth2_password_credentials_flow_token_is_expired_after_30_seconds_by_default( token_cache, responses: RequestsMock @@ -97,11 +102,10 @@ def test_oauth2_password_credentials_flow_token_is_expired_after_30_seconds_by_d token_cache._add_token( key="bdc39831ac59c0f65d36761e9b65656ae76223f2284c393a6e93fe4e09a2c0002e2638bbe02db2cc62928a2357be5e2e93b9fa4ac68729f4d28da180caae912a", token="2YotnFZFEjr1zCsicMWpAA", - expiry=requests_auth.oauth2_tokens._to_expiry(expires_in=29), + expiry=requests_auth._oauth2.tokens._to_expiry(expires_in=29), ) # Meaning a new one will be requested - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -110,18 +114,24 @@ def test_oauth2_password_credentials_flow_token_is_expired_after_30_seconds_by_d "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "password", + "username": "test_user", + "password": "test_pwd", + "scope": "openid", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request( - responses, "https://testserver.okta-emea.com/oauth2/default/v1/token" - ).body - == "grant_type=password&username=test_user&password=test_pwd&scope=openid" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_oauth2_password_credentials_flow_token_custom_expiry( token_cache, responses: RequestsMock @@ -138,13 +148,15 @@ def test_oauth2_password_credentials_flow_token_custom_expiry( token_cache._add_token( key="bdc39831ac59c0f65d36761e9b65656ae76223f2284c393a6e93fe4e09a2c0002e2638bbe02db2cc62928a2357be5e2e93b9fa4ac68729f4d28da180caae912a", token="2YotnFZFEjr1zCsicMWpAA", - expiry=requests_auth.oauth2_tokens._to_expiry(expires_in=29), + expiry=requests_auth._oauth2.tokens._to_expiry(expires_in=29), ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_expires_in_sent_as_str(token_cache, responses: RequestsMock): auth = requests_auth.OktaResourceOwnerPasswordCredentials( @@ -154,8 +166,7 @@ def test_expires_in_sent_as_str(token_cache, responses: RequestsMock): client_id="test_user2", client_secret="test_pwd2", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -164,18 +175,24 @@ def test_expires_in_sent_as_str(token_cache, responses: RequestsMock): "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "password", + "username": "test_user", + "password": "test_pwd", + "scope": "openid", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request( - responses, "https://testserver.okta-emea.com/oauth2/default/v1/token" - ).body - == "grant_type=password&username=test_user&password=test_pwd&scope=openid" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_refresh_token(token_cache, responses: RequestsMock): auth = requests_auth.OktaResourceOwnerPasswordCredentials( @@ -186,8 +203,7 @@ def test_refresh_token(token_cache, responses: RequestsMock): client_secret="test_pwd2", ) # response for password grant - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -207,21 +223,15 @@ def test_refresh_token(token_cache, responses: RequestsMock): ) ], ) - - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request( - responses, "https://testserver.okta-emea.com/oauth2/default/v1/token" - ).body - == "grant_type=password&username=test_user&password=test_pwd&scope=openid" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + # response for refresh token grant - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "access_token": "rVR7Syg5bjZtZYjbZIW", @@ -240,16 +250,13 @@ def test_refresh_token(token_cache, responses: RequestsMock): ) ], ) - - response = requests.get("http://authorized_only", auth=auth) - assert response.request.headers.get("Authorization") == "Bearer rVR7Syg5bjZtZYjbZIW" - assert ( - get_request( - responses, "https://testserver.okta-emea.com/oauth2/default/v1/token" - ).body - == "grant_type=refresh_token&scope=openid&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer rVR7Syg5bjZtZYjbZIW"})], ) + requests.get("http://authorized_only", auth=auth) + def test_refresh_token_invalid(token_cache, responses: RequestsMock): auth = requests_auth.OktaResourceOwnerPasswordCredentials( @@ -260,8 +267,7 @@ def test_refresh_token_invalid(token_cache, responses: RequestsMock): client_secret="test_pwd2", ) # response for password grant - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -281,21 +287,15 @@ def test_refresh_token_invalid(token_cache, responses: RequestsMock): ) ], ) - - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request( - responses, "https://testserver.okta-emea.com/oauth2/default/v1/token" - ).body - == "grant_type=password&username=test_user&password=test_pwd&scope=openid" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + # response for refresh token grant - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "invalid_request"}, status=400, @@ -311,11 +311,13 @@ def test_refresh_token_invalid(token_cache, responses: RequestsMock): ) # if refreshing the token fails, fallback to requesting a new token - response = requests.get("http://authorized_only", auth=auth) - assert ( - response.request.headers.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_refresh_token_access_token_not_expired(token_cache, responses: RequestsMock): auth = requests_auth.OktaResourceOwnerPasswordCredentials( @@ -326,8 +328,7 @@ def test_refresh_token_access_token_not_expired(token_cache, responses: Requests client_secret="test_pwd2", ) # response for password grant - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -347,24 +348,21 @@ def test_refresh_token_access_token_not_expired(token_cache, responses: Requests ) ], ) - - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request( - responses, "https://testserver.okta-emea.com/oauth2/default/v1/token" - ).body - == "grant_type=password&username=test_user&password=test_pwd&scope=openid" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + # expect Bearer token to remain the same - response = requests.get("http://authorized_only", auth=auth) - assert ( - response.request.headers.get("Authorization") == "Bearer 2YotnFZFEjr1zCsicMWpAA" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_scope_is_sent_as_is_when_provided_as_str(token_cache, responses: RequestsMock): auth = requests_auth.OktaResourceOwnerPasswordCredentials( @@ -375,8 +373,7 @@ def test_scope_is_sent_as_is_when_provided_as_str(token_cache, responses: Reques client_secret="test_pwd2", scope="my_scope+my_other_scope", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -385,18 +382,24 @@ def test_scope_is_sent_as_is_when_provided_as_str(token_cache, responses: Reques "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "password", + "username": "test_user", + "password": "test_pwd", + "scope": "my_scope+my_other_scope", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request( - responses, "https://testserver.okta-emea.com/oauth2/default/v1/token" - ).body - == "grant_type=password&username=test_user&password=test_pwd&scope=my_scope%2Bmy_other_scope" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_scope_is_sent_as_str_when_provided_as_list( token_cache, responses: RequestsMock @@ -409,8 +412,7 @@ def test_scope_is_sent_as_str_when_provided_as_list( client_secret="test_pwd2", scope=["my_scope", "my_other_scope"], ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", @@ -419,18 +421,24 @@ def test_scope_is_sent_as_str_when_provided_as_list( "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter": "example_value", }, + match=[ + urlencoded_params_matcher( + { + "grant_type": "password", + "username": "test_user", + "password": "test_pwd", + "scope": "my_scope my_other_scope", + } + ), + ], ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - assert ( - get_request( - responses, "https://testserver.okta-emea.com/oauth2/default/v1/token" - ).body - == "grant_type=password&username=test_user&password=test_pwd&scope=my_scope+my_other_scope" + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"})], ) + requests.get("http://authorized_only", auth=auth) + def test_with_invalid_grant_request_no_json(token_cache, responses: RequestsMock): auth = requests_auth.OktaResourceOwnerPasswordCredentials( @@ -440,8 +448,7 @@ def test_with_invalid_grant_request_no_json(token_cache, responses: RequestsMock client_id="test_user2", client_secret="test_pwd2", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", body="failure", status=400, @@ -461,8 +468,7 @@ def test_with_invalid_grant_request_invalid_request_error( client_id="test_user2", client_secret="test_pwd2", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "invalid_request"}, status=400, @@ -488,8 +494,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description( client_id="test_user2", client_secret="test_pwd2", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "invalid_request", "error_description": "desc of the error"}, status=400, @@ -509,8 +514,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ client_id="test_user2", client_secret="test_pwd2", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "error": "invalid_request", @@ -537,8 +541,7 @@ def test_with_invalid_grant_request_invalid_request_error_and_error_description_ client_id="test_user2", client_secret="test_pwd2", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "error": "invalid_request", @@ -564,8 +567,7 @@ def test_with_invalid_grant_request_without_error(token_cache, responses: Reques client_id="test_user2", client_secret="test_pwd2", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"other": "other info"}, status=400, @@ -585,8 +587,7 @@ def test_with_invalid_grant_request_invalid_client_error( client_id="test_user2", client_secret="test_pwd2", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "invalid_client"}, status=400, @@ -616,8 +617,7 @@ def test_with_invalid_grant_request_invalid_grant_error( client_id="test_user2", client_secret="test_pwd2", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "invalid_grant"}, status=400, @@ -643,8 +643,7 @@ def test_with_invalid_grant_request_unauthorized_client_error( client_id="test_user2", client_secret="test_pwd2", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "unauthorized_client"}, status=400, @@ -668,8 +667,7 @@ def test_with_invalid_grant_request_unsupported_grant_type_error( client_id="test_user2", client_secret="test_pwd2", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "unsupported_grant_type"}, status=400, @@ -693,8 +691,7 @@ def test_with_invalid_grant_request_invalid_scope_error( client_id="test_user2", client_secret="test_pwd2", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={"error": "invalid_scope"}, status=400, @@ -717,8 +714,7 @@ def test_without_expected_token(token_cache, responses: RequestsMock): client_secret="test_pwd2", token_field_name="not_provided", ) - responses.add( - responses.POST, + responses.post( "https://testserver.okta-emea.com/oauth2/default/v1/token", json={ "access_token": "2YotnFZFEjr1zCsicMWpAA", diff --git a/tests/success_ntlm/requests_ntlm.py b/tests/success_ntlm/requests_ntlm.py index 8821f8d..f516e62 100644 --- a/tests/success_ntlm/requests_ntlm.py +++ b/tests/success_ntlm/requests_ntlm.py @@ -4,6 +4,6 @@ def __init__(self, username, password): self.password = password def __call__(self, r): - r.headers[ - "Authorization" - ] = f"HttpNtlmAuth fake {self.username} / {self.password}" + r.headers["Authorization"] = ( + f"HttpNtlmAuth fake {self.username} / {self.password}" + ) diff --git a/tests/test_api_key.py b/tests/test_api_key.py index e0ee5ed..935fa44 100644 --- a/tests/test_api_key.py +++ b/tests/test_api_key.py @@ -1,9 +1,10 @@ import pytest +import requests from responses import RequestsMock +from responses.matchers import header_matcher, query_string_matcher import requests_auth -from tests.auth_helper import get_header, get_query_args def test_header_api_key_requires_an_api_key(): @@ -20,19 +21,43 @@ def test_query_api_key_requires_an_api_key(): def test_header_api_key_is_sent_in_x_api_key_by_default(responses: RequestsMock): auth = requests_auth.HeaderApiKey("my_provided_api_key") - assert get_header(responses, auth).get("X-Api-Key") == "my_provided_api_key" + + responses.get( + "http://authorized_only", + match=[header_matcher({"X-API-Key": "my_provided_api_key"})], + ) + + requests.get("http://authorized_only", auth=auth) def test_query_api_key_is_sent_in_api_key_by_default(responses: RequestsMock): auth = requests_auth.QueryApiKey("my_provided_api_key") - assert get_query_args(responses, auth) == "/?api_key=my_provided_api_key" + + responses.get( + "http://authorized_only", + match=[query_string_matcher("api_key=my_provided_api_key")], + ) + + requests.get("http://authorized_only", auth=auth) def test_header_api_key_can_be_sent_in_a_custom_field_name(responses: RequestsMock): auth = requests_auth.HeaderApiKey("my_provided_api_key", "X-API-HEADER-KEY") - assert get_header(responses, auth).get("X-Api-Header-Key") == "my_provided_api_key" + + responses.get( + "http://authorized_only", + match=[header_matcher({"X-API-HEADER-KEY": "my_provided_api_key"})], + ) + + requests.get("http://authorized_only", auth=auth) def test_query_api_key_can_be_sent_in_a_custom_field_name(responses: RequestsMock): auth = requests_auth.QueryApiKey("my_provided_api_key", "X-API-QUERY-KEY") - assert get_query_args(responses, auth) == "/?X-API-QUERY-KEY=my_provided_api_key" + + responses.get( + "http://authorized_only", + match=[query_string_matcher("X-API-QUERY-KEY=my_provided_api_key")], + ) + + requests.get("http://authorized_only", auth=auth) diff --git a/tests/test_auths.py b/tests/test_auths.py deleted file mode 100644 index 5435f79..0000000 --- a/tests/test_auths.py +++ /dev/null @@ -1,16 +0,0 @@ -import pytest -from responses import RequestsMock - -import requests_auth -from tests.auth_helper import get_header - - -def test_basic_and_api_key_authentication_can_be_combined_deprecated( - responses: RequestsMock, -): - basic_auth = requests_auth.Basic("test_user", "test_pwd") - api_key_auth = requests_auth.HeaderApiKey("my_provided_api_key") - with pytest.warns(DeprecationWarning): - header = get_header(responses, requests_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 index 8724950..d9d66d2 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,12 +1,16 @@ +import requests from responses import RequestsMock +from responses.matchers import header_matcher import requests_auth -from tests.auth_helper import get_header def test_basic_authentication_send_authorization_header(responses: RequestsMock): auth = requests_auth.Basic("test_user", "test_pwd") - assert ( - get_header(responses, auth).get("Authorization") - == "Basic dGVzdF91c2VyOnRlc3RfcHdk" + + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "Basic dGVzdF91c2VyOnRlc3RfcHdk"})], ) + + requests.get("http://authorized_only", auth=auth) diff --git a/tests/test_json_token_file_cache.py b/tests/test_json_token_file_cache.py deleted file mode 100644 index a5234d0..0000000 --- a/tests/test_json_token_file_cache.py +++ /dev/null @@ -1,92 +0,0 @@ -import datetime - -import pytest -import jwt - -import requests_auth - - -@pytest.fixture -def token_cache(request): - _token_cache = requests_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") - 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") - 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") - 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") - token_cache._add_bearer_token("key2", token2) - - same_cache = requests_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(requests_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") - - # Assert that the exception is not thrown - token_cache._add_bearer_token("key1", token1) - - same_cache = requests_auth.JsonTokenFileCache(request.node.name + ".cache") - with pytest.raises(requests_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(requests_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") - retrieved_token = token_cache.get_token( - "key1", on_missing_token=lambda: ("key1", token) - ) - assert retrieved_token == token - - -def test_token_without_refresh_token(token_cache): - expiry_in_1_hour = datetime.datetime.utcnow() + datetime.timedelta(hours=1) - # add token without refresh token - token = jwt.encode({"exp": expiry_in_1_hour}, "secret") - token_cache.tokens["key1"] = ( - token, - expiry_in_1_hour.replace(tzinfo=datetime.timezone.utc).timestamp(), - ) - token_cache._save_tokens() - - # try to retrieve it - retrieved_token = token_cache.get_token("key1") - assert token == retrieved_token diff --git a/tests/test_ntlm.py b/tests/test_ntlm.py index c710316..aa66b0b 100644 --- a/tests/test_ntlm.py +++ b/tests/test_ntlm.py @@ -1,9 +1,10 @@ import os import pytest +import requests +from responses.matchers import header_matcher import requests_auth -from tests.auth_helper import get_header def test_requests_negotiate_sspi_is_used_when_nothing_is_provided_but_without_installed( @@ -28,11 +29,15 @@ def test_requests_negotiate_sspi_is_used_when_nothing_is_provided( monkeypatch.syspath_prepend( os.path.join(os.path.abspath(os.path.dirname(__file__)), "success_ntlm") ) - assert ( - get_header(responses, requests_auth.NTLM()).get("Authorization") - == "HttpNegotiateAuth fake" + auth = requests_auth.NTLM() + + responses.get( + "http://authorized_only", + match=[header_matcher({"Authorization": "HttpNegotiateAuth fake"})], ) + requests.get("http://authorized_only", auth=auth) + def test_requests_ntlm_is_used_when_user_and_pass_provided_but_without_installed( monkeypatch, @@ -54,13 +59,17 @@ def test_requests_ntlm_is_used_when_user_and_pass_provided(monkeypatch, response monkeypatch.syspath_prepend( os.path.join(os.path.abspath(os.path.dirname(__file__)), "success_ntlm") ) - assert ( - get_header(responses, requests_auth.NTLM("fake_user", "fake_pwd")).get( - "Authorization" - ) - == "HttpNtlmAuth fake fake_user / fake_pwd" + auth = requests_auth.NTLM("fake_user", "fake_pwd") + + responses.get( + "http://authorized_only", + match=[ + header_matcher({"Authorization": "HttpNtlmAuth fake fake_user / fake_pwd"}) + ], ) + requests.get("http://authorized_only", auth=auth) + def test_user_without_password_is_invalid(): with pytest.raises(Exception) as exception_info: diff --git a/tests/test_oauth2_client_credential_okta.py b/tests/test_oauth2_client_credential_okta.py deleted file mode 100644 index 3594ea4..0000000 --- a/tests/test_oauth2_client_credential_okta.py +++ /dev/null @@ -1,128 +0,0 @@ -import requests -from responses import RequestsMock - -import requests_auth -from tests.auth_helper import get_header, get_request -from requests_auth.testing import token_cache - - -def test_okta_client_credentials_flow_uses_provided_session( - token_cache, responses: RequestsMock -): - session = requests.Session() - session.headers.update({"x-test": "Test value"}) - auth = requests_auth.OktaClientCredentials( - "test_okta", client_id="test_user", client_secret="test_pwd", session=session - ) - responses.add( - responses.POST, - "https://test_okta/oauth2/default/v1/token", - json={ - "access_token": "2YotnFZFEjr1zCsicMWpAA", - "token_type": "example", - "expires_in": 3600, - "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", - "example_parameter": "example_value", - }, - ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - request = get_request(responses, "https://test_okta/oauth2/default/v1/token") - assert request.headers["x-test"] == "Test value" - - -def test_okta_client_credentials_flow_token_is_sent_in_authorization_header_by_default( - token_cache, responses: RequestsMock -): - auth = requests_auth.OktaClientCredentials( - "test_okta", client_id="test_user", client_secret="test_pwd" - ) - responses.add( - responses.POST, - "https://test_okta/oauth2/default/v1/token", - json={ - "access_token": "2YotnFZFEjr1zCsicMWpAA", - "token_type": "example", - "expires_in": 3600, - "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", - "example_parameter": "example_value", - }, - ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - - -def test_okta_client_credentials_flow_token_is_expired_after_30_seconds_by_default( - token_cache, responses: RequestsMock -): - auth = requests_auth.OktaClientCredentials( - "test_okta", client_id="test_user", client_secret="test_pwd" - ) - # Add a token that expires in 29 seconds, so should be considered as expired when issuing the request - token_cache._add_token( - key="f0d25aa4e496c6615328e776bb981dabe53fa77768a0a58eaf6d54215c598d80e57ffc7926fd96ec6a6a872942cb684a473e36233b593fb760d3eb6dc22ae550", - token="2YotnFZFEjr1zCsicMWpAA", - expiry=requests_auth.oauth2_tokens._to_expiry(expires_in=29), - ) - # Meaning a new one will be requested - responses.add( - responses.POST, - "https://test_okta/oauth2/default/v1/token", - json={ - "access_token": "2YotnFZFEjr1zCsicMWpAA", - "token_type": "example", - "expires_in": 3600, - "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", - "example_parameter": "example_value", - }, - ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - - -def test_okta_client_credentials_flow_token_custom_expiry( - token_cache, responses: RequestsMock -): - auth = requests_auth.OktaClientCredentials( - "test_okta", - client_id="test_user", - client_secret="test_pwd", - early_expiry=28, - ) - # Add a token that expires in 29 seconds, so should be considered as not expired when issuing the request - token_cache._add_token( - key="f0d25aa4e496c6615328e776bb981dabe53fa77768a0a58eaf6d54215c598d80e57ffc7926fd96ec6a6a872942cb684a473e36233b593fb760d3eb6dc22ae550", - token="2YotnFZFEjr1zCsicMWpAA", - expiry=requests_auth.oauth2_tokens._to_expiry(expires_in=29), - ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) - - -def test_expires_in_sent_as_str(token_cache, responses: RequestsMock): - auth = requests_auth.OktaClientCredentials( - "test_okta", client_id="test_user", client_secret="test_pwd" - ) - responses.add( - responses.POST, - "https://test_okta/oauth2/default/v1/token", - json={ - "access_token": "2YotnFZFEjr1zCsicMWpAA", - "token_type": "example", - "expires_in": "3600", - "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA", - "example_parameter": "example_value", - }, - ) - assert ( - get_header(responses, auth).get("Authorization") - == "Bearer 2YotnFZFEjr1zCsicMWpAA" - ) diff --git a/tests/test_testing_token_mock.py b/tests/test_testing_token_mock.py deleted file mode 100644 index d44f0cb..0000000 --- a/tests/test_testing_token_mock.py +++ /dev/null @@ -1,13 +0,0 @@ -from responses import RequestsMock - -import requests_auth -from requests_auth.testing import token_cache_mock, token_mock -from tests.auth_helper import get_header - - -def test_token_mock(token_cache_mock, responses: RequestsMock): - auth = requests_auth.OAuth2Implicit("http://provide_token") - expected_token = requests_auth.OAuth2.token_cache.get_token("") - assert ( - get_header(responses, auth).get("Authorization") == f"Bearer {expected_token}" - )