diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44c2ed9..3cc25dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,12 +11,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -28,8 +28,8 @@ jobs: pytest --cov=httpx_auth --cov-fail-under=100 --cov-report=term-missing - name: Create packages run: | - python -m pip install wheel - python setup.py sdist bdist_wheel + python -m pip install build + python -m build . - name: Publish packages run: | python -m pip install twine diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7585662..1a579e3 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'] + python-version: ['3.8', '3.9', '3.10', '3.11'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a275c92..d31238e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ repos: - repo: https://github.com/psf/black - rev: 22.1.0 + rev: 23.3.0 hooks: - id: black \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce4918..e122fb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.16.0] - 2023-04-25 +### Changed +- Requires [`httpx`](https://www.python-httpx.org)==0.24.\* + +### Fixed +- Handle `text/html; charset=utf-8` content-type in token responses. Thanks to [`Marcelo Trylesinski`](https://github.com/Kludex). + +### Added +- `httpx_auth.WakaTimeAuthorizationCode` handling access to the [WakaTime API](https://wakatime.com/developers). + +### Removed +- Python 3.7 is no longer supported. + ## [0.15.0] - 2022-06-01 ### Changed - Requires [`httpx`](https://www.python-httpx.org)==0.23.\* @@ -157,7 +170,8 @@ Note that a few changes were made: ### Added - Placeholder for port of requests_auth to httpx -[Unreleased]: https://github.com/Colin-b/httpx_auth/compare/v0.15.0...HEAD +[Unreleased]: https://github.com/Colin-b/httpx_auth/compare/v0.16.0...HEAD +[0.16.0]: https://github.com/Colin-b/httpx_auth/compare/v0.15.0...v0.16.0 [0.15.0]: https://github.com/Colin-b/httpx_auth/compare/v0.14.1...v0.15.0 [0.14.1]: https://github.com/Colin-b/httpx_auth/compare/v0.14.0...v0.14.1 [0.14.0]: https://github.com/Colin-b/httpx_auth/compare/v0.13.0...v0.14.0 diff --git a/LICENSE b/LICENSE index ab867f9..3738ea5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Colin Bounouar +Copyright (c) 2023 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 9d31ae4..c837925 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@ Build status Coverage Code style: black -Number of tests +Number of tests Number of downloads

> Version 1.0.0 will be released once httpx is considered as stable (release of 1.0.0). > -> However current state can be considered as stable. +> However, current state can be considered as stable. Provides authentication classes to be used with [`httpx`][1] [authentication parameter][2]. @@ -27,6 +27,7 @@ Provides authentication classes to be used with [`httpx`][1] [authentication par - [OAuth2](#oauth-2) - [Authorization Code Flow](#authorization-code-flow) - [Okta](#okta-oauth2-authorization-code) + - [WakaTime](#wakatime-oauth2-authorization-code) - [Authorization Code Flow with PKCE](#authorization-code-flow-with-proof-key-for-code-exchange) - [Okta](#okta-oauth2-proof-key-for-code-exchange) - [Resource Owner Password Credentials flow](#resource-owner-password-credentials-flow) @@ -147,6 +148,44 @@ 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 `httpx_auth.WakaTimeAuthorizationCode` to configure this kind of authentication. + +```python +import httpx +from httpx_auth import WakaTimeAuthorizationCode + + +waka_time = WakaTimeAuthorizationCode(client_id="aPJQV0op6Pu3b66MWDi9b1wB", client_secret="waka_sec_0c5MB", scope="email") +with httpx.Client() as client: + client.get('https://wakatime.com/api/v1/users/current', auth=waka_time) +``` + +###### 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_endpoint` | Custom endpoint that will be used as redirect_uri the following way: http://localhost:/. | Optional | '' | +| `redirect_uri_port` | The port on which the server listening for the OAuth 2 token will be started. | Optional | 5000 | +| `timeout` | Maximum amount of seconds to wait for a token to be received once requested. | Optional | 60 | +| `success_display_time` | In case a token is successfully received, this is the maximum amount of milliseconds the success page will be displayed in your browser. | Optional | 1 | +| `failure_display_time` | In case received token is not valid, this is the maximum amount of milliseconds the failure page will be displayed in your browser. | Optional | 5000 | +| `header_name` | Name of the header field used to send token. | Optional | Authorization | +| `header_value` | Format used to send the token value. "{token}" must be present as it will be replaced by the actual token. | Optional | Bearer {token} | +| `client` | `httpx.Client` instance that will be used to request the token. Use it to provide a custom proxying rule for instance. | Optional | | + +Any other parameter will be put as query parameter in the authorization URL. + ### Authorization Code Flow with Proof Key for Code Exchange Proof Key for Code Exchange is implemented following [rfc7636](https://tools.ietf.org/html/rfc7636). diff --git a/httpx_auth/__init__.py b/httpx_auth/__init__.py index bb1509d..00859d4 100644 --- a/httpx_auth/__init__.py +++ b/httpx_auth/__init__.py @@ -15,6 +15,7 @@ OAuth2ClientCredentials, OktaClientCredentials, OAuth2ResourceOwnerPasswordCredentials, + WakaTimeAuthorizationCode, ) from httpx_auth.oauth2_tokens import JsonTokenFileCache from httpx_auth.aws import AWS4Auth @@ -46,6 +47,7 @@ "OAuth2ClientCredentials", "OktaClientCredentials", "OAuth2ResourceOwnerPasswordCredentials", + "WakaTimeAuthorizationCode", "JsonTokenFileCache", "AWS4Auth", "GrantNotProvided", diff --git a/httpx_auth/authentication.py b/httpx_auth/authentication.py index 6540a5f..d058111 100644 --- a/httpx_auth/authentication.py +++ b/httpx_auth/authentication.py @@ -3,7 +3,7 @@ import uuid from hashlib import sha256, sha512 from urllib.parse import parse_qs, urlsplit, urlunsplit, urlencode -from typing import Optional, Generator +from typing import Optional, Generator, Union, Iterable import httpx @@ -59,6 +59,17 @@ def _get_query_parameter(url: str, param_name: str) -> Optional[str]: return all_values[0] if all_values else None +def _content_from_response(response: httpx.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, client: httpx.Client ) -> (str, int): @@ -68,7 +79,7 @@ def request_new_grant_with_post( # As described in https://tools.ietf.org/html/rfc6749#section-5.2 raise InvalidGrantRequest(response) - content = response.json() + content = _content_from_response(response) token = content.get(grant_name) if not token: raise GrantNotProvided(grant_name, content) @@ -1051,6 +1062,66 @@ def __init__(self, instance: str, client_id: str, **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 an Universal Unique Identifier) + :param client_secret: WakaTime Application Secret (formatted as waka_sec_ followed by an 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 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 client: httpx.Client instance that will be used to request the token. + Use it to provide a custom proxying rule for instance. + :param kwargs: all additional authorization parameters that should be put as query parameter + in the authorization URL. + """ + 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, + ) + + class OktaAuthorizationCodePKCE(OAuth2AuthorizationCodePKCE): """ Describes an Okta (OAuth 2) "Access Token" Proof Key for Code Exchange (PKCE) flow requests authentication. diff --git a/httpx_auth/version.py b/httpx_auth/version.py index 9bb0189..7b67db0 100644 --- a/httpx_auth/version.py +++ b/httpx_auth/version.py @@ -3,4 +3,4 @@ # Major should be incremented in case there is a breaking change. (eg: 2.5.8 -> 3.0.0) # Minor should be incremented in case there is an enhancement. (eg: 2.5.8 -> 2.6.0) # Patch should be incremented in case there is a bug fix. (eg: 2.5.8 -> 2.5.9) -__version__ = "0.15.0" +__version__ = "0.16.0" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..acacf1e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = ["setuptools", "setuptools_scm"] +build-backend = "setuptools.build_meta" + +[project] +name = "httpx_auth" +description = "Authentication for HTTPX" +readme = "README.md" +requires-python = ">=3.8" +license = {file = "LICENSE"} +authors = [ + {name = "Colin Bounouar", email = "colin.bounouar.dev@gmail.com" } +] +maintainers = [ + {name = "Colin Bounouar", email = "colin.bounouar.dev@gmail.com" } +] +keywords = ["authentication", "oauth2", "aws", "okta", "aad"] +classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Typing :: Typed", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Software Development :: Build Tools", +] +dependencies = [ + "httpx==0.24.*", +] +dynamic = ["version"] + +[project.urls] +documentation = "https://colin-b.github.io/httpx_auth/" +repository = "https://github.com/Colin-b/httpx_auth" +changelog = "https://github.com/Colin-b/httpx_auth/blob/master/CHANGELOG.md" +issues = "https://github.com/Colin-b/httpx_auth/issues" + +[project.optional-dependencies] +testing = [ + # Used to generate test tokens + "pyjwt==2.*", + # Used to mock httpx + "pytest_httpx==0.22.*", + # Used to check coverage + "pytest-cov==4.*", +] + +[tool.setuptools.packages.find] +exclude = ["tests*"] + +[tool.setuptools.dynamic] +version = {attr = "httpx_auth.version.__version__"} diff --git a/setup.py b/setup.py deleted file mode 100644 index a191abc..0000000 --- a/setup.py +++ /dev/null @@ -1,60 +0,0 @@ -import os -from setuptools import setup, find_packages - -this_dir = os.path.abspath(os.path.dirname(__file__)) -with open(os.path.join(this_dir, "README.md"), "r") as f: - long_description = f.read() - -# More information on properties: https://packaging.python.org/distributing -setup( - name="httpx_auth", - version=open("httpx_auth/version.py").readlines()[-1].split()[-1].strip("\"'"), - author="Colin Bounouar", - author_email="colin.bounouar.dev@gmail.com", - maintainer="Colin Bounouar", - maintainer_email="colin.bounouar.dev@gmail.com", - url="https://colin-b.github.io/httpx_auth/", - description="Authentication for HTTPX", - long_description=long_description, - long_description_content_type="text/markdown", - download_url="https://pypi.org/project/httpx-auth/", - license="MIT", - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Natural Language :: English", - "Typing :: Typed", - "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", - "Topic :: Software Development :: Build Tools", - ], - keywords=["authentication", "oauth2", "aws", "okta", "aad"], - package_data={"httpx_auth": ["py.typed"]}, - packages=find_packages(exclude=["tests*"]), - install_requires=[ - # Used for Base Authentication and to communicate with OAuth2 servers - "httpx==0.23.*" - ], - extras_require={ - "testing": [ - # Used to generate test tokens - "pyjwt==2.*", - # Used to mock httpx - "pytest_httpx==0.21.*", - # Used to check coverage - "pytest-cov==3.*", - ] - }, - python_requires=">=3.7", - project_urls={ - "GitHub": "https://github.com/Colin-b/httpx_auth", - "Changelog": "https://github.com/Colin-b/httpx_auth/blob/master/CHANGELOG.md", - "Issues": "https://github.com/Colin-b/httpx_auth/issues", - }, - platforms=["Windows", "Linux"], -) diff --git a/tests/test_oauth2_authorization_code.py b/tests/test_oauth2_authorization_code.py index db06a15..ec1d561 100644 --- a/tests/test_oauth2_authorization_code.py +++ b/tests/test_oauth2_authorization_code.py @@ -118,6 +118,32 @@ def test_oauth2_authorization_code_flow_get_code_is_sent_in_authorization_header ) +def test_oauth2_authorization_code_flow_token_as_html( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.OAuth2AuthorizationCode( + "https://provide_code", "https://provide_access_token" + ) + tab = browser_mock.add_response( + opened_url="https://provide_code?response_type=code&state=ce9c755b41b5e3c5b64c70598715d5de271023a53f39a67a70215d265d11d2bfb6ef6e9c701701e998e69cbdbf2cee29fd51d2a950aa05f59a20cf4a646099d5&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F", + reply_url="http://localhost:5000#code=SplxlOBeZQQYbYS6WxSbIA&state=ce9c755b41b5e3c5b64c70598715d5de271023a53f39a67a70215d265d11d2bfb6ef6e9c701701e998e69cbdbf2cee29fd51d2a950aa05f59a20cf4a646099d5", + ) + httpx_mock.add_response( + method="POST", + url="https://provide_access_token", + html="access_token=2YotnFZFEjr1zCsicMWpAA&token_type=example&expires_in=3600&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA&example_parameter=example_value", + match_content=b"grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA", + ) + httpx_mock.add_response( + match_headers={"Authorization": "Bearer 2YotnFZFEjr1zCsicMWpAA"} + ) + # Send a request to this dummy URL with authentication + httpx.get("https://authorized_only", auth=auth) + tab.assert_success( + "You are now authenticated on ce9c755b41b5e3c5b64c70598715d5de271023a53f39a67a70215d265d11d2bfb6ef6e9c701701e998e69cbdbf2cee29fd51d2a950aa05f59a20cf4a646099d5. You may close this tab." + ) + + def test_oauth2_authorization_code_flow_get_code_is_expired_after_30_seconds_by_default( token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock ): diff --git a/tests/test_oauth2_authorization_code_wakatime.py b/tests/test_oauth2_authorization_code_wakatime.py new file mode 100644 index 0000000..53e6351 --- /dev/null +++ b/tests/test_oauth2_authorization_code_wakatime.py @@ -0,0 +1,784 @@ +from pytest_httpx import HTTPXMock +import pytest +import httpx + +import httpx_auth +from httpx_auth.testing import BrowserMock, browser_mock, token_cache +from tests.auth_helper import get_header + + +def test_oauth2_authorization_code_flow_uses_provided_client( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + client = httpx.Client(headers={"x-test": "Test value"}) + auth = httpx_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope="email", + client=client, + ) + 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", + ) + httpx_mock.add_response( + method="POST", + url="https://wakatime.com/oauth/token", + html="access_token=waka_tok_12345&token_type=bearer&expires_in=3600&refresh_token=waka_ref_12345&scope=email&example_parameter=example_value", + match_content=b"grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA", + match_headers={"x-test": "Test value"}, + ) + assert get_header(httpx_mock, auth).get("Authorization") == "Bearer waka_tok_12345" + tab.assert_success( + "You are now authenticated on 5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a. You may close this tab." + ) + + +def test_multiple_scopes_are_comma_separated( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="https://wakatime.com/oauth/token", + html="access_token=waka_tok_12345&token_type=bearer&expires_in=3600&refresh_token=waka_ref_12345&scope=email&example_parameter=example_value", + match_content=b"grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email%2Cread_stats&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA", + ) + assert get_header(httpx_mock, auth).get("Authorization") == "Bearer waka_tok_12345" + tab.assert_success( + "You are now authenticated on 34f21f9ea8be7b1dfd3dd1673a9aea7c3a1737228b4f08bc11ebacb88449afaa658811f8022e9962927a0ec42805c0e3cc5e6b0d9185308216b298a686001a1f. You may close this tab." + ) + + +def test_oauth2_authorization_code_flow_get_code_is_sent_in_authorization_header_by_default( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="https://wakatime.com/oauth/token", + html="access_token=waka_tok_12345&token_type=bearer&expires_in=3600&refresh_token=waka_ref_12345&scope=email&example_parameter=example_value", + match_content=b"grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA", + ) + assert get_header(httpx_mock, auth).get("Authorization") == "Bearer waka_tok_12345" + tab.assert_success( + "You are now authenticated on 5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a. You may close this tab." + ) + + +def test_json_response_is_handled_even_if_unused( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="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_content=b"grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA", + ) + assert get_header(httpx_mock, auth).get("Authorization") == "Bearer waka_tok_12345" + tab.assert_success( + "You are now authenticated on 5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a. You may close this tab." + ) + + +def test_oauth2_authorization_code_flow_get_code_is_expired_after_30_seconds_by_default( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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=httpx_auth.oauth2_tokens._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", + ) + httpx_mock.add_response( + method="POST", + url="https://wakatime.com/oauth/token", + html="access_token=waka_tok_12345&token_type=bearer&expires_in=3600&refresh_token=waka_ref_12345&scope=email&example_parameter=example_value", + match_content=b"grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA", + ) + assert get_header(httpx_mock, auth).get("Authorization") == "Bearer waka_tok_12345" + tab.assert_success( + "You are now authenticated on 5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a. You may close this tab." + ) + + +def test_oauth2_authorization_code_flow_get_code_custom_expiry( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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=httpx_auth.oauth2_tokens._to_expiry(expires_in=29), + ) + assert get_header(httpx_mock, auth).get("Authorization") == "Bearer waka_tok_12345" + + +def test_empty_token_is_invalid( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="https://wakatime.com/oauth/token", + html="access_token=&token_type=bearer&expires_in=3600&refresh_token=waka_ref_12345&scope=email&example_parameter=example_value", + match_content=b"grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2F&client_id=jPJQV0op6Pu3b66MWDi8b1wD&client_secret=waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU&scope=email&response_type=code&code=SplxlOBeZQQYbYS6WxSbIA", + ) + with pytest.raises(httpx_auth.GrantNotProvided) as exception_info: + httpx.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( + "You are now authenticated on 5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a. You may close this tab." + ) + + +def test_with_invalid_grant_request_no_json( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="https://wakatime.com/oauth/token", + text="failure", + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get("https://authorized_only", auth=auth) + assert str(exception_info.value) == "failure" + tab.assert_success( + "You are now authenticated on 5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.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", + ) + httpx_mock.add_response( + method="POST", + url="https://wakatime.com/oauth/token", + json={"error": "invalid_request"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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( + "You are now authenticated on 5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.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", + ) + httpx_mock.add_response( + method="POST", + url="https://wakatime.com/oauth/token", + json={"error": "invalid_request", "error_description": "desc of the error"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get("https://authorized_only", auth=auth) + assert str(exception_info.value) == "invalid_request: desc of the error" + tab.assert_success( + "You are now authenticated on 5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.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", + ) + httpx_mock.add_response( + method="POST", + url="https://wakatime.com/oauth/token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "https://test_url", + }, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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( + "You are now authenticated on 5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_request_error_and_error_description_and_uri_and_other_fields( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.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", + ) + httpx_mock.add_response( + method="POST", + url="https://wakatime.com/oauth/token", + json={ + "error": "invalid_request", + "error_description": "desc of the error", + "error_uri": "https://test_url", + "other": "other info", + }, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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( + "You are now authenticated on 5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a. You may close this tab." + ) + + +def test_with_invalid_grant_request_without_error( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_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", + ) + httpx_mock.add_response( + method="POST", + url="https://wakatime.com/oauth/token", + json={"other": "other info"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get("https://authorized_only", auth=auth) + assert str(exception_info.value) == "{'other': 'other info'}" + tab.assert_success( + "You are now authenticated on 5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_client_error( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.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", + ) + httpx_mock.add_response( + method="POST", + url="https://wakatime.com/oauth/token", + json={"error": "invalid_client"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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( + "You are now authenticated on 5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_grant_error( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.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", + ) + httpx_mock.add_response( + method="POST", + url="https://wakatime.com/oauth/token", + json={"error": "invalid_grant"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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( + "You are now authenticated on 5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a. You may close this tab." + ) + + +def test_with_invalid_grant_request_unauthorized_client_error( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.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", + ) + httpx_mock.add_response( + method="POST", + url="https://wakatime.com/oauth/token", + json={"error": "unauthorized_client"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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( + "You are now authenticated on 5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a. You may close this tab." + ) + + +def test_with_invalid_grant_request_unsupported_grant_type_error( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.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", + ) + httpx_mock.add_response( + method="POST", + url="https://wakatime.com/oauth/token", + json={"error": "unsupported_grant_type"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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( + "You are now authenticated on 5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a. You may close this tab." + ) + + +def test_with_invalid_grant_request_invalid_scope_error( + token_cache, httpx_mock: HTTPXMock, browser_mock: BrowserMock +): + auth = httpx_auth.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", + ) + httpx_mock.add_response( + method="POST", + url="https://wakatime.com/oauth/token", + json={"error": "invalid_scope"}, + status_code=400, + ) + with pytest.raises(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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( + "You are now authenticated on 5d0adb208bdbecaf5cfb6de0bf4ba0aea52986f3fc5ea7bc30c4b2db449c17e5c9d15f9a3926476cdaf1c72e9f73c7cfdc624dde0187c38d8c6b04532770df2a. You may close this tab." + ) + + +def test_with_invalid_token_request_invalid_request_error( + token_cache, browser_mock: BrowserMock +): + auth = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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( + "Unable to properly perform authentication: invalid_request: The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed." + ) + + +def test_with_invalid_token_request_invalid_request_error_and_error_description( + token_cache, browser_mock: BrowserMock +): + auth = httpx_auth.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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get("https://authorized_only", auth=auth) + assert str(exception_info.value) == "invalid_request: desc" + tab.assert_failure( + "Unable to properly perform authentication: invalid_request: desc" + ) + + +def test_with_invalid_token_request_invalid_request_error_and_error_description_and_uri( + token_cache, browser_mock: BrowserMock +): + auth = httpx_auth.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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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( + "Unable to properly perform authentication: invalid_request: desc\nMore 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 = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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( + "Unable to properly perform authentication: invalid_request: desc\nMore information can be found on https://test_url\nAdditional information: {'other': ['test']}" + ) + + +def test_with_invalid_token_request_unauthorized_client_error( + token_cache, browser_mock: BrowserMock +): + auth = httpx_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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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( + "Unable to properly perform authentication: unauthorized_client: The client is not authorized to request an authorization code or an access token using this method." + ) + + +def test_with_invalid_token_request_access_denied_error( + token_cache, browser_mock: BrowserMock +): + auth = httpx_auth.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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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( + "Unable to properly perform authentication: access_denied: The resource owner or authorization server denied the request." + ) + + +def test_with_invalid_token_request_unsupported_response_type_error( + token_cache, browser_mock: BrowserMock +): + auth = httpx_auth.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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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( + "Unable to properly perform authentication: unsupported_response_type: The authorization server does not support obtaining an authorization code or an access token using this method." + ) + + +def test_with_invalid_token_request_invalid_scope_error( + token_cache, browser_mock: BrowserMock +): + auth = httpx_auth.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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.get("https://authorized_only", auth=auth) + assert ( + str(exception_info.value) + == "invalid_scope: The requested scope is invalid, unknown, or malformed." + ) + tab.assert_failure( + "Unable to properly perform authentication: invalid_scope: The requested scope is invalid, unknown, or malformed." + ) + + +def test_with_invalid_token_request_server_error_error( + token_cache, browser_mock: BrowserMock +): + auth = httpx_auth.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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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( + "Unable to properly perform authentication: server_error: The authorization server encountered an unexpected condition that prevented it from fulfilling the request. (This error code is needed because a 500 Internal Server Error HTTP status code cannot be returned to the client via an HTTP redirect.)" + ) + + +def test_with_invalid_token_request_temporarily_unavailable_error( + token_cache, browser_mock: BrowserMock +): + auth = httpx_auth.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(httpx_auth.InvalidGrantRequest) as exception_info: + httpx.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( + "Unable to properly perform authentication: temporarily_unavailable: The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server. (This error code is needed because a 503 Service Unavailable HTTP status code cannot be returned to the client via an HTTP redirect.)" + ) + + +def test_header_value_must_contains_token(): + with pytest.raises(Exception) as exception_info: + httpx_auth.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: + httpx_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: + httpx_auth.WakaTimeAuthorizationCode( + "jPJQV0op6Pu3b66MWDi8b1wD", + "waka_sec_0c4MBGeR9LN74LzV5uelF9SgeQ32CqfeWpIuieneBbsL57dAAlqqJWDiVDJOlsSx61pVwHMKlsb3uMvU", + scope=None, + ) + assert str(exception_info.value) == "Scope is mandatory."