├── .all-contributorsrc ├── .editorconfig ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── build.yml │ └── documentation.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── docs ├── assets │ └── stylesheets │ │ └── extra.css ├── fastapi.md ├── index.md ├── reference │ ├── httpx_oauth.clients.md │ ├── httpx_oauth.exceptions.md │ ├── httpx_oauth.integrations.fastapi.md │ └── httpx_oauth.oauth2.md └── usage.md ├── httpx_oauth ├── __init__.py ├── branding.py ├── clients │ ├── __init__.py │ ├── discord.py │ ├── facebook.py │ ├── franceconnect.py │ ├── github.py │ ├── google.py │ ├── kakao.py │ ├── linkedin.py │ ├── microsoft.py │ ├── naver.py │ ├── okta.py │ ├── openid.py │ ├── reddit.py │ └── shopify.py ├── exceptions.py ├── integrations │ ├── __init__.py │ └── fastapi.py ├── oauth2.py └── py.typed ├── mkdocs.yml ├── pyproject.toml ├── tests ├── __init__.py ├── conftest.py ├── mock │ ├── error.json │ ├── facebook_success_long_lived_access_token.json │ ├── github_success_refresh_token.json │ ├── google_success_access_token.json │ ├── google_success_refresh_token.json │ └── reddit_success_identity.json ├── test_branding.py ├── test_clients_discord.py ├── test_clients_facebook.py ├── test_clients_franceconnect.py ├── test_clients_github.py ├── test_clients_google.py ├── test_clients_kakao.py ├── test_clients_linkedin.py ├── test_clients_microsoft.py ├── test_clients_naver.py ├── test_clients_okta.py ├── test_clients_openid.py ├── test_clients_reddit.py ├── test_clients_shopify.py ├── test_integrations_fastapi.py └── test_oauth2.py └── uv.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "frankie567", 10 | "name": "François Voron", 11 | "avatar_url": "https://avatars.githubusercontent.com/u/1144727?v=4", 12 | "profile": "http://francoisvoron.com", 13 | "contributions": [ 14 | "maintenance" 15 | ] 16 | }, 17 | { 18 | "login": "XaviTorello", 19 | "name": "Xavi Torelló", 20 | "avatar_url": "https://avatars.githubusercontent.com/u/8709244?v=4", 21 | "profile": "http://xaviertorello.cat", 22 | "contributions": [ 23 | "code" 24 | ] 25 | }, 26 | { 27 | "login": "fullonic", 28 | "name": "dbf", 29 | "avatar_url": "https://avatars.githubusercontent.com/u/13336073?v=4", 30 | "profile": "https://github.com/fullonic", 31 | "contributions": [ 32 | "code" 33 | ] 34 | }, 35 | { 36 | "login": "KentonParton", 37 | "name": "Kenton Parton", 38 | "avatar_url": "https://avatars.githubusercontent.com/u/20202312?v=4", 39 | "profile": "http://www.kentonparton.com", 40 | "contributions": [ 41 | "code" 42 | ] 43 | }, 44 | { 45 | "login": "stepan-chatalyan", 46 | "name": "stepan-chatalyan", 47 | "avatar_url": "https://avatars.githubusercontent.com/u/78931407?v=4", 48 | "profile": "https://github.com/stepan-chatalyan", 49 | "contributions": [ 50 | "code" 51 | ] 52 | }, 53 | { 54 | "login": "Forst", 55 | "name": "Foster Snowhill", 56 | "avatar_url": "https://avatars.githubusercontent.com/u/369699?v=4", 57 | "profile": "https://github.com/Forst", 58 | "contributions": [ 59 | "code" 60 | ] 61 | }, 62 | { 63 | "login": "williamhatcher", 64 | "name": "William Hatcher", 65 | "avatar_url": "https://avatars.githubusercontent.com/u/24600763?v=4", 66 | "profile": "https://hatcher.work", 67 | "contributions": [ 68 | "code" 69 | ] 70 | }, 71 | { 72 | "login": "thewchan", 73 | "name": "Matt Chan", 74 | "avatar_url": "https://avatars.githubusercontent.com/u/49702524?v=4", 75 | "profile": "https://github.com/thewchan", 76 | "contributions": [ 77 | "platform" 78 | ] 79 | }, 80 | { 81 | "login": "mekanix", 82 | "name": "Goran Mekić", 83 | "avatar_url": "https://avatars.githubusercontent.com/u/610855?v=4", 84 | "profile": "http://meka.rs", 85 | "contributions": [ 86 | "platform" 87 | ] 88 | }, 89 | { 90 | "login": "joonas-yoon", 91 | "name": "Joona Yoon", 92 | "avatar_url": "https://avatars.githubusercontent.com/u/9527681?v=4", 93 | "profile": "joonas.io", 94 | "contributions": [ 95 | "code" 96 | ] 97 | }, 98 | { 99 | "login": "LindezaGrey", 100 | "name": "LindezaGrey", 101 | "avatar_url": "https://avatars.githubusercontent.com/u/39629455?v=4", 102 | "profile": "http://vibrix.net", 103 | "contributions": [ 104 | "code" 105 | ] 106 | }, 107 | { 108 | "login": "Gr3atWh173", 109 | "name": "R. Singh", 110 | "avatar_url": "https://avatars.githubusercontent.com/u/11838184?v=4", 111 | "profile": "https://github.com/Gr3atWh173", 112 | "contributions": [ 113 | "bug" 114 | ] 115 | }, 116 | { 117 | "login": "lloesche", 118 | "name": "Lukas Lösche", 119 | "avatar_url": "https://avatars.githubusercontent.com/u/2124094?v=4", 120 | "profile": "https://github.com/lloesche", 121 | "contributions": [ 122 | "bug", 123 | "code" 124 | ] 125 | }, 126 | { 127 | "login": "king-jam", 128 | "name": "James King", 129 | "avatar_url": "https://avatars.githubusercontent.com/u/8225465?v=4", 130 | "profile": "https://github.com/king-jam", 131 | "contributions": [ 132 | "code" 133 | ] 134 | }, 135 | { 136 | "login": "bvolkmer", 137 | "name": "Benedikt Volkmer", 138 | "avatar_url": "https://avatars.githubusercontent.com/u/7070761?v=4", 139 | "profile": "https://github.com/bvolkmer", 140 | "contributions": [ 141 | "code", 142 | "bug" 143 | ] 144 | } 145 | ], 146 | "contributorsPerLine": 7, 147 | "projectName": "httpx-oauth", 148 | "projectOwner": "frankie567", 149 | "repoType": "github", 150 | "repoHost": "https://github.com", 151 | "skipCi": true, 152 | "commitConvention": "angular", 153 | "commitType": "docs" 154 | } 155 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.yml] 14 | indent_size = 2 15 | 16 | [Makefile] 17 | indent_style = tab 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | polar: frankie567 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python_version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: ${{ matrix.python_version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install hatch 23 | hatch env create 24 | - name: Lint and typecheck 25 | run: | 26 | hatch run lint-check 27 | - name: Test 28 | run: | 29 | hatch run test-cov-xml 30 | - uses: codecov/codecov-action@v3 31 | with: 32 | token: ${{ secrets.CODECOV_TOKEN }} 33 | fail_ci_if_error: true 34 | verbose: true 35 | 36 | release: 37 | runs-on: ubuntu-latest 38 | needs: test 39 | if: startsWith(github.ref, 'refs/tags/') 40 | 41 | steps: 42 | - uses: actions/checkout@v3 43 | - name: Set up Python 44 | uses: actions/setup-python@v4 45 | with: 46 | python-version: '3.9' 47 | - name: Install dependencies 48 | shell: bash 49 | run: | 50 | python -m pip install --upgrade pip 51 | pip install hatch 52 | - name: Build and publish on PyPI 53 | env: 54 | HATCH_INDEX_USER: ${{ secrets.HATCH_INDEX_USER }} 55 | HATCH_INDEX_AUTH: ${{ secrets.HATCH_INDEX_AUTH }} 56 | run: | 57 | hatch build 58 | hatch publish 59 | - name: Create release 60 | uses: ncipollo/release-action@v1 61 | with: 62 | draft: true 63 | body: ${{ github.event.head_commit.message }} 64 | artifacts: dist/*.whl,dist/*.tar.gz 65 | token: ${{ secrets.GITHUB_TOKEN }} 66 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Update documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: '3.9' 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install hatch 23 | - name: Build 24 | run: hatch run mkdocs build 25 | - name: Deploy 26 | uses: peaceiris/actions-gh-pages@v2.5.0 27 | env: 28 | ACTIONS_DEPLOY_KEY: ${{ secrets.ACTIONS_DEPLOY_KEY }} 29 | PUBLISH_BRANCH: gh-pages 30 | PUBLISH_DIR: ./site 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | junit/ 50 | junit.xml 51 | test.db 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # dotenv 87 | .env 88 | 89 | # virtualenv 90 | .venv 91 | venv/ 92 | ENV/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | # OS files 108 | .DS_Store 109 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.typeCheckingMode": "basic", 3 | "python.analysis.autoImportCompletions": true, 4 | "python.terminal.activateEnvironment": true, 5 | "python.terminal.activateEnvInCurrentTerminal": true, 6 | "python.testing.unittestEnabled": false, 7 | "python.testing.pytestEnabled": true, 8 | "editor.rulers": [88], 9 | "python.defaultInterpreterPath": "${workspaceFolder}/.hatch/httpx-oauth/bin/python", 10 | "python.testing.pytestPath": "${workspaceFolder}/.hatch/httpx-oauth/bin/pytest", 11 | "python.testing.cwd": "${workspaceFolder}", 12 | "python.testing.pytestArgs": ["--no-cov"], 13 | "[python]": { 14 | "editor.formatOnSave": true, 15 | "editor.codeActionsOnSave": { 16 | "source.fixAll": "explicit", 17 | "source.organizeImports": "explicit" 18 | }, 19 | "editor.defaultFormatter": "charliermarsh.ruff" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019, François Voron 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HTTPX OAuth 2 | 3 |

4 | Async OAuth client using HTTPX 5 |

6 | 7 | [![build](https://github.com/frankie567/httpx-oauth/workflows/Build/badge.svg)](https://github.com/frankie567/httpx-oauth/actions) 8 | [![codecov](https://codecov.io/gh/frankie567/httpx-oauth/branch/master/graph/badge.svg)](https://codecov.io/gh/frankie567/httpx-oauth) 9 | [![PyPI version](https://badge.fury.io/py/httpx-oauth.svg)](https://badge.fury.io/py/httpx-oauth) 10 | 11 | 12 | 13 | [![All Contributors](https://img.shields.io/badge/all_contributors-15-orange.svg?style=flat-square)](#contributors-) 14 | 15 | 16 | 17 | --- 18 | 19 | **Documentation**: https://frankie567.github.io/httpx-oauth/ 20 | 21 | **Source Code**: https://github.com/frankie567/httpx-oauth 22 | 23 | --- 24 | 25 | ## Installation 26 | 27 | ```bash 28 | pip install httpx-oauth 29 | ``` 30 | 31 | ## Contributors ✨ 32 | 33 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 |
François Voron
François Voron

🚧
Xavi Torelló
Xavi Torelló

💻
dbf
dbf

💻
Kenton Parton
Kenton Parton

💻
stepan-chatalyan
stepan-chatalyan

💻
Foster Snowhill
Foster Snowhill

💻
William Hatcher
William Hatcher

💻
Matt Chan
Matt Chan

📦
Goran Mekić
Goran Mekić

📦
Joona Yoon
Joona Yoon

💻
LindezaGrey
LindezaGrey

💻
R. Singh
R. Singh

🐛
Lukas Lösche
Lukas Lösche

🐛 💻
James King
James King

💻
Benedikt Volkmer
Benedikt Volkmer

💻 🐛
63 | 64 | 65 | 66 | 67 | 68 | 69 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 70 | 71 | ## Development 72 | 73 | ### Setup environment 74 | 75 | We use [Hatch](https://hatch.pypa.io/latest/install/) to manage the development environment and production build. Ensure it's installed on your system. 76 | 77 | ### Run unit tests 78 | 79 | You can run all the tests with: 80 | 81 | ```bash 82 | hatch run test 83 | ``` 84 | 85 | ### Format the code 86 | 87 | Execute the following command to apply `isort` and `black` formatting: 88 | 89 | ```bash 90 | hatch run lint 91 | ``` 92 | 93 | ### Serve the documentation 94 | 95 | You can serve the documentation locally with the following command: 96 | 97 | ```bash 98 | hatch run docs 99 | ``` 100 | 101 | The documentation will be available on [http://localhost:8000](http://localhost:8000). 102 | 103 | ## License 104 | 105 | This project is licensed under the terms of the MIT license. 106 | -------------------------------------------------------------------------------- /docs/assets/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | .buttons { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | 6 | .buttons .md-button { 7 | margin-bottom: 0.5rem; 8 | text-align: center; 9 | } 10 | -------------------------------------------------------------------------------- /docs/fastapi.md: -------------------------------------------------------------------------------- 1 | # FastAPI 2 | 3 | Utilities are provided to ease the integration of an OAuth2 process in [FastAPI](https://fastapi.tiangolo.com/). 4 | 5 | ## `OAuth2AuthorizeCallback` 6 | 7 | Dependency callable to handle the authorization callback. It reads the query parameters and returns the access token and the state. 8 | 9 | ```py 10 | from fastapi import FastAPI, Depends 11 | from httpx_oauth.integrations.fastapi import OAuth2AuthorizeCallback 12 | from httpx_oauth.oauth2 import OAuth2 13 | 14 | client = OAuth2("CLIENT_ID", "CLIENT_SECRET", "AUTHORIZE_ENDPOINT", "ACCESS_TOKEN_ENDPOINT") 15 | oauth2_authorize_callback = OAuth2AuthorizeCallback(client, "oauth-callback") 16 | app = FastAPI() 17 | 18 | @app.get("/oauth-callback", name="oauth-callback") 19 | async def oauth_callback(access_token_state=Depends(oauth2_authorize_callback)): 20 | token, state = access_token_state 21 | # Do something useful 22 | ``` 23 | 24 | [Reference](./reference/httpx_oauth.integrations.fastapi.md){ .md-button } 25 | { .buttons } 26 | 27 | ### Custom exception handler 28 | 29 | If an error occurs inside the callback logic (the user denied access, the authorization code is invalid...), the dependency will raise [OAuth2AuthorizeCallbackError][httpx_oauth.integrations.fastapi.OAuth2AuthorizeCallbackError]. 30 | 31 | It inherits from FastAPI's [HTTPException][fastapi.HTTPException], so it's automatically handled by the default FastAPI exception handler. You can customize this behavior by implementing your own exception handler for `OAuth2AuthorizeCallbackError`. 32 | 33 | ```py 34 | from fastapi import FastAPI 35 | from httpx_oauth.integrations.fastapi import OAuth2AuthorizeCallbackError 36 | 37 | app = FastAPI() 38 | 39 | @app.exception_handler(OAuth2AuthorizeCallbackError) 40 | async def oauth2_authorize_callback_error_handler(request: Request, exc: OAuth2AuthorizeCallbackError): 41 | detail = exc.detail 42 | status_code = exc.status_code 43 | return JSONResponse( 44 | status_code=status_code, 45 | content={"message": "The OAuth2 callback failed", "detail": detail}, 46 | ) 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --8<-- "README.md" 2 | -------------------------------------------------------------------------------- /docs/reference/httpx_oauth.clients.md: -------------------------------------------------------------------------------- 1 | # Reference - Clients 2 | 3 | ## Discord 4 | 5 | ::: httpx_oauth.clients.discord 6 | options: 7 | show_root_heading: false 8 | show_source: false 9 | inherited_members: true 10 | heading_level: 3 11 | 12 | ## Facebook 13 | 14 | ::: httpx_oauth.clients.facebook 15 | options: 16 | show_root_heading: true 17 | show_source: false 18 | inherited_members: true 19 | heading_level: 3 20 | 21 | ## GitHub 22 | 23 | ::: httpx_oauth.clients.github 24 | options: 25 | show_root_heading: true 26 | show_source: false 27 | inherited_members: true 28 | heading_level: 3 29 | 30 | ## Google 31 | 32 | ::: httpx_oauth.clients.google 33 | options: 34 | show_root_heading: true 35 | show_source: false 36 | inherited_members: true 37 | heading_level: 3 38 | 39 | ## Kakao 40 | 41 | ::: httpx_oauth.clients.kakao 42 | options: 43 | show_root_heading: true 44 | show_source: false 45 | inherited_members: true 46 | heading_level: 3 47 | 48 | ## LinkedIn 49 | 50 | ::: httpx_oauth.clients.linkedin 51 | options: 52 | show_root_heading: true 53 | show_source: false 54 | inherited_members: true 55 | heading_level: 3 56 | 57 | ## Microsoft 58 | 59 | ::: httpx_oauth.clients.microsoft 60 | options: 61 | show_root_heading: true 62 | show_source: false 63 | inherited_members: true 64 | heading_level: 3 65 | 66 | ## Naver 67 | 68 | ::: httpx_oauth.clients.naver 69 | options: 70 | show_root_heading: true 71 | show_source: false 72 | inherited_members: true 73 | heading_level: 3 74 | 75 | ## Okta 76 | 77 | ::: httpx_oauth.clients.okta 78 | options: 79 | show_root_heading: true 80 | show_source: false 81 | inherited_members: true 82 | heading_level: 3 83 | 84 | ## OpenID 85 | 86 | ::: httpx_oauth.clients.openid 87 | options: 88 | show_root_heading: true 89 | show_source: false 90 | inherited_members: true 91 | heading_level: 3 92 | 93 | ## Reddit 94 | 95 | ::: httpx_oauth.clients.reddit 96 | options: 97 | show_root_heading: true 98 | show_source: false 99 | inherited_members: true 100 | heading_level: 3 101 | 102 | ## Shopify 103 | 104 | ::: httpx_oauth.clients.shopify 105 | options: 106 | show_root_heading: true 107 | show_source: false 108 | inherited_members: true 109 | heading_level: 3 110 | 111 | -------------------------------------------------------------------------------- /docs/reference/httpx_oauth.exceptions.md: -------------------------------------------------------------------------------- 1 | # Reference - Exceptions 2 | 3 | ::: httpx_oauth.exceptions 4 | options: 5 | show_root_heading: false 6 | show_source: false 7 | -------------------------------------------------------------------------------- /docs/reference/httpx_oauth.integrations.fastapi.md: -------------------------------------------------------------------------------- 1 | # Reference - Integrations - FastAPI 2 | 3 | ::: httpx_oauth.integrations.fastapi 4 | options: 5 | show_root_heading: false 6 | show_source: false 7 | -------------------------------------------------------------------------------- /docs/reference/httpx_oauth.oauth2.md: -------------------------------------------------------------------------------- 1 | # Reference - OAuth2 2 | 3 | ::: httpx_oauth.oauth2 4 | options: 5 | show_root_heading: false 6 | show_source: false 7 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ## Create a client 4 | 5 | A generic OAuth2 class is provided to adapt to any OAuth2-compliant service. You can instantiate it like this: 6 | 7 | ```py 8 | from httpx_oauth.oauth2 import OAuth2 9 | 10 | client = OAuth2( 11 | "CLIENT_ID", 12 | "CLIENT_SECRET", 13 | "AUTHORIZE_ENDPOINT", 14 | "ACCESS_TOKEN_ENDPOINT", 15 | refresh_token_endpoint="REFRESH_TOKEN_ENDPOINT", 16 | revoke_token_endpoint="REVOKE_TOKEN_ENDPOINT", 17 | ) 18 | ``` 19 | 20 | Note that `refresh_token_endpoint` and `revoke_token_endpoint` are optional since not every services propose to refresh and revoke tokens. 21 | 22 | ## Generate an authorization URL 23 | 24 | Use the [get_authorization_url][httpx_oauth.oauth2.BaseOAuth2.get_authorization_url] method to generate the authorization URL where you should redirect the user to ask for their approval. 25 | 26 | ```py 27 | authorization_url = await client.get_authorization_url( 28 | "https://www.tintagel.bt/oauth-callback", scope=["SCOPE1", "SCOPE2", "SCOPE3"], 29 | ) 30 | ``` 31 | 32 | ## Request an access token 33 | 34 | Once you have the authorization code, use the [get_access_token][httpx_oauth.oauth2.BaseOAuth2.get_access_token] method to exchange it with a valid access token. 35 | 36 | It returns an [OAuth2Token][httpx_oauth.oauth2.OAuth2Token] dictionary-like object. 37 | 38 | ```py 39 | access_token = await client.get_access_token("CODE", "https://www.tintagel.bt/oauth-callback") 40 | ``` 41 | 42 | 43 | ## Refresh an access token 44 | 45 | For providers supporting it, you can ask for a fresh access token given a refresh token. For this, use the [refresh_token][httpx_oauth.oauth2.BaseOAuth2.refresh_token] method. 46 | 47 | It returns an [OAuth2Token][httpx_oauth.oauth2.OAuth2Token] dictionary-like object. 48 | 49 | ```py 50 | access_token = await client.refresh_token("REFRESH_TOKEN") 51 | ``` 52 | 53 | ## Revoke an access or refresh token 54 | 55 | For providers supporting it, you can ask to revoke an access or refresh token. For this, use the [revoke_token][httpx_oauth.oauth2.BaseOAuth2.revoke_token] method. 56 | 57 | ## Get profile 58 | 59 | For convenience, we provide a method that'll use a valid access token to query the provider API and get the profile of the authenticated user. For this, use the [get_profile][httpx_oauth.oauth2.BaseOAuth2.get_profile] method. 60 | 61 | This method is implemented specifically on each provider. Please note it's a raw JSON output from the provider API, so it might vary greatly. 62 | 63 | ### Get authenticated user ID and email 64 | 65 | Often, what you need is only the ID and the email. We offer another convenience method that'll do the heavy lifting of retrieving them from the profile output: the [get_id_email][httpx_oauth.oauth2.BaseOAuth2.get_id_email] method. 66 | 67 | This method is implemented specifically on each provider. 68 | 69 | ## Provided clients 70 | 71 | Out-of-the box, we support lot of popular providers like [Google][httpx_oauth.clients.google] or [Facebook][httpx_oauth.clients.facebook], for which we provided dedicated classes with pre-configured endpoints. 72 | 73 | [Clients reference](./reference/httpx_oauth.clients.md){ .md-button } 74 | {: .buttons } 75 | 76 | ## Customize HTTPX client 77 | 78 | By default, requests are made using [`httpx.AsyncClient`](https://www.python-httpx.org/api/#asyncclient) with default parameters. If you wish to customize settings, like setting timeout or proxies, you can do this by overloading the `get_httpx_client` method. 79 | 80 | ```py 81 | from typing import AsyncContextManager 82 | 83 | import httpx 84 | from httpx_oauth.oauth2 import OAuth2 85 | 86 | 87 | class OAuth2CustomTimeout(OAuth2): 88 | def get_httpx_client(self) -> AsyncContextManager[httpx.AsyncClient]: 89 | return httpx.AsyncClient(timeout=10.0) # Use a default 10s timeout everywhere. 90 | 91 | 92 | client = OAuth2CustomTimeout( 93 | "CLIENT_ID", 94 | "CLIENT_SECRET", 95 | "AUTHORIZE_ENDPOINT", 96 | "ACCESS_TOKEN_ENDPOINT", 97 | refresh_token_endpoint="REFRESH_TOKEN_ENDPOINT", 98 | revoke_token_endpoint="REVOKE_TOKEN_ENDPOINT", 99 | ) 100 | ``` 101 | -------------------------------------------------------------------------------- /httpx_oauth/__init__.py: -------------------------------------------------------------------------------- 1 | """Async OAuth client using HTTPX.""" 2 | 3 | __version__ = "0.16.1" 4 | -------------------------------------------------------------------------------- /httpx_oauth/branding.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol 2 | 3 | 4 | class BrandingProtocol(Protocol): 5 | display_name: str 6 | logo_svg: str 7 | -------------------------------------------------------------------------------- /httpx_oauth/clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankie567/httpx-oauth/003d13e833f412bc849ced7ed0890d4a4647ff84/httpx_oauth/clients/__init__.py -------------------------------------------------------------------------------- /httpx_oauth/clients/discord.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, cast 2 | 3 | from httpx_oauth.exceptions import GetIdEmailError, GetProfileError 4 | from httpx_oauth.oauth2 import BaseOAuth2 5 | 6 | AUTHORIZE_ENDPOINT = "https://discord.com/api/oauth2/authorize" 7 | ACCESS_TOKEN_ENDPOINT = "https://discord.com/api/oauth2/token" 8 | REVOKE_TOKEN_ENDPOINT = "https://discord.com/api/oauth2/token/revoke" 9 | BASE_SCOPES = ["identify", "email"] 10 | PROFILE_ENDPOINT = "https://discord.com/api/users/@me" 11 | 12 | 13 | LOGO_SVG = """ 14 | 15 | 16 | 17 | 18 | 19 | """ 20 | 21 | 22 | class DiscordOAuth2(BaseOAuth2[dict[str, Any]]): 23 | """OAuth2 client for Discord.""" 24 | 25 | display_name = "Discord" 26 | logo_svg = LOGO_SVG 27 | 28 | def __init__( 29 | self, 30 | client_id: str, 31 | client_secret: str, 32 | scopes: Optional[list[str]] = BASE_SCOPES, 33 | name: str = "discord", 34 | ): 35 | """ 36 | Args: 37 | client_id: The client ID provided by the OAuth2 provider. 38 | client_secret: The client secret provided by the OAuth2 provider. 39 | scopes: The default scopes to be used in the authorization URL. 40 | name: A unique name for the OAuth2 client. 41 | """ 42 | super().__init__( 43 | client_id, 44 | client_secret, 45 | AUTHORIZE_ENDPOINT, 46 | ACCESS_TOKEN_ENDPOINT, 47 | ACCESS_TOKEN_ENDPOINT, 48 | REVOKE_TOKEN_ENDPOINT, 49 | name=name, 50 | base_scopes=scopes, 51 | token_endpoint_auth_method="client_secret_basic", 52 | revocation_endpoint_auth_method="client_secret_basic", 53 | ) 54 | 55 | async def get_profile(self, token: str) -> dict[str, Any]: 56 | async with self.get_httpx_client() as client: 57 | response = await client.get( 58 | PROFILE_ENDPOINT, 59 | headers={**self.request_headers, "Authorization": f"Bearer {token}"}, 60 | ) 61 | 62 | if response.status_code >= 400: 63 | raise GetProfileError(response=response) 64 | 65 | return cast(dict[str, Any], response.json()) 66 | 67 | async def get_id_email(self, token: str) -> tuple[str, Optional[str]]: 68 | try: 69 | profile = await self.get_profile(token) 70 | except GetProfileError as e: 71 | raise GetIdEmailError(response=e.response) from e 72 | 73 | user_id = profile["id"] 74 | user_email = profile.get("email") 75 | 76 | if not profile.get("verified", False): 77 | user_email = None 78 | 79 | return user_id, user_email 80 | -------------------------------------------------------------------------------- /httpx_oauth/clients/facebook.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, cast 2 | 3 | from httpx_oauth.exceptions import GetIdEmailError, GetProfileError 4 | from httpx_oauth.oauth2 import BaseOAuth2, OAuth2RequestError, OAuth2Token 5 | 6 | AUTHORIZE_ENDPOINT = "https://www.facebook.com/v5.0/dialog/oauth" 7 | ACCESS_TOKEN_ENDPOINT = "https://graph.facebook.com/v5.0/oauth/access_token" 8 | BASE_SCOPES = ["email", "public_profile"] 9 | PROFILE_ENDPOINT = "https://graph.facebook.com/v5.0/me" 10 | 11 | 12 | LOGO_SVG = """ 13 | 14 | Facebook 15 | 16 | 17 | 18 | 19 | 20 | """ 21 | 22 | 23 | class GetLongLivedAccessTokenError(OAuth2RequestError): ... 24 | 25 | 26 | class FacebookOAuth2(BaseOAuth2[dict[str, Any]]): 27 | """OAuth2 client for Facebook.""" 28 | 29 | display_name = "Facebook" 30 | logo_svg = LOGO_SVG 31 | 32 | def __init__( 33 | self, 34 | client_id: str, 35 | client_secret: str, 36 | scopes: Optional[list[str]] = BASE_SCOPES, 37 | name: str = "facebook", 38 | ): 39 | """ 40 | Args: 41 | client_id: The client ID provided by the OAuth2 provider. 42 | client_secret: The client secret provided by the OAuth2 provider. 43 | scopes: The default scopes to be used in the authorization URL. 44 | name: A unique name for the OAuth2 client. 45 | """ 46 | super().__init__( 47 | client_id, 48 | client_secret, 49 | AUTHORIZE_ENDPOINT, 50 | ACCESS_TOKEN_ENDPOINT, 51 | name=name, 52 | base_scopes=scopes, 53 | ) 54 | 55 | async def get_long_lived_access_token(self, token: str) -> OAuth2Token: 56 | """ 57 | Request a [long-lived access token](https://developers.facebook.com/docs/facebook-login/access-tokens/refreshing/) 58 | given a short-lived access token. 59 | 60 | Args: 61 | token: The short-lived access token. 62 | 63 | Returns: 64 | An access token response dictionary. 65 | 66 | Raises: 67 | GetLongLivedAccessTokenError: An error occurred while requesting 68 | the long-lived access token. 69 | 70 | Examples: 71 | ```py 72 | long_lived_access_token = await client.get_long_lived_access_token("TOKEN") 73 | ``` 74 | """ 75 | async with self.get_httpx_client() as client: 76 | request, auth = self.build_request( 77 | client, 78 | "POST", 79 | self.access_token_endpoint, 80 | auth_method=self.token_endpoint_auth_method, 81 | data={ 82 | "grant_type": "fb_exchange_token", 83 | "fb_exchange_token": token, 84 | }, 85 | ) 86 | response = await self.send_request( 87 | client, request, auth, exc_class=GetLongLivedAccessTokenError 88 | ) 89 | data = self.get_json(response, exc_class=GetLongLivedAccessTokenError) 90 | return OAuth2Token(data) 91 | 92 | async def get_profile(self, token: str) -> dict[str, Any]: 93 | async with self.get_httpx_client() as client: 94 | response = await client.get( 95 | PROFILE_ENDPOINT, 96 | params={"fields": "id,email", "access_token": token}, 97 | ) 98 | 99 | if response.status_code >= 400: 100 | raise GetProfileError(response=response) 101 | 102 | return cast(dict[str, Any], response.json()) 103 | 104 | async def get_id_email(self, token: str) -> tuple[str, Optional[str]]: 105 | try: 106 | profile = await self.get_profile(token) 107 | except GetProfileError as e: 108 | raise GetIdEmailError(response=e.response) from e 109 | return profile["id"], profile.get("email") 110 | -------------------------------------------------------------------------------- /httpx_oauth/clients/franceconnect.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from typing import Any, Literal, Optional, TypedDict 3 | 4 | from httpx_oauth.exceptions import GetIdEmailError, GetProfileError 5 | from httpx_oauth.oauth2 import BaseOAuth2 6 | 7 | ENDPOINTS = { 8 | "integration": { 9 | "authorize": "https://fcp.integ01.dev-franceconnect.fr/api/v1/authorize", 10 | "access_token": "https://fcp.integ01.dev-franceconnect.fr/api/v1/token", 11 | "profile": "https://fcp.integ01.dev-franceconnect.fr/api/v1/userinfo", 12 | }, 13 | "production": { 14 | "authorize": "https://app.franceconnect.gouv.fr/api/v1/authorize", 15 | "access_token": "https://app.franceconnect.gouv.fr/api/v1/token", 16 | "profile": "https://app.franceconnect.gouv.fr/api/v1/userinfo", 17 | }, 18 | } 19 | BASE_SCOPES = ["openid", "email"] 20 | 21 | 22 | LOGO_SVG = """ 23 | 24 | """ 25 | 26 | 27 | class FranceConnectOAuth2AuthorizeParams(TypedDict, total=False): 28 | nonce: str 29 | 30 | 31 | class FranceConnectOAuth2(BaseOAuth2[FranceConnectOAuth2AuthorizeParams]): 32 | display_name = "FranceConnect" 33 | logo_svg = LOGO_SVG 34 | 35 | def __init__( 36 | self, 37 | client_id: str, 38 | client_secret: str, 39 | integration: bool = False, 40 | scopes: Optional[list[str]] = BASE_SCOPES, 41 | name="franceconnect", 42 | ): 43 | endpoints = ENDPOINTS["integration"] if integration else ENDPOINTS["production"] 44 | super().__init__( 45 | client_id, 46 | client_secret, 47 | endpoints["authorize"], 48 | endpoints["access_token"], 49 | refresh_token_endpoint=None, 50 | revoke_token_endpoint=None, 51 | name=name, 52 | base_scopes=scopes, 53 | ) 54 | self.profile_endpoint = endpoints["profile"] 55 | 56 | async def get_authorization_url( 57 | self, 58 | redirect_uri: str, 59 | state: Optional[str] = None, 60 | scope: Optional[list[str]] = None, 61 | code_challenge: Optional[str] = None, 62 | code_challenge_method: Optional[Literal["plain", "S256"]] = None, 63 | extras_params: Optional[FranceConnectOAuth2AuthorizeParams] = None, 64 | ) -> str: 65 | _extras_params = extras_params or {} 66 | 67 | # nonce is required for FranceConnect 68 | if _extras_params.get("nonce") is None: 69 | _extras_params["nonce"] = secrets.token_urlsafe() 70 | 71 | return await super().get_authorization_url( 72 | redirect_uri, state, scope, extras_params=_extras_params 73 | ) 74 | 75 | async def get_profile(self, token: str) -> dict[str, Any]: 76 | async with self.get_httpx_client() as client: 77 | response = await client.get( 78 | self.profile_endpoint, 79 | headers={**self.request_headers, "Authorization": f"Bearer {token}"}, 80 | ) 81 | 82 | if response.status_code >= 400: 83 | raise GetProfileError(response=response) 84 | 85 | return response.json() 86 | 87 | async def get_id_email(self, token: str) -> tuple[str, Optional[str]]: 88 | try: 89 | profile = await self.get_profile(token) 90 | except GetProfileError as e: 91 | raise GetIdEmailError(response=e.response) from e 92 | 93 | return str(profile["sub"]), profile.get("email") 94 | -------------------------------------------------------------------------------- /httpx_oauth/clients/github.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, TypedDict, cast 2 | 3 | import httpx 4 | 5 | from httpx_oauth.exceptions import GetIdEmailError, GetProfileError 6 | from httpx_oauth.oauth2 import BaseOAuth2, OAuth2Token, RefreshTokenError 7 | 8 | AUTHORIZE_ENDPOINT = "https://github.com/login/oauth/authorize" 9 | ACCESS_TOKEN_ENDPOINT = "https://github.com/login/oauth/access_token" 10 | BASE_SCOPES = ["user", "user:email"] 11 | PROFILE_ENDPOINT = "https://api.github.com/user" 12 | EMAILS_ENDPOINT = "https://api.github.com/user/emails" 13 | 14 | 15 | LOGO_SVG = """ 16 | 17 | 18 | 19 | 20 | 21 | """ 22 | 23 | 24 | class GitHubOAuth2AuthorizeParams(TypedDict, total=False): 25 | login: str 26 | allow_signup: bool 27 | 28 | 29 | class GitHubOAuth2(BaseOAuth2[GitHubOAuth2AuthorizeParams]): 30 | """OAuth2 client for GitHub.""" 31 | 32 | display_name = "GitHub" 33 | logo_svg = LOGO_SVG 34 | 35 | def __init__( 36 | self, 37 | client_id: str, 38 | client_secret: str, 39 | scopes: Optional[list[str]] = BASE_SCOPES, 40 | name: str = "github", 41 | ): 42 | """ 43 | Args: 44 | client_id: The client ID provided by the OAuth2 provider. 45 | client_secret: The client secret provided by the OAuth2 provider. 46 | scopes: The default scopes to be used in the authorization URL. 47 | name: A unique name for the OAuth2 client. 48 | """ 49 | super().__init__( 50 | client_id, 51 | client_secret, 52 | AUTHORIZE_ENDPOINT, 53 | ACCESS_TOKEN_ENDPOINT, 54 | ACCESS_TOKEN_ENDPOINT, 55 | name=name, 56 | base_scopes=scopes, 57 | token_endpoint_auth_method="client_secret_post", 58 | ) 59 | 60 | async def refresh_token(self, refresh_token: str) -> OAuth2Token: 61 | """ 62 | Requests a new access token using a refresh token. 63 | 64 | !!! warning "Refresh tokens are not enabled by default" 65 | Refresh tokens are currently an optional feature you need to enable in your GitHub app parameters. 66 | [Read more](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens). 67 | 68 | Args: 69 | refresh_token: The refresh token. 70 | 71 | Returns: 72 | An access token response dictionary. 73 | 74 | Raises: 75 | RefreshTokenError: An error occurred while refreshing the token. 76 | RefreshTokenNotSupportedError: The provider does not support token refresh. 77 | 78 | Examples: 79 | ```py 80 | access_token = await client.refresh_token("REFRESH_TOKEN") 81 | ``` 82 | """ 83 | assert self.refresh_token_endpoint is not None 84 | async with self.get_httpx_client() as client: 85 | request, auth = self.build_request( 86 | client, 87 | "POST", 88 | self.refresh_token_endpoint, 89 | auth_method=self.token_endpoint_auth_method, 90 | data={ 91 | "grant_type": "refresh_token", 92 | "refresh_token": refresh_token, 93 | }, 94 | ) 95 | response = await self.send_request( 96 | client, request, auth, exc_class=RefreshTokenError 97 | ) 98 | 99 | data = self.get_json(response, exc_class=RefreshTokenError) 100 | 101 | # GitHub sends errors with a 200 status code 102 | if "error" in data: 103 | raise RefreshTokenError(cast(str, data["error"]), response) 104 | 105 | data = self.get_json(response, exc_class=RefreshTokenError) 106 | 107 | return OAuth2Token(data) 108 | 109 | async def get_profile(self, token: str) -> dict[str, Any]: 110 | async with httpx.AsyncClient( 111 | headers={**self.request_headers, "Authorization": f"token {token}"} 112 | ) as client: 113 | response = await client.get(PROFILE_ENDPOINT) 114 | 115 | if response.status_code >= 400: 116 | raise GetProfileError(response=response) 117 | 118 | return cast(dict[str, Any], response.json()) 119 | 120 | async def get_emails(self, token: str) -> list[dict[str, Any]]: 121 | """ 122 | Return the emails of the authenticated user from the API provider. 123 | 124 | !!! tip 125 | You should enable **Email addresses** permission 126 | in the **Permissions & events** section of your GitHub app parameters. 127 | You can find it at [https://github.com/settings/apps/{YOUR_APP}/permissions](https://github.com/settings/apps/{YOUR_APP}/permissions). 128 | 129 | Args: 130 | token: The access token. 131 | 132 | Returns: 133 | A list of emails as described in the [GitHub API](https://docs.github.com/en/rest/users/emails?apiVersion=2022-11-28#list-email-addresses-for-the-authenticated-user). 134 | 135 | Raises: 136 | httpx_oauth.exceptions.GetProfileError: 137 | An error occurred while getting the emails. 138 | 139 | Examples: 140 | ```py 141 | emails = await client.get_emails("TOKEN") 142 | ``` 143 | """ 144 | async with httpx.AsyncClient( 145 | headers={**self.request_headers, "Authorization": f"token {token}"} 146 | ) as client: 147 | response = await client.get(EMAILS_ENDPOINT) 148 | 149 | if response.status_code >= 400: 150 | raise GetProfileError(response=response) 151 | 152 | return cast(list[dict[str, Any]], response.json()) 153 | 154 | async def get_id_email(self, token: str) -> tuple[str, Optional[str]]: 155 | """ 156 | Returns the id and the email (if available) of the authenticated user 157 | from the API provider. 158 | 159 | !!! tip 160 | You should enable **Email addresses** permission 161 | in the **Permissions & events** section of your GitHub app parameters. 162 | You can find it at [https://github.com/settings/apps/{YOUR_APP}/permissions](https://github.com/settings/apps/{YOUR_APP}/permissions). 163 | 164 | Args: 165 | token: The access token. 166 | 167 | Returns: 168 | A tuple with the id and the email of the authenticated user. 169 | 170 | 171 | Raises: 172 | httpx_oauth.exceptions.GetIdEmailError: 173 | An error occurred while getting the id and email. 174 | 175 | Examples: 176 | ```py 177 | user_id, user_email = await client.get_id_email("TOKEN") 178 | ``` 179 | """ 180 | try: 181 | profile = await self.get_profile(token) 182 | except GetProfileError as e: 183 | raise GetIdEmailError(response=e.response) from e 184 | 185 | id = profile["id"] 186 | email = profile.get("email") 187 | 188 | # No public email, make a separate call to /user/emails 189 | if email is None: 190 | try: 191 | emails = await self.get_emails(token) 192 | except GetProfileError as e: 193 | raise GetIdEmailError(response=e.response) from e 194 | 195 | # Use the primary email if it exists, otherwise the first 196 | email = next( 197 | (e["email"] for e in emails if e.get("primary")), emails[0]["email"] 198 | ) 199 | 200 | return str(id), email 201 | -------------------------------------------------------------------------------- /httpx_oauth/clients/google.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal, Optional, TypedDict, cast 2 | 3 | from httpx_oauth.exceptions import GetIdEmailError, GetProfileError 4 | from httpx_oauth.oauth2 import BaseOAuth2 5 | 6 | AUTHORIZE_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth" 7 | ACCESS_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" 8 | REVOKE_TOKEN_ENDPOINT = "https://accounts.google.com/o/oauth2/revoke" 9 | BASE_SCOPES = [ 10 | "https://www.googleapis.com/auth/userinfo.profile", 11 | "https://www.googleapis.com/auth/userinfo.email", 12 | ] 13 | PROFILE_ENDPOINT = "https://people.googleapis.com/v1/people/me" 14 | 15 | 16 | LOGO_SVG = """ 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | """ 26 | 27 | 28 | class GoogleOAuth2AuthorizeParams(TypedDict, total=False): 29 | access_type: Literal["online", "offline"] 30 | include_granted_scopes: bool 31 | login_hint: str 32 | prompt: Literal["none", "consent", "select_account"] 33 | 34 | 35 | class GoogleOAuth2(BaseOAuth2[GoogleOAuth2AuthorizeParams]): 36 | """OAuth2 client for Google.""" 37 | 38 | display_name = "Google" 39 | logo_svg = LOGO_SVG 40 | 41 | def __init__( 42 | self, 43 | client_id: str, 44 | client_secret: str, 45 | scopes: Optional[list[str]] = BASE_SCOPES, 46 | name: str = "google", 47 | ): 48 | """ 49 | Args: 50 | client_id: The client ID provided by the OAuth2 provider. 51 | client_secret: The client secret provided by the OAuth2 provider. 52 | scopes: The default scopes to be used in the authorization URL. 53 | name: A unique name for the OAuth2 client. 54 | """ 55 | super().__init__( 56 | client_id, 57 | client_secret, 58 | AUTHORIZE_ENDPOINT, 59 | ACCESS_TOKEN_ENDPOINT, 60 | ACCESS_TOKEN_ENDPOINT, 61 | REVOKE_TOKEN_ENDPOINT, 62 | name=name, 63 | base_scopes=scopes, 64 | token_endpoint_auth_method="client_secret_post", 65 | revocation_endpoint_auth_method="client_secret_post", 66 | ) 67 | 68 | async def get_profile(self, token: str) -> dict[str, Any]: 69 | async with self.get_httpx_client() as client: 70 | response = await client.get( 71 | PROFILE_ENDPOINT, 72 | params={"personFields": "emailAddresses"}, 73 | headers={**self.request_headers, "Authorization": f"Bearer {token}"}, 74 | ) 75 | 76 | if response.status_code >= 400: 77 | raise GetProfileError(response=response) 78 | 79 | return cast(dict[str, Any], response.json()) 80 | 81 | async def get_id_email(self, token: str) -> tuple[str, Optional[str]]: 82 | try: 83 | profile = await self.get_profile(token) 84 | except GetProfileError as e: 85 | raise GetIdEmailError(response=e.response) from e 86 | 87 | user_id = profile["resourceName"] 88 | user_email = next( 89 | email["value"] 90 | for email in profile["emailAddresses"] 91 | if email["metadata"]["primary"] 92 | ) 93 | 94 | return user_id, user_email 95 | -------------------------------------------------------------------------------- /httpx_oauth/clients/kakao.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, Optional, cast 3 | 4 | from httpx_oauth.exceptions import GetIdEmailError, GetProfileError 5 | from httpx_oauth.oauth2 import BaseOAuth2 6 | 7 | AUTHORIZE_ENDPOINT = "https://kauth.kakao.com/oauth/authorize" 8 | ACCESS_TOKEN_ENDPOINT = "https://kauth.kakao.com/oauth/token" 9 | REFRESH_TOKEN_ENDPOINT = ACCESS_TOKEN_ENDPOINT 10 | REVOKE_TOKEN_ENDPOINT = "https://kapi.kakao.com/v1/user/unlink" 11 | PROFILE_ENDPOINT = "https://kapi.kakao.com/v2/user/me" 12 | BASE_SCOPES = ["profile_nickname", "account_email"] 13 | PROFILE_PROPERTIES = ["kakao_account.email"] 14 | 15 | LOGO_SVG = """ 16 | 17 | 18 | 19 | """ 20 | 21 | 22 | class KakaoOAuth2(BaseOAuth2[dict[str, Any]]): 23 | """OAuth2 client for Kakao.""" 24 | 25 | display_name = "Kakao" 26 | logo_svg = LOGO_SVG 27 | 28 | def __init__( 29 | self, 30 | client_id: str, 31 | client_secret: str, 32 | scopes: Optional[list[str]] = BASE_SCOPES, 33 | name: str = "kakao", 34 | ): 35 | """ 36 | Args: 37 | client_id: The client ID provided by the OAuth2 provider. 38 | client_secret: The client secret provided by the OAuth2 provider. 39 | scopes: The default scopes to be used in the authorization URL. 40 | name: A unique name for the OAuth2 client. 41 | """ 42 | super().__init__( 43 | client_id, 44 | client_secret, 45 | AUTHORIZE_ENDPOINT, 46 | ACCESS_TOKEN_ENDPOINT, 47 | refresh_token_endpoint=REFRESH_TOKEN_ENDPOINT, 48 | revoke_token_endpoint=REVOKE_TOKEN_ENDPOINT, 49 | name=name, 50 | base_scopes=scopes, 51 | token_endpoint_auth_method="client_secret_post", 52 | revocation_endpoint_auth_method="client_secret_post", 53 | ) 54 | 55 | async def get_profile(self, token: str) -> dict[str, Any]: 56 | async with self.get_httpx_client() as client: 57 | response = await client.post( 58 | PROFILE_ENDPOINT, 59 | params={"property_keys": json.dumps(PROFILE_PROPERTIES)}, 60 | headers={**self.request_headers, "Authorization": f"Bearer {token}"}, 61 | ) 62 | 63 | if response.status_code >= 400: 64 | raise GetProfileError(response=response) 65 | 66 | return cast(dict[str, Any], response.json()) 67 | 68 | async def get_id_email(self, token: str) -> tuple[str, Optional[str]]: 69 | try: 70 | profile = await self.get_profile(token) 71 | except GetProfileError as e: 72 | raise GetIdEmailError(response=e.response) from e 73 | 74 | account_id = str(profile["id"]) 75 | email = profile["kakao_account"].get("email") 76 | return account_id, email 77 | -------------------------------------------------------------------------------- /httpx_oauth/clients/linkedin.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, cast 2 | 3 | from httpx_oauth.exceptions import GetIdEmailError, GetProfileError 4 | from httpx_oauth.oauth2 import BaseOAuth2, OAuth2Token 5 | 6 | AUTHORIZE_ENDPOINT = "https://www.linkedin.com/oauth/v2/authorization" 7 | ACCESS_TOKEN_ENDPOINT = "https://www.linkedin.com/oauth/v2/accessToken" 8 | BASE_SCOPES = ["r_emailaddress", "r_liteprofile", "r_basicprofile"] 9 | PROFILE_ENDPOINT = "https://api.linkedin.com/v2/me" 10 | EMAIL_ENDPOINT = "https://api.linkedin.com/v2/emailAddress" 11 | 12 | 13 | LOGO_SVG = """ 14 | 15 | 16 | 17 | 18 | 19 | """ 20 | 21 | 22 | class LinkedInOAuth2(BaseOAuth2[dict[str, Any]]): 23 | """OAuth2 client for LinkedIn.""" 24 | 25 | display_name = "LinkedIn" 26 | logo_svg = LOGO_SVG 27 | 28 | def __init__( 29 | self, 30 | client_id: str, 31 | client_secret: str, 32 | scopes: Optional[list[str]] = BASE_SCOPES, 33 | name: str = "linkedin", 34 | ): 35 | """ 36 | Args: 37 | client_id: The client ID provided by the OAuth2 provider. 38 | client_secret: The client secret provided by the OAuth2 provider. 39 | scopes: The default scopes to be used in the authorization URL. 40 | name: A unique name for the OAuth2 client. 41 | """ 42 | super().__init__( 43 | client_id, 44 | client_secret, 45 | AUTHORIZE_ENDPOINT, 46 | ACCESS_TOKEN_ENDPOINT, 47 | ACCESS_TOKEN_ENDPOINT, 48 | name=name, 49 | base_scopes=scopes, 50 | token_endpoint_auth_method="client_secret_post", 51 | ) 52 | 53 | async def refresh_token(self, refresh_token: str) -> OAuth2Token: 54 | """ 55 | Requests a new access token using a refresh token. 56 | 57 | !!! warning 58 | Only available for [selected partners](https://docs.microsoft.com/en-us/linkedin/shared/authentication/programmatic-refresh-tokens). 59 | 60 | Args: 61 | refresh_token: The refresh token. 62 | 63 | Returns: 64 | An access token response dictionary. 65 | 66 | Raises: 67 | RefreshTokenError: An error occurred while refreshing the token. 68 | RefreshTokenNotSupportedError: The provider does not support token refresh. 69 | 70 | Examples: 71 | ```py 72 | access_token = await client.refresh_token("REFRESH_TOKEN") 73 | ``` 74 | """ 75 | return await super().refresh_token(refresh_token) # pragma: no cover 76 | 77 | async def get_profile(self, token: str) -> dict[str, Any]: 78 | async with self.get_httpx_client() as client: 79 | response = await client.get( 80 | PROFILE_ENDPOINT, 81 | headers={"Authorization": f"Bearer {token}"}, 82 | params={"projection": "(id)"}, 83 | ) 84 | 85 | if response.status_code >= 400: 86 | raise GetProfileError(response=response) 87 | 88 | return cast(dict[str, Any], response.json()) 89 | 90 | async def get_email(self, token: str) -> dict[str, Any]: 91 | async with self.get_httpx_client() as client: 92 | response = await client.get( 93 | EMAIL_ENDPOINT, 94 | headers={"Authorization": f"Bearer {token}"}, 95 | params={"q": "members", "projection": "(elements*(handle~))"}, 96 | ) 97 | 98 | if response.status_code >= 400: 99 | raise GetProfileError(response=response) 100 | 101 | return cast(dict[str, Any], response.json()) 102 | 103 | async def get_id_email(self, token: str) -> tuple[str, Optional[str]]: 104 | try: 105 | profile = await self.get_profile(token) 106 | email = await self.get_email(token) 107 | except GetProfileError as e: 108 | raise GetIdEmailError(response=e.response) from e 109 | 110 | user_id = profile["id"] 111 | user_email = email["elements"][0]["handle~"]["emailAddress"] 112 | 113 | return user_id, user_email 114 | -------------------------------------------------------------------------------- /httpx_oauth/clients/microsoft.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, cast 2 | 3 | from httpx_oauth.exceptions import GetIdEmailError, GetProfileError 4 | from httpx_oauth.oauth2 import BaseOAuth2 5 | 6 | AUTHORIZE_ENDPOINT = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize" 7 | ACCESS_TOKEN_ENDPOINT = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" 8 | BASE_SCOPES = ["User.Read"] 9 | PROFILE_ENDPOINT = "https://graph.microsoft.com/v1.0/me" 10 | 11 | 12 | LOGO_SVG = """ 13 | 14 | 15 | 16 | 17 | 18 | 19 | """ 20 | 21 | 22 | class MicrosoftGraphOAuth2(BaseOAuth2[dict[str, Any]]): 23 | """OAuth2 client for Microsoft Graph API.""" 24 | 25 | display_name = "Microsoft" 26 | logo_svg = LOGO_SVG 27 | 28 | def __init__( 29 | self, 30 | client_id: str, 31 | client_secret: str, 32 | tenant: str = "common", 33 | scopes: Optional[list[str]] = BASE_SCOPES, 34 | name: str = "microsoft", 35 | ): 36 | """ 37 | Args: 38 | client_id: The client ID provided by the OAuth2 provider. 39 | client_secret: The client secret provided by the OAuth2 provider. 40 | tenant: The tenant to use for the authorization URL. 41 | scopes: The default scopes to be used in the authorization URL. 42 | name: A unique name for the OAuth2 client. 43 | """ 44 | access_token_endpoint = ACCESS_TOKEN_ENDPOINT.format(tenant=tenant) 45 | super().__init__( 46 | client_id, 47 | client_secret, 48 | AUTHORIZE_ENDPOINT.format(tenant=tenant), 49 | access_token_endpoint, 50 | access_token_endpoint, 51 | name=name, 52 | base_scopes=scopes, 53 | token_endpoint_auth_method="client_secret_post", 54 | ) 55 | 56 | def get_authorization_url( 57 | self, redirect_uri, state=None, scope=None, extras_params=None 58 | ): 59 | if extras_params is None: 60 | extras_params = {} 61 | extras_params["response_mode"] = "query" 62 | return super().get_authorization_url( 63 | redirect_uri, state=state, scope=scope, extras_params=extras_params 64 | ) 65 | 66 | async def get_profile(self, token: str) -> dict[str, Any]: 67 | async with self.get_httpx_client() as client: 68 | response = await client.get( 69 | PROFILE_ENDPOINT, 70 | headers={"Authorization": f"Bearer {token}"}, 71 | ) 72 | 73 | if response.status_code >= 400: 74 | raise GetProfileError(response=response) 75 | 76 | return cast(dict[str, Any], response.json()) 77 | 78 | async def get_id_email(self, token: str) -> tuple[str, Optional[str]]: 79 | try: 80 | profile = await self.get_profile(token) 81 | except GetProfileError as e: 82 | raise GetIdEmailError(response=e.response) from e 83 | 84 | return profile["id"], profile["userPrincipalName"] 85 | -------------------------------------------------------------------------------- /httpx_oauth/clients/naver.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, cast 2 | 3 | from httpx_oauth.exceptions import GetIdEmailError, GetProfileError 4 | from httpx_oauth.oauth2 import BaseOAuth2, RevokeTokenError 5 | 6 | AUTHORIZE_ENDPOINT = "https://nid.naver.com/oauth2.0/authorize" 7 | ACCESS_TOKEN_ENDPOINT = "https://nid.naver.com/oauth2.0/token" 8 | REFRESH_TOKEN_ENDPOINT = ACCESS_TOKEN_ENDPOINT 9 | REVOKE_TOKEN_ENDPOINT = ACCESS_TOKEN_ENDPOINT 10 | PROFILE_ENDPOINT = "https://openapi.naver.com/v1/nid/me" 11 | BASE_SCOPES: list[str] = [] 12 | 13 | LOGO_SVG = """ 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | """ 23 | 24 | 25 | class NaverOAuth2(BaseOAuth2[dict[str, Any]]): 26 | """OAuth2 client for Naver.""" 27 | 28 | display_name = "Naver" 29 | logo_svg = LOGO_SVG 30 | 31 | def __init__( 32 | self, 33 | client_id: str, 34 | client_secret: str, 35 | scopes: Optional[list[str]] = BASE_SCOPES, 36 | name: str = "naver", 37 | ): 38 | """ 39 | Args: 40 | client_id: The client ID provided by the OAuth2 provider. 41 | client_secret: The client secret provided by the OAuth2 provider. 42 | scopes: The default scopes to be used in the authorization URL. 43 | name: A unique name for the OAuth2 client. 44 | """ 45 | super().__init__( 46 | client_id, 47 | client_secret, 48 | AUTHORIZE_ENDPOINT, 49 | ACCESS_TOKEN_ENDPOINT, 50 | refresh_token_endpoint=REFRESH_TOKEN_ENDPOINT, 51 | revoke_token_endpoint=REVOKE_TOKEN_ENDPOINT, 52 | name=name, 53 | base_scopes=scopes, 54 | token_endpoint_auth_method="client_secret_post", 55 | revocation_endpoint_auth_method="client_secret_post", 56 | ) 57 | 58 | async def revoke_token( 59 | self, token: str, token_type_hint: Optional[str] = None 60 | ) -> None: 61 | assert self.revoke_token_endpoint is not None 62 | async with self.get_httpx_client() as client: 63 | data = { 64 | "grant_type": "delete", 65 | "access_token": token, 66 | "service_provider": "NAVER", 67 | } 68 | 69 | if token_type_hint is not None: 70 | data["token_type_hint"] = token_type_hint 71 | 72 | request, auth = self.build_request( 73 | client, 74 | "POST", 75 | self.revoke_token_endpoint, 76 | auth_method=self.token_endpoint_auth_method, 77 | data=data, 78 | ) 79 | await self.send_request(client, request, auth, exc_class=RevokeTokenError) 80 | 81 | return None 82 | 83 | async def get_profile(self, token: str) -> dict[str, Any]: 84 | async with self.get_httpx_client() as client: 85 | response = await client.post( 86 | PROFILE_ENDPOINT, 87 | headers={**self.request_headers, "Authorization": f"Bearer {token}"}, 88 | ) 89 | 90 | if response.status_code >= 400: 91 | raise GetProfileError(response=response) 92 | 93 | json = response.json() 94 | return cast(dict[str, Any], json["response"]) 95 | 96 | async def get_id_email(self, token: str) -> tuple[str, Optional[str]]: 97 | try: 98 | profile = await self.get_profile(token) 99 | except GetProfileError as e: 100 | raise GetIdEmailError(response=e.response) from e 101 | 102 | return profile["id"], profile.get("email") 103 | -------------------------------------------------------------------------------- /httpx_oauth/clients/okta.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from httpx_oauth.clients.openid import OpenID 4 | 5 | BASE_SCOPES = ["openid", "email"] 6 | 7 | 8 | class OktaOAuth2(OpenID): 9 | """OAuth2 client for Okta.""" 10 | 11 | def __init__( 12 | self, 13 | client_id: str, 14 | client_secret: str, 15 | okta_domain: str, 16 | scopes: Optional[list[str]] = BASE_SCOPES, 17 | name: str = "okta", 18 | ): 19 | """ 20 | Args: 21 | client_id: The client ID provided by the OAuth2 provider. 22 | client_secret: The client secret provided by the OAuth2 provider. 23 | okta_domain: The Okta organization domain. 24 | scopes: The default scopes to be used in the authorization URL. 25 | name: A unique name for the OAuth2 client. 26 | """ 27 | super().__init__( 28 | client_id, 29 | client_secret, 30 | f"https://{okta_domain}/.well-known/openid-configuration", 31 | name=name, 32 | base_scopes=scopes, 33 | ) 34 | -------------------------------------------------------------------------------- /httpx_oauth/clients/openid.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, get_args 2 | 3 | import httpx 4 | 5 | from httpx_oauth.exceptions import GetIdEmailError, GetProfileError 6 | from httpx_oauth.oauth2 import BaseOAuth2, OAuth2ClientAuthMethod, OAuth2RequestError 7 | 8 | BASE_SCOPES = ["openid", "email"] 9 | 10 | 11 | class OpenIDConfigurationError(OAuth2RequestError): 12 | """ 13 | Raised when an error occurred while fetching the OpenID configuration. 14 | """ 15 | 16 | 17 | class OpenID(BaseOAuth2[dict[str, Any]]): 18 | """ 19 | Generic client for providers following the [OpenID Connect protocol](https://openid.net/connect/). 20 | 21 | Besides the Client ID and the Client Secret, you'll have to provide the OpenID configuration endpoint, allowing the client to discover the required endpoints automatically. By convention, it's usually served under the path `.well-known/openid-configuration`. 22 | """ 23 | 24 | def __init__( 25 | self, 26 | client_id: str, 27 | client_secret: str, 28 | openid_configuration_endpoint: str, 29 | name: str = "openid", 30 | base_scopes: Optional[list[str]] = BASE_SCOPES, 31 | ): 32 | """ 33 | Args: 34 | client_id: The client ID provided by the OAuth2 provider. 35 | client_secret: The client secret provided by the OAuth2 provider. 36 | openid_configuration_endpoint: OpenID Connect discovery endpoint URL. 37 | name: A unique name for the OAuth2 client. 38 | base_scopes: The base scopes to be used in the authorization URL. 39 | 40 | Raises: 41 | OpenIDConfigurationError: 42 | An error occurred while fetching the OpenID configuration. 43 | 44 | Examples: 45 | ```py 46 | from httpx_oauth.clients.openid import OpenID 47 | 48 | client = OpenID("CLIENT_ID", "CLIENT_SECRET", "https://example.fief.dev/.well-known/openid-configuration") 49 | `` 50 | """ 51 | with httpx.Client() as client: 52 | try: 53 | response = client.get(openid_configuration_endpoint) 54 | response.raise_for_status() 55 | except httpx.HTTPStatusError as e: 56 | raise OpenIDConfigurationError(str(e), e.response) from e 57 | except httpx.HTTPError as e: 58 | raise OpenIDConfigurationError(str(e)) from e 59 | self.openid_configuration: dict[str, Any] = response.json() 60 | 61 | token_endpoint = self.openid_configuration["token_endpoint"] 62 | refresh_token_supported = "refresh_token" in self.openid_configuration.get( 63 | "grant_types_supported", [] 64 | ) 65 | revocation_endpoint = self.openid_configuration.get("revocation_endpoint") 66 | token_endpoint_auth_methods_supported = self.openid_configuration.get( 67 | "token_endpoint_auth_methods_supported", ["client_secret_basic"] 68 | ) 69 | revocation_endpoint_auth_methods_supported = self.openid_configuration.get( 70 | "revocation_endpoint_auth_methods_supported", ["client_secret_basic"] 71 | ) 72 | 73 | supported_auth_methods = get_args(OAuth2ClientAuthMethod) 74 | # check if there is any supported and select the first one 75 | token_endpoint_auth_methods_supported = [ 76 | method 77 | for method in token_endpoint_auth_methods_supported 78 | if method in supported_auth_methods 79 | ] 80 | revocation_endpoint_auth_methods_supported = [ 81 | method 82 | for method in revocation_endpoint_auth_methods_supported 83 | if method in supported_auth_methods 84 | ] 85 | 86 | super().__init__( 87 | client_id, 88 | client_secret, 89 | self.openid_configuration["authorization_endpoint"], 90 | token_endpoint, 91 | token_endpoint if refresh_token_supported else None, 92 | revocation_endpoint, 93 | name=name, 94 | base_scopes=base_scopes, 95 | token_endpoint_auth_method=token_endpoint_auth_methods_supported[0], 96 | revocation_endpoint_auth_method=( 97 | revocation_endpoint_auth_methods_supported[0] 98 | if revocation_endpoint 99 | else None 100 | ), 101 | ) 102 | 103 | async def get_profile(self, token: str) -> dict[str, Any]: 104 | async with self.get_httpx_client() as client: 105 | response = await client.get( 106 | self.openid_configuration["userinfo_endpoint"], 107 | headers={**self.request_headers, "Authorization": f"Bearer {token}"}, 108 | ) 109 | 110 | if response.status_code >= 400: 111 | raise GetProfileError(response=response) 112 | 113 | return response.json() 114 | 115 | async def get_id_email(self, token: str) -> tuple[str, Optional[str]]: 116 | try: 117 | profile = await self.get_profile(token) 118 | except GetProfileError as e: 119 | raise GetIdEmailError(response=e.response) from e 120 | 121 | return str(profile["sub"]), profile.get("email") 122 | -------------------------------------------------------------------------------- /httpx_oauth/clients/reddit.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, cast 2 | 3 | import httpx 4 | 5 | from httpx_oauth.exceptions import GetIdEmailError, GetProfileError 6 | from httpx_oauth.oauth2 import ( 7 | BaseOAuth2, 8 | GetAccessTokenError, 9 | OAuth2Token, 10 | ) 11 | 12 | AUTHORIZE_ENDPOINT = "https://www.reddit.com/api/v1/authorize" 13 | ACCESS_TOKEN_ENDPOINT = "https://www.reddit.com/api/v1/access_token" 14 | REFRESH_ENDPOINT = ACCESS_TOKEN_ENDPOINT 15 | REVOKE_ENDPOINT = "https://www.reddit.com/api/v1/revoke_token" 16 | IDENTITY_ENDPOINT = "https://oauth.reddit.com/api/v1/me" 17 | 18 | BASE_SCOPES = ["identity"] 19 | 20 | 21 | LOGO_SVG = """ 22 | 23 | 24 | 25 | 26 | 27 | 28 | """ 29 | 30 | 31 | class RedditOAuth2(BaseOAuth2[dict[str, Any]]): 32 | """OAuth2 client for Reddit.""" 33 | 34 | display_name = "Reddit" 35 | logo_svg = LOGO_SVG 36 | 37 | def __init__( 38 | self, 39 | client_id: str, 40 | client_secret: str, 41 | scopes: Optional[list[str]] = None, 42 | name: str = "reddit", 43 | ): 44 | """ 45 | Args: 46 | client_id: The client ID provided by the OAuth2 provider. 47 | client_secret: The client secret provided by the OAuth2 provider. 48 | scopes: The default scopes to be used in the authorization URL. 49 | name: A unique name for the OAuth2 client. 50 | """ 51 | if scopes is None: 52 | scopes = BASE_SCOPES 53 | 54 | super().__init__( 55 | client_id, 56 | client_secret, 57 | AUTHORIZE_ENDPOINT, 58 | ACCESS_TOKEN_ENDPOINT, 59 | REFRESH_ENDPOINT, 60 | REVOKE_ENDPOINT, 61 | name=name, 62 | base_scopes=scopes, 63 | token_endpoint_auth_method="client_secret_basic", 64 | revocation_endpoint_auth_method="client_secret_basic", 65 | ) 66 | 67 | async def get_access_token( 68 | self, code: str, redirect_uri: str, code_verifier: Optional[str] = None 69 | ) -> OAuth2Token: 70 | oauth2_token = await super().get_access_token(code, redirect_uri, code_verifier) 71 | 72 | if "error" in oauth2_token: 73 | raise GetAccessTokenError(oauth2_token["error"]) 74 | 75 | return oauth2_token 76 | 77 | async def get_profile(self, token: str) -> dict[str, Any]: 78 | async with self.get_httpx_client() as client: 79 | response = await client.get( 80 | IDENTITY_ENDPOINT, 81 | headers={**self.request_headers, "Authorization": f"Bearer {token}"}, 82 | ) 83 | 84 | if response.status_code != httpx.codes.OK: 85 | raise GetProfileError(response=response) 86 | 87 | return cast(dict[str, Any], response.json()) 88 | 89 | async def get_id_email(self, token: str) -> tuple[str, Optional[str]]: 90 | try: 91 | profile = await self.get_profile(token) 92 | except GetProfileError as e: 93 | raise GetIdEmailError(response=e.response) from e 94 | 95 | return profile["name"], None 96 | -------------------------------------------------------------------------------- /httpx_oauth/clients/shopify.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal, Optional, TypedDict, cast 2 | 3 | from httpx_oauth.exceptions import GetIdEmailError, GetProfileError 4 | from httpx_oauth.oauth2 import BaseOAuth2 5 | 6 | AUTHORIZE_ENDPOINT = "https://{shop}.myshopify.com/admin/oauth/authorize" 7 | ACCESS_TOKEN_ENDPOINT = "https://{shop}.myshopify.com/admin/oauth/access_token" 8 | BASE_SCOPES = ["read_orders"] 9 | PROFILE_ENDPOINT = "https://{shop}.myshopify.com/admin/api/{api_version}/shop.json" 10 | 11 | 12 | LOGO_SVG = """ 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | """ 21 | 22 | 23 | class ShopifyOAuth2AuthorizeParams(TypedDict, total=False): 24 | access_mode: Optional[Literal["per-user"]] 25 | 26 | 27 | class ShopifyOAuth2(BaseOAuth2[ShopifyOAuth2AuthorizeParams]): 28 | """ 29 | OAuth2 client for Shopify. 30 | 31 | The OAuth2 client for Shopify authenticates shop owners to allow making calls 32 | to the [Shopify Admin API](https://shopify.dev/docs/api/admin). 33 | """ 34 | 35 | display_name = "Shopify" 36 | logo_svg = LOGO_SVG 37 | 38 | def __init__( 39 | self, 40 | client_id: str, 41 | client_secret: str, 42 | shop: str, 43 | scopes: Optional[list[str]] = BASE_SCOPES, 44 | api_version: str = "2023-04", 45 | name: str = "shopify", 46 | ): 47 | """ 48 | Args: 49 | client_id: The client ID provided by the OAuth2 provider. 50 | client_secret: The client secret provided by the OAuth2 provider. 51 | shop: The shop subdomain. 52 | scopes: The default scopes to be used in the authorization URL. 53 | api_version: The version of the Shopify Admin API. 54 | name: A unique name for the OAuth2 client. 55 | """ 56 | authorize_endpoint = AUTHORIZE_ENDPOINT.format(shop=shop) 57 | access_token_endpoint = ACCESS_TOKEN_ENDPOINT.format(shop=shop) 58 | self.profile_endpoint = PROFILE_ENDPOINT.format( 59 | shop=shop, api_version=api_version 60 | ) 61 | super().__init__( 62 | client_id, 63 | client_secret, 64 | authorize_endpoint, 65 | access_token_endpoint, 66 | name=name, 67 | base_scopes=scopes, 68 | token_endpoint_auth_method="client_secret_post", 69 | ) 70 | 71 | async def get_profile(self, token: str) -> dict[str, Any]: 72 | """ 73 | Returns the profile of the authenticated user from the API provider. 74 | 75 | !!! warning "`get_profile` is based on the `Shop` resource" 76 | The implementation of `get_profile` calls the [Get Shop endpoint](https://shopify.dev/docs/api/admin-rest/2023-04/resources/shop#get-shop) of the Shopify Admin API. 77 | It means that it'll return you the **profile of the shop**. 78 | 79 | Args: 80 | token: The access token. 81 | 82 | Returns: 83 | The profile of the authenticated shop. 84 | 85 | Raises: 86 | httpx_oauth.exceptions.GetProfileError: 87 | An error occurred while getting the profile 88 | 89 | Examples: 90 | ```py 91 | profile = await client.get_profile("TOKEN") 92 | ``` 93 | """ 94 | async with self.get_httpx_client() as client: 95 | response = await client.get( 96 | self.profile_endpoint, 97 | headers={"X-Shopify-Access-Token": token}, 98 | ) 99 | 100 | if response.status_code >= 400: 101 | raise GetProfileError(response=response) 102 | 103 | return cast(dict[str, Any], response.json()) 104 | 105 | async def get_id_email(self, token: str) -> tuple[str, Optional[str]]: 106 | """ 107 | Returns the id and the email (if available) of the authenticated user 108 | from the API provider. 109 | 110 | !!! warning "`get_id_email` is based on the `Shop` resource" 111 | The implementation of `get_id_email` calls the [Get Shop endpoint](https://shopify.dev/docs/api/admin-rest/2023-04/resources/shop#get-shop) of the Shopify Admin API. 112 | It means that it'll return you the **ID of the shop** and the **email of the shop owner**. 113 | 114 | Args: 115 | token: The access token. 116 | 117 | Returns: 118 | A tuple with the id and the email of the authenticated user. 119 | 120 | 121 | Raises: 122 | httpx_oauth.exceptions.GetIdEmailError: 123 | An error occurred while getting the id and email. 124 | 125 | Examples: 126 | ```py 127 | user_id, user_email = await client.get_id_email("TOKEN") 128 | ``` 129 | """ 130 | try: 131 | profile = await self.get_profile(token) 132 | except GetProfileError as e: 133 | raise GetIdEmailError(response=e.response) from e 134 | 135 | shop = profile["shop"] 136 | return str(shop["id"]), shop["email"] 137 | -------------------------------------------------------------------------------- /httpx_oauth/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | import httpx 4 | 5 | 6 | class HTTPXOAuthError(Exception): 7 | """Base exception class for every httpx-oauth errors.""" 8 | 9 | message: str 10 | 11 | def __init__(self, message: str) -> None: 12 | self.message = message 13 | super().__init__(message) 14 | 15 | 16 | class GetProfileError(HTTPXOAuthError): 17 | """Error raised while retrieving user profile from provider API.""" 18 | 19 | def __init__( 20 | self, 21 | message: str = "Error while retrieving user profile.", 22 | response: Union[httpx.Response, None] = None, 23 | ) -> None: 24 | self.response = response 25 | super().__init__(message) 26 | 27 | 28 | class GetIdEmailError(GetProfileError): 29 | """Error raised while retrieving id and email from provider API.""" 30 | 31 | def __init__( 32 | self, 33 | message: str = "Error while retrieving id and email.", 34 | response: Union[httpx.Response, None] = None, 35 | ) -> None: 36 | super().__init__(message, response) 37 | -------------------------------------------------------------------------------- /httpx_oauth/integrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankie567/httpx-oauth/003d13e833f412bc849ced7ed0890d4a4647ff84/httpx_oauth/integrations/__init__.py -------------------------------------------------------------------------------- /httpx_oauth/integrations/fastapi.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, Union 2 | 3 | import httpx 4 | from fastapi import HTTPException 5 | from starlette import status 6 | from starlette.requests import Request 7 | 8 | from httpx_oauth.oauth2 import BaseOAuth2, GetAccessTokenError, OAuth2Error, OAuth2Token 9 | 10 | 11 | class OAuth2AuthorizeCallbackError(HTTPException, OAuth2Error): 12 | """ 13 | Error raised when an error occurs during the OAuth2 authorization callback. 14 | 15 | It inherits from [HTTPException][fastapi.HTTPException], so you can either keep 16 | the default FastAPI error handling or implement a 17 | [dedicated exception handler](https://fastapi.tiangolo.com/tutorial/handling-errors/#install-custom-exception-handlers). 18 | """ 19 | 20 | def __init__( 21 | self, 22 | status_code: int, 23 | detail: Any = None, 24 | headers: Union[dict[str, str], None] = None, 25 | response: Union[httpx.Response, None] = None, 26 | ) -> None: 27 | self.response = response 28 | super().__init__(status_code, detail, headers) 29 | 30 | 31 | class OAuth2AuthorizeCallback: 32 | """ 33 | Dependency callable to handle the authorization callback. It reads the query parameters and returns the access token and the state. 34 | 35 | Examples: 36 | ```py 37 | from fastapi import FastAPI, Depends 38 | from httpx_oauth.integrations.fastapi import OAuth2AuthorizeCallback 39 | from httpx_oauth.oauth2 import OAuth2 40 | 41 | client = OAuth2("CLIENT_ID", "CLIENT_SECRET", "AUTHORIZE_ENDPOINT", "ACCESS_TOKEN_ENDPOINT") 42 | oauth2_authorize_callback = OAuth2AuthorizeCallback(client, "oauth-callback") 43 | app = FastAPI() 44 | 45 | @app.get("/oauth-callback", name="oauth-callback") 46 | async def oauth_callback(access_token_state=Depends(oauth2_authorize_callback)): 47 | token, state = access_token_state 48 | # Do something useful 49 | ``` 50 | """ 51 | 52 | client: BaseOAuth2 53 | route_name: Optional[str] 54 | redirect_url: Optional[str] 55 | 56 | def __init__( 57 | self, 58 | client: BaseOAuth2, 59 | route_name: Optional[str] = None, 60 | redirect_url: Optional[str] = None, 61 | ): 62 | """ 63 | Args: 64 | client: An [OAuth2][httpx_oauth.oauth2.BaseOAuth2] client. 65 | route_name: Name of the callback route, as defined in the `name` parameter of the route decorator. 66 | redirect_url: Full URL to the callback route. 67 | """ 68 | assert (route_name is not None and redirect_url is None) or ( 69 | route_name is None and redirect_url is not None 70 | ), "You should either set route_name or redirect_url" 71 | self.client = client 72 | self.route_name = route_name 73 | self.redirect_url = redirect_url 74 | 75 | async def __call__( 76 | self, 77 | request: Request, 78 | code: Optional[str] = None, 79 | code_verifier: Optional[str] = None, 80 | state: Optional[str] = None, 81 | error: Optional[str] = None, 82 | ) -> tuple[OAuth2Token, Optional[str]]: 83 | if code is None or error is not None: 84 | raise OAuth2AuthorizeCallbackError( 85 | status_code=status.HTTP_400_BAD_REQUEST, 86 | detail=error if error is not None else None, 87 | ) 88 | 89 | if self.route_name: 90 | redirect_url = str(request.url_for(self.route_name)) 91 | elif self.redirect_url: 92 | redirect_url = self.redirect_url 93 | 94 | try: 95 | access_token = await self.client.get_access_token( 96 | code, redirect_url, code_verifier 97 | ) 98 | except GetAccessTokenError as e: 99 | raise OAuth2AuthorizeCallbackError( 100 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 101 | detail=e.message, 102 | response=e.response, 103 | ) from e 104 | 105 | return access_token, state 106 | -------------------------------------------------------------------------------- /httpx_oauth/oauth2.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import json 3 | import time 4 | from collections.abc import Mapping 5 | from typing import ( 6 | Any, 7 | Generic, 8 | Literal, 9 | Optional, 10 | TypeVar, 11 | Union, 12 | cast, 13 | get_args, 14 | ) 15 | from urllib.parse import urlencode 16 | 17 | import httpx 18 | 19 | from httpx_oauth.exceptions import HTTPXOAuthError 20 | 21 | 22 | class OAuth2Error(HTTPXOAuthError): 23 | """Base exception class for OAuth2 client errors.""" 24 | 25 | pass 26 | 27 | 28 | class NotSupportedAuthMethodError(OAuth2Error): 29 | """Error raised when an unsupported authentication method is used.""" 30 | 31 | def __init__(self, auth_method: str): 32 | super().__init__(f"Auth method {auth_method} is not supported.") 33 | 34 | 35 | class MissingRevokeTokenAuthMethodError(OAuth2Error): 36 | """Error raised when the revocation endpoint auth method is missing.""" 37 | 38 | def __init__(self): 39 | super().__init__("Missing revocation endpoint auth method.") 40 | 41 | 42 | class RefreshTokenNotSupportedError(OAuth2Error): 43 | """ 44 | Error raised when trying to refresh a token 45 | on a provider that does not support it. 46 | """ 47 | 48 | def __init__(self): 49 | super().__init__("Refresh token is not supported by this provider.") 50 | 51 | 52 | class RevokeTokenNotSupportedError(OAuth2Error): 53 | """ 54 | Error raised when trying to revole a token 55 | on a provider that does not support it. 56 | """ 57 | 58 | def __init__(self): 59 | super().__init__("Revoke token is not supported by this provider.") 60 | 61 | 62 | class OAuth2RequestError(OAuth2Error): 63 | """ 64 | Base exception class for OAuth2 request errors. 65 | """ 66 | 67 | def __init__( 68 | self, message: str, response: Union[httpx.Response, None] = None 69 | ) -> None: 70 | self.response = response 71 | super().__init__(message) 72 | 73 | 74 | class GetAccessTokenError(OAuth2RequestError): 75 | """Error raised when an error occurs while getting an access token.""" 76 | 77 | 78 | class RefreshTokenError(OAuth2RequestError): 79 | """Error raised when an error occurs while refreshing a token.""" 80 | 81 | 82 | class RevokeTokenError(OAuth2RequestError): 83 | """Error raised when an error occurs while revoking a token.""" 84 | 85 | 86 | OAuth2ClientAuthMethod = Literal["client_secret_basic", "client_secret_post"] 87 | """Supported OAuth2 client authentication methods.""" 88 | 89 | 90 | def _check_valid_auth_method(auth_method: str) -> None: 91 | if auth_method not in get_args(OAuth2ClientAuthMethod): 92 | raise NotSupportedAuthMethodError(auth_method) 93 | 94 | 95 | class OAuth2Token(dict[str, Any]): 96 | """ 97 | Wrapper around a standard `Dict[str, Any]` that bears the response 98 | of a successful token request. 99 | 100 | Properties can vary greatly from a service to another but, usually, 101 | you can get access token like this: 102 | 103 | Examples: 104 | ```py 105 | access_token = token["access_token"] 106 | ``` 107 | """ 108 | 109 | def __init__(self, token_dict: dict[str, Any]): 110 | if "expires_at" in token_dict: 111 | token_dict["expires_at"] = int(token_dict["expires_at"]) 112 | elif "expires_in" in token_dict: 113 | token_dict["expires_at"] = int(time.time()) + int(token_dict["expires_in"]) 114 | super().__init__(token_dict) 115 | 116 | def is_expired(self) -> bool: 117 | """ 118 | Checks if the token is expired. 119 | 120 | Returns: 121 | True if the token is expired, False otherwise 122 | """ 123 | if "expires_at" not in self: 124 | return False 125 | return time.time() > self["expires_at"] 126 | 127 | 128 | T = TypeVar("T") 129 | 130 | 131 | class BaseOAuth2(Generic[T]): 132 | """ 133 | Base OAuth2 client. 134 | 135 | This class provides a base implementation for OAuth2 clients. If you need to use a generic client, use [OAuth2][httpx_oauth.oauth2.OAuth2] instead. 136 | """ 137 | 138 | name: str 139 | client_id: str 140 | client_secret: str 141 | authorize_endpoint: str 142 | access_token_endpoint: str 143 | refresh_token_endpoint: Optional[str] 144 | revoke_token_endpoint: Optional[str] 145 | base_scopes: Optional[list[str]] 146 | token_endpoint_auth_method: OAuth2ClientAuthMethod 147 | revocation_endpoint_auth_method: Optional[OAuth2ClientAuthMethod] 148 | request_headers: dict[str, str] 149 | 150 | def __init__( 151 | self, 152 | client_id: str, 153 | client_secret: str, 154 | authorize_endpoint: str, 155 | access_token_endpoint: str, 156 | refresh_token_endpoint: Optional[str] = None, 157 | revoke_token_endpoint: Optional[str] = None, 158 | *, 159 | name: str = "oauth2", 160 | base_scopes: Optional[list[str]] = None, 161 | token_endpoint_auth_method: OAuth2ClientAuthMethod = "client_secret_post", 162 | revocation_endpoint_auth_method: Optional[OAuth2ClientAuthMethod] = None, 163 | ): 164 | """ 165 | Args: 166 | client_id: The client ID provided by the OAuth2 provider. 167 | client_secret: The client secret provided by the OAuth2 provider. 168 | authorize_endpoint: The authorization endpoint URL. 169 | access_token_endpoint: The access token endpoint URL. 170 | refresh_token_endpoint: The refresh token endpoint URL. 171 | If not supported, set it to `None`. 172 | revoke_token_endpoint: The revoke token endpoint URL. 173 | If not supported, set it to `None`. 174 | name: A unique name for the OAuth2 client. 175 | base_scopes: The base scopes to be used in the authorization URL. 176 | token_endpoint_auth_method: The authentication method to be used in the token endpoint. 177 | revocation_endpoint_auth_method: The authentication method to be used in the revocation endpoint. 178 | If the revocation endpoint is not supported, set it to `None`. 179 | 180 | Raises: 181 | NotSupportedAuthMethodError: 182 | The provided authentication method is not supported. 183 | MissingRevokeTokenAuthMethodError: 184 | The revocation endpoint auth method is missing. 185 | """ 186 | _check_valid_auth_method(token_endpoint_auth_method) 187 | if revocation_endpoint_auth_method is not None: 188 | _check_valid_auth_method(revocation_endpoint_auth_method) 189 | if ( 190 | revoke_token_endpoint is not None 191 | and revocation_endpoint_auth_method is None 192 | ): 193 | raise MissingRevokeTokenAuthMethodError() 194 | 195 | self.client_id = client_id 196 | self.client_secret = client_secret 197 | self.authorize_endpoint = authorize_endpoint 198 | self.access_token_endpoint = access_token_endpoint 199 | self.refresh_token_endpoint = refresh_token_endpoint 200 | self.revoke_token_endpoint = revoke_token_endpoint 201 | self.name = name 202 | self.base_scopes = base_scopes 203 | self.token_endpoint_auth_method = token_endpoint_auth_method 204 | self.revocation_endpoint_auth_method = revocation_endpoint_auth_method 205 | 206 | self.request_headers = { 207 | "Accept": "application/json", 208 | } 209 | 210 | async def get_authorization_url( 211 | self, 212 | redirect_uri: str, 213 | state: Optional[str] = None, 214 | scope: Optional[list[str]] = None, 215 | code_challenge: Optional[str] = None, 216 | code_challenge_method: Optional[Literal["plain", "S256"]] = None, 217 | extras_params: Optional[T] = None, 218 | ) -> str: 219 | """ 220 | Builds the authorization URL 221 | where the user should be redirected to authorize the application. 222 | 223 | Args: 224 | redirect_uri: The URL where the user will be redirected after authorization. 225 | state: An opaque value used by the client to maintain state 226 | between the request and the callback. 227 | scope: The scopes to be requested. 228 | If not provided, `base_scopes` will be used. 229 | code_challenge: Optional 230 | [PKCE](https://datatracker.ietf.org/doc/html/rfc7636)) code challenge. 231 | code_challenge_method: Optional 232 | [PKCE](https://datatracker.ietf.org/doc/html/rfc7636)) code challenge 233 | method. 234 | extras_params: Optional extra parameters specific to the service. 235 | 236 | Returns: 237 | The authorization URL. 238 | 239 | Examples: 240 | ```py 241 | authorization_url = await client.get_authorization_url( 242 | "https://www.tintagel.bt/oauth-callback", scope=["SCOPE1", "SCOPE2", "SCOPE3"], 243 | ) 244 | ``` 245 | """ 246 | params = { 247 | "response_type": "code", 248 | "client_id": self.client_id, 249 | "redirect_uri": redirect_uri, 250 | } 251 | 252 | if state is not None: 253 | params["state"] = state 254 | 255 | # Provide compatibility with current scope from the endpoint 256 | _scope = scope or self.base_scopes 257 | if _scope is not None: 258 | params["scope"] = " ".join(_scope) 259 | 260 | if code_challenge is not None: 261 | params["code_challenge"] = code_challenge 262 | 263 | if code_challenge_method is not None: 264 | params["code_challenge_method"] = code_challenge_method 265 | 266 | if extras_params is not None: 267 | params = {**params, **extras_params} # type: ignore 268 | 269 | return f"{self.authorize_endpoint}?{urlencode(params)}" 270 | 271 | async def get_access_token( 272 | self, code: str, redirect_uri: str, code_verifier: Optional[str] = None 273 | ) -> OAuth2Token: 274 | """ 275 | Requests an access token using the authorization code obtained 276 | after the user has authorized the application. 277 | 278 | Args: 279 | code: The authorization code. 280 | redirect_uri: The URL where the user was redirected after authorization. 281 | code_verifier: Optional code verifier used 282 | in the [PKCE](https://datatracker.ietf.org/doc/html/rfc7636)) flow. 283 | 284 | Returns: 285 | An access token response dictionary. 286 | 287 | Raises: 288 | GetAccessTokenError: An error occurred while getting the access token. 289 | 290 | Examples: 291 | ```py 292 | access_token = await client.get_access_token("CODE", "https://www.tintagel.bt/oauth-callback") 293 | ``` 294 | """ 295 | async with self.get_httpx_client() as client: 296 | data = { 297 | "grant_type": "authorization_code", 298 | "code": code, 299 | "redirect_uri": redirect_uri, 300 | } 301 | 302 | if code_verifier: 303 | data["code_verifier"] = code_verifier 304 | 305 | request, auth = self.build_request( 306 | client, 307 | "POST", 308 | self.access_token_endpoint, 309 | auth_method=self.token_endpoint_auth_method, 310 | data=data, 311 | ) 312 | response = await self.send_request( 313 | client, request, auth, exc_class=GetAccessTokenError 314 | ) 315 | data = self.get_json(response, exc_class=GetAccessTokenError) 316 | return OAuth2Token(data) 317 | 318 | async def refresh_token(self, refresh_token: str) -> OAuth2Token: 319 | """ 320 | Requests a new access token using a refresh token. 321 | 322 | Args: 323 | refresh_token: The refresh token. 324 | 325 | Returns: 326 | An access token response dictionary. 327 | 328 | Raises: 329 | RefreshTokenError: An error occurred while refreshing the token. 330 | RefreshTokenNotSupportedError: The provider does not support token refresh. 331 | 332 | Examples: 333 | ```py 334 | access_token = await client.refresh_token("REFRESH_TOKEN") 335 | ``` 336 | """ 337 | if self.refresh_token_endpoint is None: 338 | raise RefreshTokenNotSupportedError() 339 | 340 | async with self.get_httpx_client() as client: 341 | request, auth = self.build_request( 342 | client, 343 | "POST", 344 | self.refresh_token_endpoint, 345 | auth_method=self.token_endpoint_auth_method, 346 | data={ 347 | "grant_type": "refresh_token", 348 | "refresh_token": refresh_token, 349 | }, 350 | ) 351 | response = await self.send_request( 352 | client, request, auth, exc_class=RefreshTokenError 353 | ) 354 | data = self.get_json(response, exc_class=RefreshTokenError) 355 | return OAuth2Token(data) 356 | 357 | async def revoke_token( 358 | self, token: str, token_type_hint: Optional[str] = None 359 | ) -> None: 360 | """ 361 | Revokes a token. 362 | 363 | Args: 364 | token: A token or refresh token to revoke. 365 | token_type_hint: Optional hint for the service to help it determine 366 | if it's a token or refresh token. 367 | Usually either `token` or `refresh_token`. 368 | 369 | Returns: 370 | None 371 | 372 | Raises: 373 | RevokeTokenError: An error occurred while revoking the token. 374 | RevokeTokenNotSupportedError: The provider does not support token revoke. 375 | """ 376 | if self.revoke_token_endpoint is None: 377 | raise RevokeTokenNotSupportedError() 378 | 379 | async with self.get_httpx_client() as client: 380 | data = {"token": token} 381 | 382 | if token_type_hint is not None: 383 | data["token_type_hint"] = token_type_hint 384 | 385 | request, auth = self.build_request( 386 | client, 387 | "POST", 388 | self.revoke_token_endpoint, 389 | auth_method=self.token_endpoint_auth_method, 390 | data=data, 391 | ) 392 | await self.send_request(client, request, auth, exc_class=RevokeTokenError) 393 | 394 | return None 395 | 396 | async def get_profile(self, token: str) -> dict[str, Any]: 397 | """ 398 | Returns the profile of the authenticated user 399 | from the API provider. 400 | 401 | **It assumes you have asked for the required scopes**. 402 | 403 | Args: 404 | token: The access token. 405 | 406 | Returns: 407 | The profile of the authenticated user. 408 | 409 | Raises: 410 | httpx_oauth.exceptions.GetProfileError: 411 | An error occurred while getting the profile. 412 | 413 | Examples: 414 | ```py 415 | profile = await client.get_profile("TOKEN") 416 | ``` 417 | """ 418 | raise NotImplementedError() 419 | 420 | async def get_id_email(self, token: str) -> tuple[str, Optional[str]]: 421 | """ 422 | Returns the id and the email (if available) of the authenticated user 423 | from the API provider. 424 | 425 | **It assumes you have asked for the required scopes**. 426 | 427 | Args: 428 | token: The access token. 429 | 430 | Returns: 431 | A tuple with the id and the email of the authenticated user. 432 | 433 | 434 | Raises: 435 | httpx_oauth.exceptions.GetIdEmailError: 436 | An error occurred while getting the id and email. 437 | 438 | Examples: 439 | ```py 440 | user_id, user_email = await client.get_id_email("TOKEN") 441 | ``` 442 | """ 443 | raise NotImplementedError() 444 | 445 | def get_httpx_client( 446 | self, 447 | ) -> contextlib.AbstractAsyncContextManager[httpx.AsyncClient]: 448 | return httpx.AsyncClient() 449 | 450 | def build_request( 451 | self, 452 | client: httpx.AsyncClient, 453 | method: str, 454 | url: str, 455 | *, 456 | auth_method: Union[OAuth2ClientAuthMethod, None] = None, 457 | data: Union[Mapping[str, Any], None] = None, 458 | ) -> tuple[httpx.Request, Union[httpx.Auth, None]]: 459 | if data is not None: 460 | data = { 461 | **data, 462 | **( 463 | { 464 | "client_id": self.client_id, 465 | "client_secret": self.client_secret, 466 | } 467 | if auth_method == "client_secret_post" 468 | else {} 469 | ), 470 | } 471 | 472 | request = client.build_request( 473 | method, 474 | url, 475 | data=data, 476 | headers=self.request_headers, 477 | ) 478 | 479 | auth = None 480 | if auth_method == "client_secret_basic": 481 | auth = httpx.BasicAuth(self.client_id, self.client_secret) 482 | 483 | return request, auth 484 | 485 | async def send_request( 486 | self, 487 | client: httpx.AsyncClient, 488 | request: httpx.Request, 489 | auth: Union[httpx.Auth, None], 490 | *, 491 | exc_class: type[OAuth2RequestError], 492 | ) -> httpx.Response: 493 | try: 494 | response = await client.send(request, auth=auth) 495 | response.raise_for_status() 496 | except httpx.HTTPStatusError as e: 497 | raise exc_class(str(e), e.response) from e 498 | except httpx.HTTPError as e: 499 | raise exc_class(str(e)) from e 500 | 501 | return response 502 | 503 | def get_json( 504 | self, response: httpx.Response, *, exc_class: type[OAuth2RequestError] 505 | ) -> dict[str, Any]: 506 | try: 507 | return cast(dict[str, Any], response.json()) 508 | except json.decoder.JSONDecodeError as e: 509 | message = "Invalid JSON content" 510 | raise exc_class(message, response) from e 511 | 512 | 513 | OAuth2 = BaseOAuth2[dict[str, Any]] 514 | """ 515 | Generic OAuth2 client. 516 | 517 | Examples: 518 | ```py 519 | from httpx_oauth.oauth2 import OAuth2 520 | 521 | client = OAuth2( 522 | "CLIENT_ID", 523 | "CLIENT_SECRET", 524 | "AUTHORIZE_ENDPOINT", 525 | "ACCESS_TOKEN_ENDPOINT", 526 | refresh_token_endpoint="REFRESH_TOKEN_ENDPOINT", 527 | revoke_token_endpoint="REVOKE_TOKEN_ENDPOINT", 528 | ) 529 | ``` 530 | """ 531 | 532 | __all__ = [ 533 | "BaseOAuth2", 534 | ] 535 | -------------------------------------------------------------------------------- /httpx_oauth/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankie567/httpx-oauth/003d13e833f412bc849ced7ed0890d4a4647ff84/httpx_oauth/py.typed -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: HTTPX OAuth 2 | site_description: Async OAuth client using HTTPX 3 | 4 | theme: 5 | name: material 6 | icon: 7 | logo: material/security 8 | palette: 9 | # Palette toggle for automatic mode 10 | - media: "(prefers-color-scheme)" 11 | toggle: 12 | icon: material/brightness-auto 13 | name: Switch to light mode 14 | 15 | # Palette toggle for light mode 16 | - media: "(prefers-color-scheme: light)" 17 | scheme: default 18 | primary: blue grey 19 | accent: blue grey 20 | toggle: 21 | icon: material/brightness-7 22 | name: Switch to dark mode 23 | 24 | # Palette toggle for dark mode 25 | - media: "(prefers-color-scheme: dark)" 26 | scheme: slate 27 | primary: blue grey 28 | accent: blue grey 29 | toggle: 30 | icon: material/brightness-4 31 | name: Switch to light mode 32 | features: 33 | - content.code.copy 34 | 35 | extra_css: 36 | - assets/stylesheets/extra.css 37 | 38 | repo_name: frankie567/httpx-oauth 39 | repo_url: https://github.com/frankie567/httpx-oauth 40 | edit_uri: "" 41 | 42 | markdown_extensions: 43 | - toc: 44 | permalink: true 45 | - pymdownx.highlight: 46 | anchor_linenums: true 47 | - pymdownx.tasklist: 48 | custom_checkbox: true 49 | - pymdownx.inlinehilite 50 | - pymdownx.snippets 51 | - pymdownx.superfences 52 | - admonition 53 | - attr_list 54 | 55 | plugins: 56 | - search 57 | - autorefs 58 | - mkdocstrings: 59 | handlers: 60 | python: 61 | import: 62 | - https://docs.python.org/3.9/objects.inv 63 | - https://fastapi.tiangolo.com/objects.inv 64 | options: 65 | docstring_style: google 66 | extensions: 67 | - griffe_inherited_docstrings 68 | 69 | watch: 70 | - docs 71 | - httpx_oauth 72 | 73 | nav: 74 | - About: index.md 75 | - Usage: usage.md 76 | - Integrations: 77 | - fastapi.md 78 | - Reference: 79 | - httpx_oauth.clients: reference/httpx_oauth.clients.md 80 | - httpx_oauth.oauth2: reference/httpx_oauth.oauth2.md 81 | - httpx_oauth.integrations.fastapi: reference/httpx_oauth.integrations.fastapi.md 82 | - httpx_oauth.exceptions: reference/httpx_oauth.exceptions.md 83 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | target-version = "py39" 3 | 4 | [tool.ruff.lint] 5 | extend-select = ["I", "TRY", "UP"] 6 | ignore = ["E501"] 7 | 8 | [tool.pytest.ini_options] 9 | addopts = "--cov=httpx_oauth/ --cov-report=term-missing --cov-fail-under=100" 10 | asyncio_mode = "strict" 11 | asyncio_default_fixture_loop_scope = "function" 12 | 13 | [tool.hatch] 14 | 15 | [tool.hatch.metadata] 16 | allow-direct-references = true 17 | 18 | [tool.hatch.version] 19 | source = "regex_commit" 20 | commit_extra_args = ["-e"] 21 | path = "httpx_oauth/__init__.py" 22 | 23 | [tool.hatch.envs.default] 24 | installer = "uv" 25 | dependencies = [ 26 | "pytest", 27 | "ruff", 28 | "mkdocs", 29 | "mkdocs-material", 30 | "mypy", 31 | "pytest-cov", 32 | "pytest-mock", 33 | "mkdocstrings[python]", 34 | "griffe-inherited-docstrings", 35 | "pytest-asyncio", 36 | "respx", 37 | "fastapi", 38 | ] 39 | 40 | [tool.hatch.envs.default.scripts] 41 | test = "pytest" 42 | test-cov-xml = "pytest --cov-report=xml" 43 | lint = [ 44 | "ruff format .", 45 | "ruff check --fix .", 46 | "mypy httpx_oauth/", 47 | ] 48 | lint-check = [ 49 | "ruff format --check .", 50 | "ruff check .", 51 | "mypy httpx_oauth/", 52 | ] 53 | docs = "mkdocs serve" 54 | 55 | [tool.hatch.build.targets.sdist] 56 | support-legacy = true # Create setup.py 57 | 58 | [build-system] 59 | requires = ["hatchling", "hatch-regex-commit"] 60 | build-backend = "hatchling.build" 61 | 62 | [project] 63 | name = "httpx-oauth" 64 | authors = [ 65 | { name = "François Voron", email = "fvoron@gmail.com" } 66 | ] 67 | description = "Async OAuth client using HTTPX" 68 | readme = "README.md" 69 | dynamic = ["version"] 70 | classifiers = [ 71 | "License :: OSI Approved :: MIT License", 72 | "Development Status :: 5 - Production/Stable", 73 | "Framework :: AsyncIO", 74 | "Intended Audience :: Developers", 75 | "Programming Language :: Python :: 3.9", 76 | "Programming Language :: Python :: 3.10", 77 | "Programming Language :: Python :: 3.11", 78 | "Programming Language :: Python :: 3.12", 79 | "Programming Language :: Python :: 3.13", 80 | "Programming Language :: Python :: 3 :: Only", 81 | ] 82 | requires-python = ">=3.9" 83 | dependencies = [ 84 | "httpx >=0.18" 85 | ] 86 | 87 | [project.urls] 88 | Documentation = "https://frankie567.github.io/httpx-oauth/" 89 | Source = "https://github.com/frankie567/httpx-oauth" 90 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankie567/httpx-oauth/003d13e833f412bc849ced7ed0890d4a4647ff84/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import os 4 | import sys 5 | from typing import Any 6 | 7 | import httpx 8 | import pytest 9 | 10 | 11 | @pytest.fixture 12 | def load_mock(): 13 | def _load_mock(name: str) -> dict[Any, Any]: 14 | mock_path = os.path.join(os.path.dirname(__file__), "mock", f"{name}.json") 15 | with open(mock_path) as mock_file: 16 | return json.load(mock_file) 17 | 18 | return _load_mock 19 | 20 | 21 | @pytest.fixture 22 | def get_respx_call_args(): 23 | async def _get_respx_call_args( 24 | mock, 25 | ) -> tuple[httpx.URL, httpx.Headers, str]: 26 | request_call = mock.calls[0][0] 27 | 28 | content = "" 29 | async for c in request_call.stream: 30 | content += c.decode("utf-8") 31 | 32 | return request_call.url, request_call.headers, content 33 | 34 | return _get_respx_call_args 35 | 36 | 37 | @pytest.fixture 38 | def patch_async_method(mocker): 39 | minor_version = sys.version_info.minor 40 | 41 | def _patch_async_method(instance, method: str, return_value: Any): 42 | if minor_version < 8: 43 | future: Any = asyncio.Future() 44 | future.set_result(return_value) 45 | mocker.patch.object(instance, method, return_value=future) 46 | else: 47 | from unittest.mock import AsyncMock 48 | 49 | async_mock = AsyncMock() 50 | async_mock.return_value = return_value 51 | mocker.patch.object(instance, method, side_effect=async_mock) 52 | 53 | return _patch_async_method 54 | -------------------------------------------------------------------------------- /tests/mock/error.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": "invalid_request" 3 | } 4 | -------------------------------------------------------------------------------- /tests/mock/facebook_success_long_lived_access_token.json: -------------------------------------------------------------------------------- 1 | { 2 | "access_token": "{long-lived-user-access-token}", 3 | "token_type": "bearer", 4 | "expires_in": 5183944 5 | } 6 | -------------------------------------------------------------------------------- /tests/mock/github_success_refresh_token.json: -------------------------------------------------------------------------------- 1 | { 2 | "access_token": "ghu_XXX", 3 | "expires_in": 28800, 4 | "refresh_token": "ghr_XXX", 5 | "refresh_token_expires_in": 15897600, 6 | "scope": "", 7 | "token_type": "bearer" 8 | } 9 | -------------------------------------------------------------------------------- /tests/mock/google_success_access_token.json: -------------------------------------------------------------------------------- 1 | { 2 | "access_token": "1/fFAGRNJru1FTz70BzhT3Zg", 3 | "expires_in": 3920, 4 | "token_type": "Bearer", 5 | "refresh_token": "1/xEoDL4iW3cxlI7yDbSRFYNG01kVKM2C-259HOF2aQbI" 6 | } 7 | -------------------------------------------------------------------------------- /tests/mock/google_success_refresh_token.json: -------------------------------------------------------------------------------- 1 | { 2 | "access_token": "1/fFAGRNJru1FTz70BzhT3Zg", 3 | "expires_in": 3920, 4 | "token_type": "Bearer" 5 | } 6 | -------------------------------------------------------------------------------- /tests/mock/reddit_success_identity.json: -------------------------------------------------------------------------------- 1 | { 2 | "is_employee": false, 3 | "seen_layout_switch": true, 4 | "has_visited_new_profile": true, 5 | "pref_no_profanity": true, 6 | "has_external_account": true, 7 | "pref_geopopular": "AQ", 8 | "seen_redesign_modal": true, 9 | "pref_show_trending": true, 10 | "subreddit": { 11 | "default_set": true, 12 | "user_is_contributor": false, 13 | "banner_img": "https://placekitten.com/g/1280/384", 14 | "restrict_posting": true, 15 | "user_is_banned": false, 16 | "free_form_reports": true, 17 | "community_icon": null, 18 | "show_media": true, 19 | "icon_color": "", 20 | "user_is_muted": false, 21 | "display_name": "u_TheQuickBrownCatJumpsOverTheLazyDog", 22 | "header_img": null, 23 | "title": "Meow", 24 | "coins": 0, 25 | "previous_names": [], 26 | "over_18": false, 27 | "icon_size": [ 28 | 256, 29 | 256 30 | ], 31 | "primary_color": "", 32 | "icon_img": "https://placekitten.com/g/256/256", 33 | "description": "", 34 | "submit_link_label": "", 35 | "header_size": null, 36 | "restrict_commenting": false, 37 | "subscribers": 0, 38 | "submit_text_label": "", 39 | "is_default_icon": false, 40 | "link_flair_position": "", 41 | "display_name_prefixed": "u/TheQuickBrownCatJumpsOverTheLazyDog", 42 | "key_color": "", 43 | "name": "a1_aa1aa", 44 | "is_default_banner": false, 45 | "url": "/user/TheQuickBrownCatJumpsOverTheLazyDog/", 46 | "quarantine": false, 47 | "banner_size": [ 48 | 1280, 49 | 384 50 | ], 51 | "user_is_moderator": true, 52 | "accept_followers": false, 53 | "public_description": "I am cat. Hear me meow.", 54 | "link_flair_enabled": false, 55 | "disable_contributor_requests": false, 56 | "subreddit_type": "user", 57 | "user_is_subscriber": false 58 | }, 59 | "pref_show_presence": false, 60 | "snoovatar_img": "", 61 | "snoovatar_size": null, 62 | "gold_expiration": null, 63 | "has_gold_subscription": false, 64 | "is_sponsor": false, 65 | "num_friends": 0, 66 | "features": { 67 | "mod_service_mute_writes": true, 68 | "promoted_trend_blanks": true, 69 | "show_amp_link": true, 70 | "chat": true, 71 | "is_email_permission_required": true, 72 | "mod_awards": true, 73 | "mweb_xpromo_revamp_v3": { 74 | "owner": "growth", 75 | "variant": "treatment_1", 76 | "experiment_id": 480 77 | }, 78 | "mweb_xpromo_revamp_v2": { 79 | "owner": "growth", 80 | "variant": "treatment_2", 81 | "experiment_id": 457 82 | }, 83 | "awards_on_streams": true, 84 | "webhook_config": true, 85 | "mweb_xpromo_modal_listing_click_daily_dismissible_ios": true, 86 | "live_orangereds": true, 87 | "cookie_consent_banner": true, 88 | "modlog_copyright_removal": true, 89 | "do_not_track": true, 90 | "mod_service_mute_reads": true, 91 | "chat_user_settings": true, 92 | "use_pref_account_deployment": true, 93 | "mweb_xpromo_interstitial_comments_ios": true, 94 | "chat_subreddit": true, 95 | "noreferrer_to_noopener": true, 96 | "premium_subscriptions_table": true, 97 | "mweb_xpromo_interstitial_comments_android": true, 98 | "chat_group_rollout": true, 99 | "resized_styles_images": true, 100 | "spez_modal": true, 101 | "mweb_xpromo_modal_listing_click_daily_dismissible_android": true, 102 | "expensive_coins_package": true 103 | }, 104 | "can_edit_name": false, 105 | "verified": true, 106 | "pref_autoplay": false, 107 | "coins": 0, 108 | "has_paypal_subscription": false, 109 | "has_subscribed_to_premium": false, 110 | "id": "abcde", 111 | "has_stripe_subscription": false, 112 | "oauth_client_id": "SAMPLE_CLIENT_ID", 113 | "can_create_subreddit": true, 114 | "over_18": true, 115 | "is_gold": false, 116 | "is_mod": false, 117 | "awarder_karma": 0, 118 | "suspension_expiration_utc": null, 119 | "has_verified_email": true, 120 | "is_suspended": false, 121 | "pref_video_autoplay": false, 122 | "has_android_subscription": false, 123 | "in_redesign_beta": true, 124 | "icon_img": "https://placekitten.com/g/256/256", 125 | "pref_nightmode": true, 126 | "awardee_karma": 2222, 127 | "hide_from_robots": false, 128 | "password_set": true, 129 | "link_karma": 1111, 130 | "force_password_reset": false, 131 | "total_karma": 6666, 132 | "seen_give_award_tooltip": false, 133 | "inbox_count": 0, 134 | "seen_premium_adblock_modal": false, 135 | "pref_top_karma_subreddits": false, 136 | "pref_show_snoovatar": false, 137 | "name": "TheQuickBrownCatJumpsOverTheLazyDog", 138 | "pref_clickgadget": 0, 139 | "created": 1500000000.0, 140 | "gold_creddits": 0, 141 | "created_utc": 1500000000.0, 142 | "has_ios_subscription": false, 143 | "pref_show_twitter": true, 144 | "in_beta": true, 145 | "comment_karma": 3333, 146 | "accept_followers": false, 147 | "has_subscribed": true, 148 | "linked_identities": [], 149 | "seen_subreddit_chat_ftux": false 150 | } 151 | -------------------------------------------------------------------------------- /tests/test_branding.py: -------------------------------------------------------------------------------- 1 | from httpx_oauth.branding import BrandingProtocol 2 | 3 | 4 | def test_branding_protocol(): 5 | assert isinstance(BrandingProtocol.__annotations__["display_name"], type(str)) 6 | assert isinstance(BrandingProtocol.__annotations__["logo_svg"], type(str)) 7 | -------------------------------------------------------------------------------- /tests/test_clients_discord.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | import respx 5 | from httpx import Response 6 | 7 | from httpx_oauth.clients.discord import PROFILE_ENDPOINT, DiscordOAuth2 8 | from httpx_oauth.exceptions import GetIdEmailError 9 | 10 | client = DiscordOAuth2("CLIENT_ID", "CLIENT_SECRET") 11 | 12 | 13 | def test_discord_oauth2(): 14 | assert client.authorize_endpoint == "https://discord.com/api/oauth2/authorize" 15 | assert client.access_token_endpoint == "https://discord.com/api/oauth2/token" 16 | assert client.refresh_token_endpoint == "https://discord.com/api/oauth2/token" 17 | assert client.revoke_token_endpoint == "https://discord.com/api/oauth2/token/revoke" 18 | assert client.base_scopes == ["identify", "email"] 19 | assert client.name == "discord" 20 | 21 | 22 | profile_verified_email_response = { 23 | "id": "80351110224678912", 24 | "username": "Nelly", 25 | "discriminator": "1337", 26 | "avatar": "8342729096ea3675442027381ff50dfe", 27 | "verified": True, 28 | "email": "nelly@discord.com", 29 | "flags": 64, 30 | "banner": "06c16474723fe537c283b8efa61a30c8", 31 | "accent_color": 16711680, 32 | "premium_type": 1, 33 | "public_flags": 64, 34 | } 35 | 36 | profile_no_email_response = { 37 | "id": "80351110224678912", 38 | "username": "Nelly", 39 | "discriminator": "1337", 40 | "avatar": "8342729096ea3675442027381ff50dfe", 41 | "flags": 64, 42 | "banner": "06c16474723fe537c283b8efa61a30c8", 43 | "accent_color": 16711680, 44 | "premium_type": 1, 45 | "public_flags": 64, 46 | } 47 | 48 | profile_not_verified_email_response = { 49 | "id": "80351110224678912", 50 | "username": "Nelly", 51 | "discriminator": "1337", 52 | "avatar": "8342729096ea3675442027381ff50dfe", 53 | "verified": False, 54 | "email": "nelly@discord.com", 55 | "flags": 64, 56 | "banner": "06c16474723fe537c283b8efa61a30c8", 57 | "accent_color": 16711680, 58 | "premium_type": 1, 59 | "public_flags": 64, 60 | } 61 | 62 | 63 | class TestDiscordGetIdEmail: 64 | @pytest.mark.asyncio 65 | @respx.mock 66 | async def test_success(self, get_respx_call_args): 67 | request = respx.get(re.compile(f"^{PROFILE_ENDPOINT}")).mock( 68 | return_value=Response(200, json=profile_verified_email_response) 69 | ) 70 | 71 | user_id, user_email = await client.get_id_email("TOKEN") 72 | _, headers, _ = await get_respx_call_args(request) 73 | 74 | assert headers["Authorization"] == "Bearer TOKEN" 75 | assert headers["Accept"] == "application/json" 76 | assert user_id == "80351110224678912" 77 | assert user_email == "nelly@discord.com" 78 | 79 | @pytest.mark.asyncio 80 | @respx.mock 81 | async def test_error(self): 82 | respx.get(re.compile(f"^{PROFILE_ENDPOINT}")).mock( 83 | return_value=Response(400, json={"error": "message"}) 84 | ) 85 | 86 | with pytest.raises(GetIdEmailError) as excinfo: 87 | await client.get_id_email("TOKEN") 88 | 89 | assert isinstance(excinfo.value.response, Response) 90 | 91 | @pytest.mark.asyncio 92 | @respx.mock 93 | async def test_no_email(self): 94 | respx.get(re.compile(f"^{PROFILE_ENDPOINT}$")).mock( 95 | return_value=Response(200, json=profile_no_email_response) 96 | ) 97 | 98 | user_id, user_email = await client.get_id_email("TOKEN") 99 | 100 | assert user_id == "80351110224678912" 101 | assert user_email is None 102 | 103 | @pytest.mark.asyncio 104 | @respx.mock 105 | async def test_email_not_verified_error(self): 106 | respx.get(re.compile(f"^{PROFILE_ENDPOINT}$")).mock( 107 | return_value=Response(200, json=profile_not_verified_email_response) 108 | ) 109 | 110 | user_id, user_email = await client.get_id_email("TOKEN") 111 | 112 | assert user_id == "80351110224678912" 113 | assert user_email is None 114 | -------------------------------------------------------------------------------- /tests/test_clients_facebook.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | import respx 5 | from httpx import HTTPError, Response 6 | 7 | from httpx_oauth.clients.facebook import ( 8 | PROFILE_ENDPOINT, 9 | FacebookOAuth2, 10 | GetLongLivedAccessTokenError, 11 | ) 12 | from httpx_oauth.exceptions import GetIdEmailError 13 | from httpx_oauth.oauth2 import OAuth2Token 14 | 15 | CLIENT_ID = "CLIENT_ID" 16 | CLIENT_SECRET = "CLIENT_SECRET" 17 | REDIRECT_URI = "https://www.tintagel.bt/oauth-callback" 18 | 19 | client = FacebookOAuth2("CLIENT_ID", "CLIENT_SECRET") 20 | 21 | 22 | def test_facebook_oauth2(): 23 | assert client.authorize_endpoint == "https://www.facebook.com/v5.0/dialog/oauth" 24 | assert ( 25 | client.access_token_endpoint 26 | == "https://graph.facebook.com/v5.0/oauth/access_token" 27 | ) 28 | assert client.refresh_token_endpoint is None 29 | assert client.revoke_token_endpoint is None 30 | assert client.base_scopes == ["email", "public_profile"] 31 | assert client.name == "facebook" 32 | 33 | 34 | @pytest.mark.asyncio 35 | class TestGetLongLivedAccessToken: 36 | @respx.mock 37 | async def test_get_long_lived_access_token(self, load_mock, get_respx_call_args): 38 | request = respx.post(client.access_token_endpoint).mock( 39 | return_value=Response( 40 | 200, json=load_mock("facebook_success_long_lived_access_token") 41 | ) 42 | ) 43 | access_token = await client.get_long_lived_access_token("ACCESS_TOKEN") 44 | 45 | url, headers, content = await get_respx_call_args(request) 46 | assert headers["Content-Type"] == "application/x-www-form-urlencoded" 47 | assert "grant_type=fb_exchange_token" in content 48 | assert "fb_exchange_token=ACCESS_TOKEN" in content 49 | assert f"client_id={CLIENT_ID}" in content 50 | assert f"client_secret={CLIENT_SECRET}" in content 51 | 52 | assert type(access_token) is OAuth2Token 53 | assert "access_token" in access_token 54 | assert "token_type" in access_token 55 | assert access_token.is_expired() is False 56 | 57 | @respx.mock 58 | async def test_get_long_lived_access_token_error(self, load_mock): 59 | respx.post(client.access_token_endpoint).mock( 60 | return_value=Response(400, json=load_mock("error")) 61 | ) 62 | 63 | with pytest.raises(GetLongLivedAccessTokenError) as excinfo: 64 | await client.get_long_lived_access_token("ACCESS_TOKEN") 65 | assert isinstance(excinfo.value.response, Response) 66 | 67 | @respx.mock 68 | async def test_get_long_lived_access_token_http_error(self): 69 | respx.post(client.access_token_endpoint).mock(side_effect=HTTPError("ERROR")) 70 | 71 | with pytest.raises(GetLongLivedAccessTokenError) as excinfo: 72 | await client.get_long_lived_access_token("ACCESS_TOKEN") 73 | assert excinfo.value.response is None 74 | 75 | @respx.mock 76 | async def test_get_long_lived_access_token_json_error(self): 77 | respx.post(client.access_token_endpoint).mock( 78 | return_value=Response(200, text="NOT JSON") 79 | ) 80 | 81 | with pytest.raises(GetLongLivedAccessTokenError) as excinfo: 82 | await client.get_long_lived_access_token("ACCESS_TOKEN") 83 | assert isinstance(excinfo.value.response, Response) 84 | 85 | 86 | profile_response = {"id": "424242", "email": "arthur@camelot.bt"} 87 | profile_response_no_email = {"id": "424242"} 88 | 89 | 90 | class TestFacebookGetIdEmail: 91 | @pytest.mark.asyncio 92 | @respx.mock 93 | async def test_success(self, get_respx_call_args): 94 | request = respx.get(re.compile(f"^{PROFILE_ENDPOINT}")).mock( 95 | return_value=Response(200, json=profile_response) 96 | ) 97 | 98 | user_id, user_email = await client.get_id_email("TOKEN") 99 | url, headers, content = await get_respx_call_args(request) 100 | 101 | assert "access_token=TOKEN" in url.query.decode("utf-8") 102 | assert user_id == "424242" 103 | assert user_email == "arthur@camelot.bt" 104 | 105 | @pytest.mark.asyncio 106 | @respx.mock 107 | async def test_success_no_email(self, get_respx_call_args): 108 | request = respx.get(re.compile(f"^{PROFILE_ENDPOINT}")).mock( 109 | return_value=Response(200, json=profile_response_no_email) 110 | ) 111 | 112 | user_id, user_email = await client.get_id_email("TOKEN") 113 | url, headers, content = await get_respx_call_args(request) 114 | 115 | assert "access_token=TOKEN" in url.query.decode("utf-8") 116 | assert user_id == "424242" 117 | assert user_email is None 118 | 119 | @pytest.mark.asyncio 120 | @respx.mock 121 | async def test_error(self): 122 | respx.get(re.compile(f"^{PROFILE_ENDPOINT}")).mock( 123 | Response(400, json={"error": "message"}) 124 | ) 125 | 126 | with pytest.raises(GetIdEmailError) as excinfo: 127 | await client.get_id_email("TOKEN") 128 | 129 | assert isinstance(excinfo.value.response, Response) 130 | -------------------------------------------------------------------------------- /tests/test_clients_franceconnect.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import respx 3 | from httpx import Response 4 | 5 | from httpx_oauth.clients.franceconnect import FranceConnectOAuth2 6 | from httpx_oauth.exceptions import GetIdEmailError 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "integration,authorize,access_token,profile", 11 | [ 12 | ( 13 | False, 14 | "https://app.franceconnect.gouv.fr/api/v1/authorize", 15 | "https://app.franceconnect.gouv.fr/api/v1/token", 16 | "https://app.franceconnect.gouv.fr/api/v1/userinfo", 17 | ), 18 | ( 19 | True, 20 | "https://fcp.integ01.dev-franceconnect.fr/api/v1/authorize", 21 | "https://fcp.integ01.dev-franceconnect.fr/api/v1/token", 22 | "https://fcp.integ01.dev-franceconnect.fr/api/v1/userinfo", 23 | ), 24 | ], 25 | ) 26 | def test_franceconnect( 27 | integration: bool, authorize: str, access_token: str, profile: str 28 | ): 29 | client = FranceConnectOAuth2("CLIENT_ID", "CLIENT_SECRET", integration) 30 | assert client.authorize_endpoint == authorize 31 | assert client.access_token_endpoint == access_token 32 | assert client.refresh_token_endpoint is None 33 | assert client.revoke_token_endpoint is None 34 | assert client.profile_endpoint == profile 35 | assert client.base_scopes == ["openid", "email"] 36 | assert client.name == "franceconnect" 37 | 38 | 39 | profile_response = {"sub": 42, "email": "arthur@camelot.bt"} 40 | 41 | 42 | @pytest.fixture(params=[False, True]) 43 | def client(request: pytest.FixtureRequest) -> FranceConnectOAuth2: 44 | return FranceConnectOAuth2("CLIENT_ID", "CLIENT_SECRET", integration=request.param) 45 | 46 | 47 | class TestFranceConnectGetAuthorizationURL: 48 | @pytest.mark.asyncio 49 | async def test_get_authorization_url(self, client: FranceConnectOAuth2): 50 | authorization_url = await client.get_authorization_url("REDIRECT_URI") 51 | assert "nonce=" in authorization_url 52 | 53 | 54 | class TestFranceConnectGetIdEmail: 55 | @pytest.mark.asyncio 56 | @respx.mock 57 | async def test_success(self, client: FranceConnectOAuth2, get_respx_call_args): 58 | request = respx.get(path="/api/v1/userinfo").mock( 59 | return_value=Response(200, json=profile_response) 60 | ) 61 | 62 | user_id, user_email = await client.get_id_email("TOKEN") 63 | url, headers, content = await get_respx_call_args(request) 64 | 65 | assert headers["Authorization"] == "Bearer TOKEN" 66 | assert headers["Accept"] == "application/json" 67 | assert user_id == "42" 68 | assert user_email == "arthur@camelot.bt" 69 | 70 | @pytest.mark.asyncio 71 | @respx.mock 72 | async def test_error(self, client: FranceConnectOAuth2): 73 | respx.get(path="/api/v1/userinfo").mock( 74 | return_value=Response(400, json={"error": "message"}) 75 | ) 76 | 77 | with pytest.raises(GetIdEmailError) as excinfo: 78 | await client.get_id_email("TOKEN") 79 | 80 | assert isinstance(excinfo.value.response, Response) 81 | -------------------------------------------------------------------------------- /tests/test_clients_github.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | 4 | import pytest 5 | import respx 6 | from httpx import HTTPError, Response 7 | 8 | from httpx_oauth.clients.github import EMAILS_ENDPOINT, PROFILE_ENDPOINT, GitHubOAuth2 9 | from httpx_oauth.exceptions import GetIdEmailError 10 | from httpx_oauth.oauth2 import OAuth2Token, RefreshTokenError 11 | 12 | client = GitHubOAuth2("CLIENT_ID", "CLIENT_SECRET") 13 | 14 | 15 | def test_github_oauth2(): 16 | assert client.authorize_endpoint == "https://github.com/login/oauth/authorize" 17 | assert client.access_token_endpoint == "https://github.com/login/oauth/access_token" 18 | assert ( 19 | client.refresh_token_endpoint == "https://github.com/login/oauth/access_token" 20 | ) 21 | assert client.revoke_token_endpoint is None 22 | assert client.base_scopes == ["user", "user:email"] 23 | assert client.name == "github" 24 | 25 | 26 | profile_response = {"id": 42, "email": "arthur@camelot.bt"} 27 | profile_response_no_public_email = {"id": 42, "email": None} 28 | emails_response = [{"email": "arthur@camelot.bt"}] 29 | 30 | 31 | @pytest.mark.asyncio 32 | class TestGitHubRefreshToken: 33 | @respx.mock 34 | async def test_refresh_token(self, load_mock, get_respx_call_args): 35 | request = respx.post(client.refresh_token_endpoint).mock( 36 | return_value=Response(200, json=load_mock("github_success_refresh_token")) 37 | ) 38 | access_token = await client.refresh_token("REFRESH_TOKEN") 39 | 40 | url, headers, content = await get_respx_call_args(request) 41 | assert headers["Content-Type"] == "application/x-www-form-urlencoded" 42 | assert headers["Accept"] == "application/json" 43 | assert "grant_type=refresh_token" in content 44 | assert "refresh_token=REFRESH_TOKEN" in content 45 | assert "client_id=CLIENT_ID" in content 46 | assert "client_secret=CLIENT_SECRET" in content 47 | 48 | assert type(access_token) is OAuth2Token 49 | assert "access_token" in access_token 50 | assert "token_type" in access_token 51 | assert access_token.is_expired() is False 52 | 53 | @respx.mock 54 | async def test_refresh_token_status_error(self, load_mock): 55 | respx.post(client.refresh_token_endpoint).mock( 56 | return_value=Response(400, json=load_mock("error")) 57 | ) 58 | 59 | with pytest.raises(RefreshTokenError) as excinfo: 60 | await client.refresh_token("REFRESH_TOKEN") 61 | assert isinstance(excinfo.value.response, Response) 62 | 63 | @respx.mock 64 | async def test_refresh_token_http_error(self, load_mock): 65 | respx.post(client.refresh_token_endpoint).mock(side_effect=HTTPError("ERROR")) 66 | 67 | with pytest.raises(RefreshTokenError) as excinfo: 68 | await client.refresh_token("REFRESH_TOKEN") 69 | assert excinfo.value.response is None 70 | 71 | @respx.mock 72 | async def test_refresh_token_200_error(self): 73 | error_response = { 74 | "error": "bad_refresh_token", 75 | "error_description": "The refresh token passed is incorrect or expired.", 76 | "error_uri": "https://docs.github.com", 77 | } 78 | 79 | respx.post(client.refresh_token_endpoint).mock( 80 | return_value=Response( 81 | 200, 82 | headers={"content-type": "application/json"}, 83 | content=json.dumps(error_response), 84 | ) 85 | ) 86 | 87 | with pytest.raises(RefreshTokenError) as excinfo: 88 | await client.refresh_token("REFRESH_TOKEN") 89 | assert isinstance(excinfo.value.response, Response) 90 | 91 | @respx.mock 92 | async def test_refresh_token_json_error(self): 93 | respx.post(client.refresh_token_endpoint).mock( 94 | return_value=Response(200, text="NOT JSON") 95 | ) 96 | 97 | with pytest.raises(RefreshTokenError) as excinfo: 98 | await client.refresh_token("REFRESH_TOKEN") 99 | assert isinstance(excinfo.value.response, Response) 100 | 101 | 102 | class TestGitHubGetIdEmail: 103 | @pytest.mark.asyncio 104 | @respx.mock 105 | async def test_success(self, get_respx_call_args): 106 | request = respx.get(re.compile(f"^{PROFILE_ENDPOINT}")).mock( 107 | return_value=Response(200, json=profile_response) 108 | ) 109 | 110 | user_id, user_email = await client.get_id_email("TOKEN") 111 | _, headers, _ = await get_respx_call_args(request) 112 | 113 | assert headers["Authorization"] == "token TOKEN" 114 | assert headers["Accept"] == "application/json" 115 | assert user_id == "42" 116 | assert user_email == "arthur@camelot.bt" 117 | 118 | @pytest.mark.asyncio 119 | @respx.mock 120 | async def test_error(self): 121 | respx.get(re.compile(f"^{PROFILE_ENDPOINT}")).mock( 122 | return_value=Response(400, json={"error": "message"}) 123 | ) 124 | 125 | with pytest.raises(GetIdEmailError) as excinfo: 126 | await client.get_id_email("TOKEN") 127 | 128 | assert isinstance(excinfo.value.response, Response) 129 | 130 | @pytest.mark.asyncio 131 | @respx.mock 132 | async def test_no_public_email_success(self, get_respx_call_args): 133 | respx.get(re.compile(f"^{PROFILE_ENDPOINT}$")).mock( 134 | return_value=Response(200, json=profile_response_no_public_email) 135 | ) 136 | request = respx.get(re.compile(f"^{EMAILS_ENDPOINT}")).mock( 137 | return_value=Response(200, json=emails_response) 138 | ) 139 | 140 | user_id, user_email = await client.get_id_email("TOKEN") 141 | _, headers, _ = await get_respx_call_args(request) 142 | 143 | assert headers["Authorization"] == "token TOKEN" 144 | assert headers["Accept"] == "application/json" 145 | assert user_id == "42" 146 | assert user_email == "arthur@camelot.bt" 147 | 148 | @pytest.mark.asyncio 149 | @respx.mock 150 | async def test_no_public_email_error(self): 151 | respx.get(re.compile(f"^{PROFILE_ENDPOINT}$")).mock( 152 | return_value=Response(200, json=profile_response_no_public_email) 153 | ) 154 | respx.get(re.compile(f"^{EMAILS_ENDPOINT}")).mock( 155 | return_value=Response(400, json={"error": "message"}) 156 | ) 157 | 158 | with pytest.raises(GetIdEmailError) as excinfo: 159 | await client.get_id_email("TOKEN") 160 | 161 | assert isinstance(excinfo.value.response, Response) 162 | -------------------------------------------------------------------------------- /tests/test_clients_google.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | import respx 5 | from httpx import Response 6 | 7 | from httpx_oauth.clients.google import PROFILE_ENDPOINT, GoogleOAuth2 8 | from httpx_oauth.exceptions import GetIdEmailError 9 | 10 | client = GoogleOAuth2("CLIENT_ID", "CLIENT_SECRET") 11 | 12 | 13 | def test_google_oauth2(): 14 | assert client.authorize_endpoint == "https://accounts.google.com/o/oauth2/v2/auth" 15 | assert client.access_token_endpoint == "https://oauth2.googleapis.com/token" 16 | assert client.refresh_token_endpoint == "https://oauth2.googleapis.com/token" 17 | assert client.revoke_token_endpoint == "https://accounts.google.com/o/oauth2/revoke" 18 | assert client.base_scopes == [ 19 | "https://www.googleapis.com/auth/userinfo.profile", 20 | "https://www.googleapis.com/auth/userinfo.email", 21 | ] 22 | assert client.name == "google" 23 | 24 | 25 | profile_response = { 26 | "resourceName": "people/424242424242", 27 | "emailAddresses": [ 28 | {"metadata": {"primary": True, "verified": True}, "value": "arthur@camelot.bt"}, 29 | {"metadata": {"primary": False, "verified": True}, "value": "arthur@graal.com"}, 30 | ], 31 | } 32 | 33 | 34 | class TestGoogleGetIdEmail: 35 | @pytest.mark.asyncio 36 | @respx.mock 37 | async def test_success(self, get_respx_call_args): 38 | request = respx.get(re.compile(f"^{PROFILE_ENDPOINT}")).mock( 39 | return_value=Response(200, json=profile_response) 40 | ) 41 | 42 | user_id, user_email = await client.get_id_email("TOKEN") 43 | url, headers, content = await get_respx_call_args(request) 44 | 45 | assert "personFields=emailAddresses" in url.query.decode("utf-8") 46 | assert headers["Authorization"] == "Bearer TOKEN" 47 | assert user_id == "people/424242424242" 48 | assert user_email == "arthur@camelot.bt" 49 | 50 | @pytest.mark.asyncio 51 | @respx.mock 52 | async def test_error(self): 53 | respx.get(re.compile(f"^{PROFILE_ENDPOINT}")).mock( 54 | return_value=Response(400, json={"error": "message"}) 55 | ) 56 | 57 | with pytest.raises(GetIdEmailError) as excinfo: 58 | await client.get_id_email("TOKEN") 59 | 60 | assert isinstance(excinfo.value.response, Response) 61 | -------------------------------------------------------------------------------- /tests/test_clients_kakao.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | import respx 5 | from httpx import Response 6 | 7 | from httpx_oauth.clients.kakao import PROFILE_ENDPOINT, KakaoOAuth2 8 | from httpx_oauth.exceptions import GetIdEmailError 9 | 10 | client = KakaoOAuth2("CLIENT_ID", "CLIENT_SECRET") 11 | 12 | 13 | def test_kakao_oauth2(): 14 | assert client.authorize_endpoint == "https://kauth.kakao.com/oauth/authorize" 15 | assert client.access_token_endpoint == "https://kauth.kakao.com/oauth/token" 16 | assert client.refresh_token_endpoint == "https://kauth.kakao.com/oauth/token" 17 | assert client.revoke_token_endpoint == "https://kapi.kakao.com/v1/user/unlink" 18 | assert client.base_scopes == ["profile_nickname", "account_email"] 19 | assert client.name == "kakao" 20 | 21 | 22 | profile_response = {"id": 4242424242, "kakao_account": {"email": "arthur@camelot.bt"}} 23 | 24 | profile_no_email_response = {"id": 4242424242, "kakao_account": {}} 25 | 26 | 27 | class TestKakaoGetIdEmail: 28 | @pytest.mark.asyncio 29 | @respx.mock 30 | async def test_success(self, get_respx_call_args): 31 | request = respx.post(re.compile(f"^{PROFILE_ENDPOINT}")).mock( 32 | return_value=Response(200, json=profile_response) 33 | ) 34 | 35 | user_id, user_email = await client.get_id_email("TOKEN") 36 | url, headers, _ = await get_respx_call_args(request) 37 | 38 | assert headers["Authorization"] == "Bearer TOKEN" 39 | assert headers["Accept"] == "application/json" 40 | assert user_id == "4242424242" 41 | assert user_email == "arthur@camelot.bt" 42 | 43 | @pytest.mark.asyncio 44 | @respx.mock 45 | async def test_error(self): 46 | respx.post(re.compile(f"^{PROFILE_ENDPOINT}")).mock( 47 | return_value=Response(400, json={"msg": "failed message"}) 48 | ) 49 | 50 | with pytest.raises(GetIdEmailError) as excinfo: 51 | await client.get_id_email("TOKEN") 52 | 53 | assert isinstance(excinfo.value.response, Response) 54 | 55 | @pytest.mark.asyncio 56 | @respx.mock 57 | async def test_no_email(self): 58 | respx.post(re.compile(f"^{PROFILE_ENDPOINT}")).mock( 59 | return_value=Response(200, json=profile_no_email_response) 60 | ) 61 | 62 | user_id, user_email = await client.get_id_email("TOKEN") 63 | 64 | assert user_id == "4242424242" 65 | assert user_email is None 66 | -------------------------------------------------------------------------------- /tests/test_clients_linkedin.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | import respx 5 | from httpx import Response 6 | 7 | from httpx_oauth.clients.linkedin import ( 8 | EMAIL_ENDPOINT, 9 | PROFILE_ENDPOINT, 10 | LinkedInOAuth2, 11 | ) 12 | from httpx_oauth.exceptions import GetIdEmailError 13 | 14 | client = LinkedInOAuth2("CLIENT_ID", "CLIENT_SECRET") 15 | 16 | 17 | def test_linkedin_oauth2(): 18 | assert ( 19 | client.authorize_endpoint == "https://www.linkedin.com/oauth/v2/authorization" 20 | ) 21 | assert ( 22 | client.access_token_endpoint == "https://www.linkedin.com/oauth/v2/accessToken" 23 | ) 24 | assert ( 25 | client.refresh_token_endpoint == "https://www.linkedin.com/oauth/v2/accessToken" 26 | ) 27 | assert client.base_scopes == ["r_emailaddress", "r_liteprofile", "r_basicprofile"] 28 | assert client.revoke_token_endpoint is None 29 | assert client.name == "linkedin" 30 | 31 | 32 | profile_response = {"id": "424242"} 33 | email_response = { 34 | "elements": [ 35 | { 36 | "handle": "urn:li:emailAddress:667536010", 37 | "handle~": {"emailAddress": "arthur@camelot.bt"}, 38 | } 39 | ] 40 | } 41 | 42 | 43 | class TestLinkedInGetIdEmail: 44 | @pytest.mark.asyncio 45 | @respx.mock 46 | async def test_success(self, get_respx_call_args): 47 | profile_request = respx.get(re.compile(f"^{PROFILE_ENDPOINT}")).mock( 48 | return_value=Response(200, json=profile_response) 49 | ) 50 | email_request = respx.get(re.compile(f"^{EMAIL_ENDPOINT}")).mock( 51 | return_value=Response(200, json=email_response) 52 | ) 53 | 54 | user_id, user_email = await client.get_id_email("TOKEN") 55 | profile_url, profile_headers, profile_content = await get_respx_call_args( 56 | profile_request 57 | ) 58 | email_url, email_headers, email_content = await get_respx_call_args( 59 | email_request 60 | ) 61 | 62 | assert profile_headers["Authorization"] == "Bearer TOKEN" 63 | assert email_headers["Authorization"] == "Bearer TOKEN" 64 | assert user_id == "424242" 65 | assert user_email == "arthur@camelot.bt" 66 | 67 | @pytest.mark.asyncio 68 | @respx.mock 69 | async def test_profile_error(self): 70 | respx.get(re.compile(f"^{PROFILE_ENDPOINT}")).mock( 71 | return_value=Response(400, json={"error": "message"}) 72 | ) 73 | respx.get(re.compile(f"^{EMAIL_ENDPOINT}")).mock( 74 | return_value=Response(200, json=email_response) 75 | ) 76 | 77 | with pytest.raises(GetIdEmailError) as excinfo: 78 | await client.get_id_email("TOKEN") 79 | 80 | assert isinstance(excinfo.value.response, Response) 81 | 82 | @pytest.mark.asyncio 83 | @respx.mock 84 | async def test_email_error(self): 85 | respx.get(re.compile(f"^{PROFILE_ENDPOINT}")).mock( 86 | return_value=Response(200, json=profile_response) 87 | ) 88 | respx.get(re.compile(f"^{EMAIL_ENDPOINT}")).mock( 89 | return_value=Response(400, json={"error": "message"}) 90 | ) 91 | 92 | with pytest.raises(GetIdEmailError) as excinfo: 93 | await client.get_id_email("TOKEN") 94 | 95 | assert isinstance(excinfo.value.response, Response) 96 | -------------------------------------------------------------------------------- /tests/test_clients_microsoft.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | import respx 5 | from httpx import Response 6 | 7 | from httpx_oauth.clients.microsoft import PROFILE_ENDPOINT, MicrosoftGraphOAuth2 8 | from httpx_oauth.exceptions import GetIdEmailError 9 | 10 | client = MicrosoftGraphOAuth2("CLIENT_ID", "CLIENT_SECRET") 11 | 12 | 13 | def test_microsoft_graph_oauth2_default_tenant(): 14 | assert ( 15 | client.authorize_endpoint 16 | == "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" 17 | ) 18 | assert ( 19 | client.access_token_endpoint 20 | == "https://login.microsoftonline.com/common/oauth2/v2.0/token" 21 | ) 22 | assert ( 23 | client.refresh_token_endpoint 24 | == "https://login.microsoftonline.com/common/oauth2/v2.0/token" 25 | ) 26 | assert client.revoke_token_endpoint is None 27 | assert client.base_scopes == ["User.Read"] 28 | assert client.name == "microsoft" 29 | 30 | 31 | def test_microsoft_graph_oauth2_custom_tenant(): 32 | client = MicrosoftGraphOAuth2("CLIENT_ID", "CLIENT_SECRET", "my_tenant") 33 | 34 | assert ( 35 | client.authorize_endpoint 36 | == "https://login.microsoftonline.com/my_tenant/oauth2/v2.0/authorize" 37 | ) 38 | assert ( 39 | client.access_token_endpoint 40 | == "https://login.microsoftonline.com/my_tenant/oauth2/v2.0/token" 41 | ) 42 | assert ( 43 | client.refresh_token_endpoint 44 | == "https://login.microsoftonline.com/my_tenant/oauth2/v2.0/token" 45 | ) 46 | assert client.revoke_token_endpoint is None 47 | 48 | 49 | @pytest.mark.asyncio 50 | async def test_microsoft_graph_oauth2_authorization_url(): 51 | client = MicrosoftGraphOAuth2("CLIENT_ID", "CLIENT_SECRET") 52 | 53 | authorization_url = await client.get_authorization_url( 54 | "https://www.tintagel.bt/oauth-callback" 55 | ) 56 | assert "response_mode=query" in authorization_url 57 | 58 | 59 | profile_response = {"id": "424242", "userPrincipalName": "arthur@camelot.bt"} 60 | 61 | 62 | class TestMicrosoftGetIdEmail: 63 | @pytest.mark.asyncio 64 | @respx.mock 65 | async def test_success(self, get_respx_call_args): 66 | request = respx.get(re.compile(f"^{PROFILE_ENDPOINT}")).mock( 67 | return_value=Response(200, json=profile_response) 68 | ) 69 | 70 | user_id, user_email = await client.get_id_email("TOKEN") 71 | url, headers, content = await get_respx_call_args(request) 72 | 73 | assert headers["Authorization"] == "Bearer TOKEN" 74 | assert user_id == "424242" 75 | assert user_email == "arthur@camelot.bt" 76 | 77 | @pytest.mark.asyncio 78 | @respx.mock 79 | async def test_error(self): 80 | respx.get(re.compile(f"^{PROFILE_ENDPOINT}")).mock( 81 | return_value=Response(400, json={"error": "message"}) 82 | ) 83 | 84 | with pytest.raises(GetIdEmailError) as excinfo: 85 | await client.get_id_email("TOKEN") 86 | 87 | assert isinstance(excinfo.value.response, Response) 88 | -------------------------------------------------------------------------------- /tests/test_clients_naver.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | import respx 5 | from httpx import HTTPError, Response 6 | 7 | from httpx_oauth.clients.naver import PROFILE_ENDPOINT, NaverOAuth2 8 | from httpx_oauth.exceptions import GetIdEmailError 9 | from httpx_oauth.oauth2 import RevokeTokenError 10 | 11 | client = NaverOAuth2("CLIENT_ID", "CLIENT_SECRET") 12 | 13 | 14 | def test_naver_oauth2(): 15 | assert client.authorize_endpoint == "https://nid.naver.com/oauth2.0/authorize" 16 | assert client.access_token_endpoint == "https://nid.naver.com/oauth2.0/token" 17 | assert client.refresh_token_endpoint == "https://nid.naver.com/oauth2.0/token" 18 | assert client.revoke_token_endpoint == "https://nid.naver.com/oauth2.0/token" 19 | assert client.base_scopes == [] 20 | assert client.name == "naver" 21 | 22 | 23 | profile_response = { 24 | "resultcode": "00", 25 | "message": "success", 26 | "response": { 27 | "id": "424242424242424242424242", 28 | "email": "example@naver.com", 29 | }, 30 | } 31 | 32 | profile_no_email_response = { 33 | "resultcode": "00", 34 | "message": "success", 35 | "response": { 36 | "id": "424242424242424242424242", 37 | }, 38 | } 39 | 40 | 41 | class TestNaverdGetIdEmail: 42 | @pytest.mark.asyncio 43 | @respx.mock 44 | async def test_success(self, get_respx_call_args): 45 | request = respx.post(re.compile(f"^{PROFILE_ENDPOINT}")).mock( 46 | return_value=Response(200, json=profile_response) 47 | ) 48 | 49 | user_id, user_email = await client.get_id_email("TOKEN") 50 | _, headers, _ = await get_respx_call_args(request) 51 | 52 | assert headers["Authorization"] == "Bearer TOKEN" 53 | assert headers["Accept"] == "application/json" 54 | assert user_id == "424242424242424242424242" 55 | assert user_email == "example@naver.com" 56 | 57 | @pytest.mark.asyncio 58 | @respx.mock 59 | async def test_error(self): 60 | respx.post(re.compile(f"^{PROFILE_ENDPOINT}")).mock( 61 | return_value=Response(400, json={"error": "message"}) 62 | ) 63 | 64 | with pytest.raises(GetIdEmailError) as excinfo: 65 | await client.get_id_email("TOKEN") 66 | 67 | assert isinstance(excinfo.value.response, Response) 68 | 69 | @pytest.mark.asyncio 70 | @respx.mock 71 | async def test_no_email(self): 72 | respx.post(re.compile(f"^{PROFILE_ENDPOINT}$")).mock( 73 | return_value=Response(200, json=profile_no_email_response) 74 | ) 75 | 76 | user_id, user_email = await client.get_id_email("TOKEN") 77 | 78 | assert user_id == "424242424242424242424242" 79 | assert user_email is None 80 | 81 | 82 | class TestRevokeToken: 83 | @pytest.mark.asyncio 84 | @respx.mock 85 | async def test_revoke_token(self, get_respx_call_args): 86 | request = respx.post(client.revoke_token_endpoint).mock( 87 | return_value=Response(200) 88 | ) 89 | await client.revoke_token("TOKEN", "TOKEN_TYPE_HINT") 90 | 91 | url, headers, content = await get_respx_call_args(request) 92 | assert headers["Content-Type"] == "application/x-www-form-urlencoded" 93 | assert headers["Accept"] == "application/json" 94 | assert "token=TOKEN" in content 95 | assert "token_type_hint=TOKEN_TYPE_HINT" in content 96 | 97 | @pytest.mark.asyncio 98 | @respx.mock 99 | async def test_revoke_token_error(self): 100 | respx.post(client.revoke_token_endpoint).mock( 101 | return_value=Response(400, json={"error": "message"}) 102 | ) 103 | 104 | with pytest.raises(RevokeTokenError) as excinfo: 105 | await client.revoke_token("TOKEN", "TOKEN_TYPE_HINT") 106 | assert isinstance(excinfo.value.response, Response) 107 | 108 | @pytest.mark.asyncio 109 | @respx.mock 110 | async def test_revoke_token_http_error(self): 111 | respx.post(client.revoke_token_endpoint).mock(side_effect=HTTPError("ERROR")) 112 | 113 | with pytest.raises(RevokeTokenError) as excinfo: 114 | await client.revoke_token("TOKEN", "TOKEN_TYPE_HINT") 115 | assert excinfo.value.response is None 116 | -------------------------------------------------------------------------------- /tests/test_clients_okta.py: -------------------------------------------------------------------------------- 1 | import respx 2 | from httpx import Response 3 | 4 | from httpx_oauth.clients.okta import OktaOAuth2 5 | 6 | OKTA_DOMAIN = "foo.okta.com" 7 | 8 | openid_configuration_response = { 9 | "issuer": "https://foo.okta.com", 10 | "authorization_endpoint": "https://foo.okta.com/oauth2/v1/authorize", 11 | "token_endpoint": "https://foo.okta.com/oauth2/v1/token", 12 | "userinfo_endpoint": "https://foo.okta.com/oauth2/v1/userinfo", 13 | "registration_endpoint": "https://foo.okta.com/oauth2/v1/clients", 14 | "jwks_uri": "https://foo.okta.com/oauth2/v1/keys", 15 | "response_types_supported": [ 16 | "code", 17 | "id_token", 18 | "code id_token", 19 | "code token", 20 | "id_token token", 21 | "code id_token token", 22 | ], 23 | "response_modes_supported": ["query", "fragment", "form_post", "okta_post_message"], 24 | "grant_types_supported": [ 25 | "authorization_code", 26 | "implicit", 27 | "refresh_token", 28 | "password", 29 | "urn:ietf:params:oauth:grant-type:device_code", 30 | ], 31 | "subject_types_supported": ["public"], 32 | "id_token_signing_alg_values_supported": ["RS256"], 33 | "scopes_supported": [ 34 | "openid", 35 | "email", 36 | "profile", 37 | "address", 38 | "phone", 39 | "offline_access", 40 | "groups", 41 | ], 42 | "token_endpoint_auth_methods_supported": [ 43 | "client_secret_basic", 44 | "client_secret_post", 45 | "client_secret_jwt", 46 | "private_key_jwt", 47 | "none", 48 | ], 49 | "claims_supported": [ 50 | "iss", 51 | "ver", 52 | "sub", 53 | "aud", 54 | "iat", 55 | "exp", 56 | "jti", 57 | "auth_time", 58 | "amr", 59 | "idp", 60 | "nonce", 61 | "name", 62 | "nickname", 63 | "preferred_username", 64 | "given_name", 65 | "middle_name", 66 | "family_name", 67 | "email", 68 | "email_verified", 69 | "profile", 70 | "zoneinfo", 71 | "locale", 72 | "address", 73 | "phone_number", 74 | "picture", 75 | "website", 76 | "gender", 77 | "birthdate", 78 | "updated_at", 79 | "at_hash", 80 | "c_hash", 81 | ], 82 | "code_challenge_methods_supported": ["S256"], 83 | "introspection_endpoint": "https://foo.okta.com/oauth2/v1/introspect", 84 | "introspection_endpoint_auth_methods_supported": [ 85 | "client_secret_basic", 86 | "client_secret_post", 87 | "client_secret_jwt", 88 | "private_key_jwt", 89 | "none", 90 | ], 91 | "revocation_endpoint": "https://foo.okta.com/oauth2/v1/revoke", 92 | "revocation_endpoint_auth_methods_supported": [ 93 | "client_secret_basic", 94 | "client_secret_post", 95 | "client_secret_jwt", 96 | "private_key_jwt", 97 | "none", 98 | ], 99 | "end_session_endpoint": "https://foo.okta.com/oauth2/v1/logout", 100 | "request_parameter_supported": True, 101 | "request_object_signing_alg_values_supported": [ 102 | "HS256", 103 | "HS384", 104 | "HS512", 105 | "RS256", 106 | "RS384", 107 | "RS512", 108 | "ES256", 109 | "ES384", 110 | "ES512", 111 | ], 112 | "device_authorization_endpoint": "https://foo.okta.com/oauth2/v1/device/authorize", 113 | } 114 | 115 | 116 | @respx.mock 117 | def test_okta_oauth2(): 118 | respx.get(f"https://{OKTA_DOMAIN}/.well-known/openid-configuration").mock( 119 | return_value=Response(200, json=openid_configuration_response) 120 | ) 121 | 122 | client = OktaOAuth2("CLIENT_ID", "CLIENT_SECRET", OKTA_DOMAIN) 123 | assert client.authorize_endpoint == f"https://{OKTA_DOMAIN}/oauth2/v1/authorize" 124 | assert client.access_token_endpoint == f"https://{OKTA_DOMAIN}/oauth2/v1/token" 125 | assert client.refresh_token_endpoint == f"https://{OKTA_DOMAIN}/oauth2/v1/token" 126 | assert client.revoke_token_endpoint == f"https://{OKTA_DOMAIN}/oauth2/v1/revoke" 127 | assert client.base_scopes == ["openid", "email"] 128 | assert client.name == "okta" 129 | -------------------------------------------------------------------------------- /tests/test_clients_openid.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | import respx 5 | from httpx import HTTPError, Response 6 | 7 | from httpx_oauth.clients.openid import OpenID, OpenIDConfigurationError 8 | from httpx_oauth.exceptions import GetIdEmailError 9 | 10 | openid_configuration_response = { 11 | "issuer": "https://example.fief.dev", 12 | "authorization_endpoint": "https://example.fief.dev/authorize", 13 | "token_endpoint": "https://example.fief.dev/api/token", 14 | "jwks_uri": "https://example.fief.dev/.well-known/jwks.json", 15 | "registration_endpoint": "https://example.fief.dev/register", 16 | "scopes_supported": ["openid", "offline_access"], 17 | "response_types_supported": [ 18 | "code", 19 | "code id_token", 20 | "code token", 21 | "code id_token token", 22 | ], 23 | "response_modes_supported": ["query", "fragment"], 24 | "grant_types_supported": ["authorization_code", "refresh_token"], 25 | "token_endpoint_auth_methods_supported": [ 26 | "client_secret_basic", 27 | "client_secret_post", 28 | ], 29 | "service_documentation": "https://docs.fief.dev", 30 | "code_challenge_methods_supported": ["plain", "S256"], 31 | "userinfo_endpoint": "https://example.fief.dev/api/userinfo", 32 | "subject_types_supported": ["public"], 33 | "id_token_signing_alg_values_supported": ["RS256"], 34 | "id_token_encryption_alg_values_supported": ["RSA-OAEP-256"], 35 | "id_token_encryption_enc_values_supported": ["A256CBC-HS512"], 36 | "userinfo_signing_alg_values_supported": ["none"], 37 | "claims_supported": ["email", "tenant_id"], 38 | "request_parameter_supported": False, 39 | } 40 | 41 | 42 | @pytest.fixture 43 | @respx.mock 44 | def client() -> OpenID: 45 | respx.get( 46 | re.compile("https://example.fief.dev/.well-known/openid-configuration") 47 | ).mock(return_value=Response(200, json=openid_configuration_response)) 48 | return OpenID( 49 | "CLIENT_ID", 50 | "CLIENT_SECRET", 51 | "https://example.fief.dev/.well-known/openid-configuration", 52 | ) 53 | 54 | 55 | @respx.mock 56 | def test_openid_configuration_error(): 57 | respx.get( 58 | re.compile("https://example.fief.dev/.well-known/openid-configuration") 59 | ).mock(return_value=Response(400, json={"error": "message"})) 60 | with pytest.raises(OpenIDConfigurationError): 61 | OpenID( 62 | "CLIENT_ID", 63 | "CLIENT_SECRET", 64 | "https://example.fief.dev/.well-known/openid-configuration", 65 | ) 66 | 67 | 68 | @respx.mock 69 | def test_openid_configuration_http_error(): 70 | respx.get( 71 | re.compile("https://example.fief.dev/.well-known/openid-configuration") 72 | ).mock(side_effect=HTTPError("ERROR")) 73 | with pytest.raises(OpenIDConfigurationError): 74 | OpenID( 75 | "CLIENT_ID", 76 | "CLIENT_SECRET", 77 | "https://example.fief.dev/.well-known/openid-configuration", 78 | ) 79 | 80 | 81 | @respx.mock 82 | def test_openid(client: OpenID): 83 | assert client.authorize_endpoint == "https://example.fief.dev/authorize" 84 | assert client.access_token_endpoint == "https://example.fief.dev/api/token" 85 | assert client.refresh_token_endpoint == "https://example.fief.dev/api/token" 86 | assert client.revoke_token_endpoint is None 87 | assert client.base_scopes == ["openid", "email"] 88 | assert client.name == "openid" 89 | 90 | 91 | userinfo_response = {"sub": 42, "email": "arthur@camelot.bt"} 92 | 93 | 94 | class TestOpenIdGetIdEmail: 95 | @pytest.mark.asyncio 96 | @respx.mock 97 | async def test_success(self, client: OpenID, get_respx_call_args): 98 | request = respx.get("https://example.fief.dev/api/userinfo").mock( 99 | return_value=Response(200, json=userinfo_response) 100 | ) 101 | 102 | user_id, user_email = await client.get_id_email("TOKEN") 103 | url, headers, content = await get_respx_call_args(request) 104 | 105 | assert headers["Authorization"] == "Bearer TOKEN" 106 | assert headers["Accept"] == "application/json" 107 | assert user_id == "42" 108 | assert user_email == "arthur@camelot.bt" 109 | 110 | @pytest.mark.asyncio 111 | @respx.mock 112 | async def test_error(self, client: OpenID): 113 | respx.get(re.compile("https://example.fief.dev/api/userinfo")).mock( 114 | return_value=Response(400, json={"error": "message"}) 115 | ) 116 | 117 | with pytest.raises(GetIdEmailError) as excinfo: 118 | await client.get_id_email("TOKEN") 119 | 120 | assert isinstance(excinfo.value.response, Response) 121 | -------------------------------------------------------------------------------- /tests/test_clients_reddit.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import re 3 | from typing import Callable 4 | from urllib.parse import parse_qsl 5 | 6 | import httpx 7 | import pytest 8 | import respx 9 | 10 | import httpx_oauth.clients.reddit as reddit 11 | import httpx_oauth.oauth2 as oauth 12 | from httpx_oauth.exceptions import GetIdEmailError 13 | 14 | FAKE_CLIENT_ID = "fake-client-id-1234567" 15 | FAKE_CLIENT_SECRET = "fake-client-secret-12345678901" 16 | FAKE_AUTHORIZATION_CODE = "fake-authorization-code-123456" 17 | FAKE_REDIRECT_URI = "http://example.com/redirect/" 18 | FAKE_ACCESS_TOKEN = "12345678-fake-access-token-123456789012" 19 | FAKE_REFRESH_TOKEN = "12345678-fake-refresh-token-12345678901" 20 | 21 | 22 | client = reddit.RedditOAuth2(FAKE_CLIENT_ID, FAKE_CLIENT_SECRET) 23 | 24 | response_unauthorized = httpx.Response( 25 | httpx.codes.UNAUTHORIZED, 26 | json={ 27 | "error": httpx.codes.UNAUTHORIZED, 28 | "message": "Unauthorized", 29 | }, 30 | ) 31 | 32 | 33 | def b64encode(s: str) -> str: 34 | return base64.b64encode(s.encode("utf-8")).decode("utf-8") 35 | 36 | 37 | def require_auth(response: httpx.Response) -> Callable[[httpx.Request], httpx.Response]: 38 | def require_auth_inner(request: httpx.Request) -> httpx.Response: 39 | expected_auth_header = ( 40 | f"Basic {b64encode(f'{FAKE_CLIENT_ID}:{FAKE_CLIENT_SECRET}')}" 41 | ) 42 | 43 | if request.headers.get("Authorization", None) != expected_auth_header: 44 | return response_unauthorized 45 | 46 | return response 47 | 48 | return require_auth_inner 49 | 50 | 51 | def test_reddit_defaults(): 52 | assert client.authorize_endpoint == "https://www.reddit.com/api/v1/authorize" 53 | assert client.access_token_endpoint == "https://www.reddit.com/api/v1/access_token" 54 | assert client.refresh_token_endpoint == "https://www.reddit.com/api/v1/access_token" 55 | assert client.revoke_token_endpoint == "https://www.reddit.com/api/v1/revoke_token" 56 | assert client.base_scopes == ["identity"] 57 | assert client.name == "reddit" 58 | 59 | 60 | @pytest.mark.asyncio 61 | class TestRedditGetAccessToken: 62 | response_success = httpx.Response( 63 | httpx.codes.OK, 64 | json={ 65 | "access_token": FAKE_ACCESS_TOKEN, 66 | "token_type": "bearer", 67 | "expires_in": 3600, 68 | "scope": "identity", 69 | }, 70 | ) 71 | 72 | response_error = httpx.Response( 73 | httpx.codes.OK, # sic, Reddit returns 200 upon errors on this endpoint 74 | json={ 75 | "error": "invalid_grant", 76 | }, 77 | ) 78 | 79 | @respx.mock 80 | async def test_bad_auth(self): 81 | respx.post(re.compile(f"^{reddit.ACCESS_TOKEN_ENDPOINT}")).mock( 82 | side_effect=require_auth(self.response_success), 83 | ) 84 | 85 | invalid_client = reddit.RedditOAuth2( 86 | "INVALID_CLIENT_ID", "INVALID_CLIENT_SECRET" 87 | ) 88 | 89 | with pytest.raises(oauth.GetAccessTokenError) as excinfo: 90 | await invalid_client.get_access_token( 91 | FAKE_AUTHORIZATION_CODE, FAKE_REDIRECT_URI 92 | ) 93 | 94 | assert isinstance(excinfo.value.response, httpx.Response) 95 | 96 | @respx.mock 97 | async def test_success(self, get_respx_call_args): 98 | request = respx.post(re.compile(f"^{reddit.ACCESS_TOKEN_ENDPOINT}")).mock( 99 | side_effect=require_auth(self.response_success), 100 | ) 101 | 102 | token = await client.get_access_token( 103 | FAKE_AUTHORIZATION_CODE, FAKE_REDIRECT_URI 104 | ) 105 | url, headers, content = await get_respx_call_args(request) 106 | content_url_decoded = parse_qsl(content) 107 | 108 | assert ("grant_type", "authorization_code") in content_url_decoded 109 | assert ("code", FAKE_AUTHORIZATION_CODE) in content_url_decoded 110 | assert ("redirect_uri", FAKE_REDIRECT_URI) in content_url_decoded 111 | 112 | # Check the subset, since httpx-oauth lib also adds a calculated "expires_at" 113 | assert self.response_success.json().items() <= token.items() 114 | 115 | @respx.mock 116 | async def test_error(self): 117 | respx.post(re.compile(f"^{reddit.ACCESS_TOKEN_ENDPOINT}")).mock( 118 | side_effect=require_auth(self.response_error) 119 | ) 120 | 121 | with pytest.raises(oauth.GetAccessTokenError) as excinfo: 122 | await client.get_access_token( 123 | "INVALID_AUTHORIZATION_CODE", FAKE_REDIRECT_URI 124 | ) 125 | 126 | assert isinstance(excinfo.value.message, str) 127 | 128 | 129 | @pytest.mark.asyncio 130 | class TestRedditRevokeToken: 131 | @respx.mock 132 | async def test_bad_auth(self): 133 | respx.post(re.compile(f"^{reddit.REVOKE_ENDPOINT}")).mock( 134 | side_effect=require_auth(httpx.Response(httpx.codes.OK)), 135 | ) 136 | 137 | invalid_client = reddit.RedditOAuth2( 138 | "INVALID_CLIENT_ID", "INVALID_CLIENT_SECRET" 139 | ) 140 | 141 | with pytest.raises(oauth.RevokeTokenError): 142 | await invalid_client.revoke_token(FAKE_REFRESH_TOKEN, "refresh_token") 143 | 144 | @respx.mock 145 | async def test_success(self, get_respx_call_args): 146 | request = respx.post(re.compile(f"^{reddit.REVOKE_ENDPOINT}")).mock( 147 | side_effect=require_auth(httpx.Response(httpx.codes.OK)), 148 | ) 149 | 150 | await client.revoke_token(FAKE_REFRESH_TOKEN, "refresh_token") 151 | 152 | url, headers, content = await get_respx_call_args(request) 153 | content_url_decoded = parse_qsl(content) 154 | 155 | assert ("token", FAKE_REFRESH_TOKEN) in content_url_decoded 156 | assert ("token_type_hint", "refresh_token") in content_url_decoded 157 | 158 | 159 | @pytest.mark.asyncio 160 | class TestRedditGetIdEmail: 161 | @respx.mock 162 | async def test_success(self, load_mock, get_respx_call_args): 163 | request = respx.get(re.compile(f"^{reddit.IDENTITY_ENDPOINT}")).mock( 164 | return_value=httpx.Response( 165 | httpx.codes.OK, json=load_mock("reddit_success_identity") 166 | ) 167 | ) 168 | 169 | user_id, user_email = await client.get_id_email(FAKE_ACCESS_TOKEN) 170 | url, headers, content = await get_respx_call_args(request) 171 | 172 | assert headers["Authorization"] == f"Bearer {FAKE_ACCESS_TOKEN}" 173 | assert user_id == "TheQuickBrownCatJumpsOverTheLazyDog" 174 | assert user_email is None 175 | 176 | @respx.mock 177 | async def test_error(self): 178 | respx.get(re.compile(f"^{reddit.IDENTITY_ENDPOINT}")).mock( 179 | # Reddit often returns HTML in case of a bad request 180 | return_value=httpx.Response(httpx.codes.BAD_REQUEST, html="") 181 | ) 182 | 183 | with pytest.raises(GetIdEmailError) as excinfo: 184 | await client.get_id_email(FAKE_ACCESS_TOKEN) 185 | 186 | assert isinstance(excinfo.value.response, httpx.Response) 187 | -------------------------------------------------------------------------------- /tests/test_clients_shopify.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import respx 3 | from httpx import Response 4 | 5 | from httpx_oauth.clients.shopify import ShopifyOAuth2 6 | from httpx_oauth.exceptions import GetIdEmailError 7 | 8 | client = ShopifyOAuth2("CLIENT_ID", "CLIENT_SECRET", "my-shop") 9 | 10 | 11 | def test_shopify_oauth2(): 12 | assert ( 13 | client.authorize_endpoint 14 | == "https://my-shop.myshopify.com/admin/oauth/authorize" 15 | ) 16 | assert ( 17 | client.access_token_endpoint 18 | == "https://my-shop.myshopify.com/admin/oauth/access_token" 19 | ) 20 | assert client.refresh_token_endpoint is None 21 | assert client.revoke_token_endpoint is None 22 | assert client.base_scopes == ["read_orders"] 23 | assert client.name == "shopify" 24 | 25 | 26 | profile_response = { 27 | "shop": { 28 | "id": 548380009, 29 | "name": "John Smith Test Store", 30 | "email": "j.smith@example.com", 31 | "domain": "shop.apple.com", 32 | "province": "California", 33 | "country": "US", 34 | "address1": "1 Infinite Loop", 35 | "zip": "95014", 36 | "city": "Cupertino", 37 | "source": None, 38 | "phone": "1231231234", 39 | "latitude": 45.45, 40 | "longitude": -75.43, 41 | "primary_locale": "en", 42 | "address2": "Suite 100", 43 | "created_at": "2007-12-31T19:00:00-05:00", 44 | "updated_at": "2023-06-14T14:21:51-04:00", 45 | "country_code": "US", 46 | "country_name": "United States", 47 | "currency": "USD", 48 | "customer_email": "customers@apple.com", 49 | "timezone": "(GMT-05:00) Eastern Time (US & Canada)", 50 | "iana_timezone": "America/New_York", 51 | "shop_owner": "John Smith", 52 | "money_format": "${{amount}}", 53 | "money_with_currency_format": "${{amount}} USD", 54 | "weight_unit": "lb", 55 | "province_code": "CA", 56 | "taxes_included": None, 57 | "auto_configure_tax_inclusivity": None, 58 | "tax_shipping": None, 59 | "county_taxes": True, 60 | "plan_display_name": "Shopify Plus", 61 | "plan_name": "enterprise", 62 | "has_discounts": True, 63 | "has_gift_cards": True, 64 | "myshopify_domain": "jsmith.myshopify.com", 65 | "google_apps_domain": None, 66 | "google_apps_login_enabled": None, 67 | "money_in_emails_format": "${{amount}}", 68 | "money_with_currency_in_emails_format": "${{amount}} USD", 69 | "eligible_for_payments": True, 70 | "requires_extra_payments_agreement": False, 71 | "password_enabled": False, 72 | "has_storefront": True, 73 | "finances": True, 74 | "primary_location_id": 655441491, 75 | "cookie_consent_level": "implicit", 76 | "visitor_tracking_consent_preference": "allow_all", 77 | "checkout_api_supported": True, 78 | "multi_location_enabled": True, 79 | "setup_required": False, 80 | "pre_launch_enabled": False, 81 | "enabled_presentment_currencies": ["USD"], 82 | "transactional_sms_disabled": False, 83 | "marketing_sms_consent_enabled_at_checkout": False, 84 | } 85 | } 86 | 87 | 88 | class TestShopifyGetIdEmail: 89 | @pytest.mark.asyncio 90 | @respx.mock 91 | async def test_success(self, get_respx_call_args): 92 | request = respx.get(client.profile_endpoint).mock( 93 | return_value=Response(200, json=profile_response) 94 | ) 95 | 96 | user_id, user_email = await client.get_id_email("TOKEN") 97 | url, headers, content = await get_respx_call_args(request) 98 | 99 | assert headers["X-Shopify-Access-Token"] == "TOKEN" 100 | assert user_id == "548380009" 101 | assert user_email == "j.smith@example.com" 102 | 103 | @pytest.mark.asyncio 104 | @respx.mock 105 | async def test_error(self): 106 | respx.get(client.profile_endpoint).mock( 107 | return_value=Response(400, json={"error": "message"}) 108 | ) 109 | 110 | with pytest.raises(GetIdEmailError) as excinfo: 111 | await client.get_id_email("TOKEN") 112 | 113 | assert isinstance(excinfo.value.response, Response) 114 | -------------------------------------------------------------------------------- /tests/test_integrations_fastapi.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import Depends, FastAPI 3 | from pytest_mock import MockerFixture 4 | from starlette import status 5 | from starlette.testclient import TestClient 6 | 7 | from httpx_oauth.integrations.fastapi import OAuth2AuthorizeCallback 8 | from httpx_oauth.oauth2 import GetAccessTokenError, OAuth2 9 | 10 | CLIENT_ID = "CLIENT_ID" 11 | CLIENT_SECRET = "CLIENT_SECRET" 12 | AUTHORIZE_ENDPOINT = "https://www.camelot.bt/authorize" 13 | ACCESS_TOKEN_ENDPOINT = "https://www.camelot.bt/access-token" 14 | REDIRECT_URL = "https://www.tintagel.bt/callback" 15 | ROUTE_NAME = "callback" 16 | 17 | client = OAuth2(CLIENT_ID, CLIENT_SECRET, AUTHORIZE_ENDPOINT, ACCESS_TOKEN_ENDPOINT) 18 | oauth2_authorize_callback_route_name = OAuth2AuthorizeCallback( 19 | client, route_name=ROUTE_NAME 20 | ) 21 | oauth2_authorize_callback_redirect_url = OAuth2AuthorizeCallback( 22 | client, redirect_url=REDIRECT_URL 23 | ) 24 | app = FastAPI() 25 | 26 | 27 | @app.get("/authorize-route-name") 28 | async def authorize_route_name( 29 | access_token_state=Depends(oauth2_authorize_callback_route_name), 30 | ): 31 | return access_token_state 32 | 33 | 34 | @app.get("/authorize-redirect-url") 35 | async def authorize_redirect_url( 36 | access_token_state=Depends(oauth2_authorize_callback_redirect_url), 37 | ): 38 | return access_token_state 39 | 40 | 41 | @app.get("/callback", name="callback") 42 | async def callback(): 43 | pass 44 | 45 | 46 | test_client = TestClient(app) 47 | 48 | 49 | @pytest.mark.parametrize( 50 | "route,expected_redirect_url", 51 | [ 52 | ("/authorize-route-name", "http://testserver/callback"), 53 | ("/authorize-redirect-url", "https://www.tintagel.bt/callback"), 54 | ], 55 | ) 56 | class TestOAuth2AuthorizeCallback: 57 | def test_oauth2_authorize_missing_code(self, route, expected_redirect_url): 58 | response = test_client.get(route) 59 | assert response.status_code == status.HTTP_400_BAD_REQUEST 60 | 61 | def test_oauth2_authorize_error(self, route, expected_redirect_url): 62 | response = test_client.get(route, params={"error": "access_denied"}) 63 | assert response.status_code == status.HTTP_400_BAD_REQUEST 64 | assert response.json() == {"detail": "access_denied"} 65 | 66 | def test_oauth2_authorize_get_access_token_error( 67 | self, mocker: MockerFixture, route, expected_redirect_url 68 | ): 69 | get_access_token_mock = mocker.patch.object( 70 | client, "get_access_token", side_effect=GetAccessTokenError("ERROR") 71 | ) 72 | 73 | response = test_client.get(route, params={"code": "CODE"}) 74 | 75 | get_access_token_mock.assert_called_once_with( 76 | "CODE", expected_redirect_url, None 77 | ) 78 | assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR 79 | assert response.json() == {"detail": "ERROR"} 80 | 81 | def test_oauth2_authorize_without_state( 82 | self, patch_async_method, route, expected_redirect_url 83 | ): 84 | patch_async_method(client, "get_access_token", return_value="ACCESS_TOKEN") 85 | 86 | response = test_client.get(route, params={"code": "CODE"}) 87 | 88 | client.get_access_token.assert_called() 89 | client.get_access_token.assert_called_once_with( 90 | "CODE", expected_redirect_url, None 91 | ) 92 | assert response.status_code == status.HTTP_200_OK 93 | assert response.json() == ["ACCESS_TOKEN", None] 94 | 95 | def test_oauth2_authorize_code_verifier_without_state( 96 | self, patch_async_method, route, expected_redirect_url 97 | ): 98 | patch_async_method(client, "get_access_token", return_value="ACCESS_TOKEN") 99 | 100 | response = test_client.get( 101 | route, params={"code": "CODE", "code_verifier": "CODE_VERIFIER"} 102 | ) 103 | 104 | client.get_access_token.assert_called() 105 | client.get_access_token.assert_called_once_with( 106 | "CODE", expected_redirect_url, "CODE_VERIFIER" 107 | ) 108 | assert response.status_code == status.HTTP_200_OK 109 | assert response.json() == ["ACCESS_TOKEN", None] 110 | 111 | def test_oauth2_authorize_with_state( 112 | self, patch_async_method, route, expected_redirect_url 113 | ): 114 | patch_async_method(client, "get_access_token", return_value="ACCESS_TOKEN") 115 | 116 | response = test_client.get(route, params={"code": "CODE", "state": "STATE"}) 117 | 118 | client.get_access_token.assert_called() 119 | client.get_access_token.assert_called_once_with( 120 | "CODE", expected_redirect_url, None 121 | ) 122 | assert response.status_code == status.HTTP_200_OK 123 | assert response.json() == ["ACCESS_TOKEN", "STATE"] 124 | 125 | def test_oauth2_authorize_with_state_and_code_verifier( 126 | self, patch_async_method, route, expected_redirect_url 127 | ): 128 | patch_async_method(client, "get_access_token", return_value="ACCESS_TOKEN") 129 | 130 | response = test_client.get( 131 | route, 132 | params={"code": "CODE", "state": "STATE", "code_verifier": "CODE_VERIFIER"}, 133 | ) 134 | 135 | client.get_access_token.assert_called() 136 | client.get_access_token.assert_called_once_with( 137 | "CODE", expected_redirect_url, "CODE_VERIFIER" 138 | ) 139 | assert response.status_code == status.HTTP_200_OK 140 | assert response.json() == ["ACCESS_TOKEN", "STATE"] 141 | -------------------------------------------------------------------------------- /tests/test_oauth2.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pytest 4 | import respx 5 | from httpx import HTTPError, Response 6 | 7 | from httpx_oauth.oauth2 import ( 8 | GetAccessTokenError, 9 | MissingRevokeTokenAuthMethodError, 10 | NotSupportedAuthMethodError, 11 | OAuth2, 12 | OAuth2Token, 13 | RefreshTokenError, 14 | RefreshTokenNotSupportedError, 15 | RevokeTokenError, 16 | RevokeTokenNotSupportedError, 17 | ) 18 | 19 | CLIENT_ID = "CLIENT_ID" 20 | CLIENT_SECRET = "CLIENT_SECRET" 21 | AUTHORIZE_ENDPOINT = "https://www.camelot.bt/authorize" 22 | ACCESS_TOKEN_ENDPOINT = "https://www.camelot.bt/access-token" 23 | REDIRECT_URI = "https://www.tintagel.bt/oauth-callback" 24 | REFRESH_TOKEN_ENDPOINT = "https://www.camelot.bt/refresh" 25 | REVOKE_TOKEN_ENDPOINT = "https://www.camelot.bt/revoke" 26 | 27 | 28 | @pytest.fixture(scope="module", params=["client_secret_basic", "client_secret_post"]) 29 | def client(request: pytest.FixtureRequest) -> OAuth2: 30 | return OAuth2( 31 | CLIENT_ID, 32 | CLIENT_SECRET, 33 | AUTHORIZE_ENDPOINT, 34 | ACCESS_TOKEN_ENDPOINT, 35 | token_endpoint_auth_method=request.param, 36 | ) 37 | 38 | 39 | @pytest.fixture(scope="module", params=["client_secret_basic", "client_secret_post"]) 40 | def client_refresh(request: pytest.FixtureRequest) -> OAuth2: 41 | return OAuth2( 42 | CLIENT_ID, 43 | CLIENT_SECRET, 44 | AUTHORIZE_ENDPOINT, 45 | ACCESS_TOKEN_ENDPOINT, 46 | refresh_token_endpoint=REFRESH_TOKEN_ENDPOINT, 47 | token_endpoint_auth_method=request.param, 48 | ) 49 | 50 | 51 | @pytest.fixture(scope="module", params=["client_secret_basic", "client_secret_post"]) 52 | def client_revoke(request: pytest.FixtureRequest) -> OAuth2: 53 | return OAuth2( 54 | CLIENT_ID, 55 | CLIENT_SECRET, 56 | AUTHORIZE_ENDPOINT, 57 | ACCESS_TOKEN_ENDPOINT, 58 | revoke_token_endpoint=REVOKE_TOKEN_ENDPOINT, 59 | token_endpoint_auth_method=request.param, 60 | revocation_endpoint_auth_method=request.param, 61 | ) 62 | 63 | 64 | def test_not_supported_auth_method() -> None: 65 | with pytest.raises(NotSupportedAuthMethodError): 66 | OAuth2( 67 | CLIENT_ID, 68 | CLIENT_SECRET, 69 | AUTHORIZE_ENDPOINT, 70 | ACCESS_TOKEN_ENDPOINT, 71 | token_endpoint_auth_method="invalid", # type: ignore 72 | ) 73 | 74 | 75 | def test_missing_revoke_token_auth_method() -> None: 76 | with pytest.raises(MissingRevokeTokenAuthMethodError): 77 | OAuth2( 78 | CLIENT_ID, 79 | CLIENT_SECRET, 80 | AUTHORIZE_ENDPOINT, 81 | ACCESS_TOKEN_ENDPOINT, 82 | revoke_token_endpoint=REVOKE_TOKEN_ENDPOINT, 83 | ) 84 | 85 | 86 | class TestOAuth2Token: 87 | @pytest.mark.parametrize( 88 | "expires_at,expired", [(0, True), (time.time() + 3600, False)] 89 | ) 90 | def test_expires_at(self, expires_at, expired): 91 | token = OAuth2Token({"access_token": "ACCESS_TOKEN", "expires_at": expires_at}) 92 | 93 | assert token["access_token"] == "ACCESS_TOKEN" 94 | assert token.is_expired() is expired 95 | 96 | def test_expires_in(self): 97 | token = OAuth2Token({"access_token": "ACCESS_TOKEN", "expires_in": 3600}) 98 | 99 | assert token["access_token"] == "ACCESS_TOKEN" 100 | assert "expires_at" in token 101 | assert token.is_expired() is False 102 | 103 | def test_no_expire(self): 104 | token = OAuth2Token({"access_token": "ACCESS_TOKEN"}) 105 | 106 | assert token["access_token"] == "ACCESS_TOKEN" 107 | assert token.is_expired() is False 108 | 109 | 110 | @pytest.mark.asyncio 111 | class TestGetAuthorizationURL: 112 | async def test_get_authorization_url(self, client: OAuth2): 113 | authorization_url = await client.get_authorization_url(REDIRECT_URI) 114 | assert authorization_url.startswith("https://www.camelot.bt/authorize") 115 | assert "response_type=code" in authorization_url 116 | assert f"client_id={CLIENT_ID}" in authorization_url 117 | assert ( 118 | "redirect_uri=https%3A%2F%2Fwww.tintagel.bt%2Foauth-callback" 119 | in authorization_url 120 | ) 121 | 122 | async def test_get_authorization_url_with_state(self, client: OAuth2): 123 | authorization_url = await client.get_authorization_url( 124 | REDIRECT_URI, 125 | state="STATE", 126 | ) 127 | assert "state=STATE" in authorization_url 128 | 129 | async def test_get_authorization_url_with_scopes(self, client: OAuth2): 130 | authorization_url = await client.get_authorization_url( 131 | REDIRECT_URI, 132 | scope=["SCOPE1", "SCOPE2", "SCOPE3"], 133 | ) 134 | assert "scope=SCOPE1+SCOPE2+SCOPE3" in authorization_url 135 | 136 | async def test_get_authorization_url_with_plain_code_challenge( 137 | self, client: OAuth2 138 | ): 139 | authorization_url = await client.get_authorization_url( 140 | REDIRECT_URI, 141 | code_challenge="CODE_CHALLENGE", 142 | code_challenge_method="plain", 143 | ) 144 | assert "code_challenge=CODE_CHALLENGE" in authorization_url 145 | assert "code_challenge_method=plain" in authorization_url 146 | 147 | @pytest.mark.asyncio 148 | async def test_get_authorization_url_with_extras_params(self, client: OAuth2): 149 | authorization_url = await client.get_authorization_url( 150 | REDIRECT_URI, 151 | extras_params={"PARAM1": "VALUE1", "PARAM2": "VALUE2"}, 152 | ) 153 | assert "PARAM1=VALUE1" in authorization_url 154 | assert "PARAM2=VALUE2" in authorization_url 155 | 156 | 157 | @pytest.mark.asyncio 158 | class TestGetAccessToken: 159 | @respx.mock 160 | async def test_get_access_token( 161 | self, load_mock, get_respx_call_args, client: OAuth2 162 | ): 163 | request = respx.post(client.access_token_endpoint).mock( 164 | return_value=Response(200, json=load_mock("google_success_access_token")) 165 | ) 166 | access_token = await client.get_access_token( 167 | "CODE", REDIRECT_URI, "CODE_VERIFIER" 168 | ) 169 | 170 | url, headers, content = await get_respx_call_args(request) 171 | assert headers["Content-Type"] == "application/x-www-form-urlencoded" 172 | assert headers["Accept"] == "application/json" 173 | assert "grant_type=authorization_code" in content 174 | assert "code=CODE" in content 175 | assert "code_verifier=CODE_VERIFIER" in content 176 | assert "redirect_uri=https%3A%2F%2Fwww.tintagel.bt%2Foauth-callback" in content 177 | 178 | if client.token_endpoint_auth_method == "client_secret_basic": 179 | assert "Authorization" in headers 180 | assert headers["Authorization"].startswith("Basic ") 181 | elif client.token_endpoint_auth_method == "client_secret_post": 182 | assert f"client_id={CLIENT_ID}" in content 183 | assert f"client_secret={CLIENT_SECRET}" in content 184 | 185 | assert type(access_token) is OAuth2Token 186 | assert "access_token" in access_token 187 | assert "token_type" in access_token 188 | assert access_token.is_expired() is False 189 | 190 | @respx.mock 191 | async def test_get_access_token_error(self, load_mock, client: OAuth2): 192 | respx.post(client.access_token_endpoint).mock( 193 | return_value=Response(400, json=load_mock("error")) 194 | ) 195 | 196 | with pytest.raises(GetAccessTokenError) as excinfo: 197 | await client.get_access_token("CODE", REDIRECT_URI) 198 | assert isinstance(excinfo.value.response, Response) 199 | 200 | @respx.mock 201 | async def test_get_access_token_http_error(self, client: OAuth2): 202 | respx.post(client.access_token_endpoint).mock(side_effect=HTTPError("ERROR")) 203 | 204 | with pytest.raises(GetAccessTokenError) as excinfo: 205 | await client.get_access_token("CODE", REDIRECT_URI) 206 | assert excinfo.value.response is None 207 | 208 | @respx.mock 209 | async def test_get_access_token_json_error(self, client: OAuth2): 210 | respx.post(client.access_token_endpoint).mock( 211 | return_value=Response(200, text="NOT JSON") 212 | ) 213 | 214 | with pytest.raises(GetAccessTokenError) as excinfo: 215 | await client.get_access_token("CODE", REDIRECT_URI) 216 | assert isinstance(excinfo.value.response, Response) 217 | 218 | 219 | @pytest.mark.asyncio 220 | class TestRefreshToken: 221 | @respx.mock 222 | async def test_unsupported_refresh_token(self, client: OAuth2): 223 | with pytest.raises(RefreshTokenNotSupportedError): 224 | await client.refresh_token("REFRESH_TOKEN") 225 | 226 | @respx.mock 227 | async def test_refresh_token( 228 | self, load_mock, get_respx_call_args, client_refresh: OAuth2 229 | ): 230 | request = respx.post(client_refresh.refresh_token_endpoint).mock( 231 | return_value=Response(200, json=load_mock("google_success_refresh_token")) 232 | ) 233 | access_token = await client_refresh.refresh_token("REFRESH_TOKEN") 234 | 235 | url, headers, content = await get_respx_call_args(request) 236 | assert headers["Content-Type"] == "application/x-www-form-urlencoded" 237 | assert headers["Accept"] == "application/json" 238 | assert "grant_type=refresh_token" in content 239 | assert "refresh_token=REFRESH_TOKEN" in content 240 | 241 | if client_refresh.token_endpoint_auth_method == "client_secret_basic": 242 | assert "Authorization" in headers 243 | assert headers["Authorization"].startswith("Basic ") 244 | elif client_refresh.token_endpoint_auth_method == "client_secret_post": 245 | assert f"client_id={CLIENT_ID}" in content 246 | assert f"client_secret={CLIENT_SECRET}" in content 247 | 248 | assert type(access_token) is OAuth2Token 249 | assert "access_token" in access_token 250 | assert "token_type" in access_token 251 | assert access_token.is_expired() is False 252 | 253 | @respx.mock 254 | async def test_refresh_token_error(self, load_mock, client_refresh: OAuth2): 255 | respx.post(client_refresh.refresh_token_endpoint).mock( 256 | return_value=Response(400, json=load_mock("error")) 257 | ) 258 | 259 | with pytest.raises(RefreshTokenError) as excinfo: 260 | await client_refresh.refresh_token("REFRESH_TOKEN") 261 | assert isinstance(excinfo.value.response, Response) 262 | 263 | @respx.mock 264 | async def test_refresh_token_http_error(self, client_refresh: OAuth2): 265 | respx.post(client_refresh.refresh_token_endpoint).mock( 266 | side_effect=HTTPError("ERROR") 267 | ) 268 | 269 | with pytest.raises(RefreshTokenError) as excinfo: 270 | await client_refresh.refresh_token("REFRESH_TOKEN") 271 | assert excinfo.value.response is None 272 | 273 | @respx.mock 274 | async def test_refresh_token_json_error(self, client_refresh: OAuth2): 275 | respx.post(client_refresh.refresh_token_endpoint).mock( 276 | return_value=Response(200, text="NOT JSON") 277 | ) 278 | 279 | with pytest.raises(RefreshTokenError) as excinfo: 280 | await client_refresh.refresh_token("REFRESH_TOKEN") 281 | assert isinstance(excinfo.value.response, Response) 282 | 283 | 284 | @pytest.mark.asyncio 285 | class TestRevokeToken: 286 | @respx.mock 287 | async def test_unsupported_revoke_token(self, client: OAuth2): 288 | with pytest.raises(RevokeTokenNotSupportedError): 289 | await client.revoke_token("TOKEN") 290 | 291 | @respx.mock 292 | async def test_revoke_token( 293 | self, load_mock, get_respx_call_args, client_revoke: OAuth2 294 | ): 295 | request = respx.post(client_revoke.revoke_token_endpoint).mock( 296 | return_value=Response(200) 297 | ) 298 | await client_revoke.revoke_token("TOKEN", "TOKEN_TYPE_HINT") 299 | 300 | url, headers, content = await get_respx_call_args(request) 301 | assert headers["Content-Type"] == "application/x-www-form-urlencoded" 302 | assert headers["Accept"] == "application/json" 303 | assert "token=TOKEN" in content 304 | assert "token_type_hint=TOKEN_TYPE_HINT" in content 305 | 306 | if client_revoke.revocation_endpoint_auth_method == "client_secret_basic": 307 | assert "Authorization" in headers 308 | assert headers["Authorization"].startswith("Basic ") 309 | elif client_revoke.revocation_endpoint_auth_method == "client_secret_post": 310 | assert f"client_id={CLIENT_ID}" in content 311 | assert f"client_secret={CLIENT_SECRET}" in content 312 | 313 | @respx.mock 314 | async def test_revoke_token_error(self, load_mock, client_revoke: OAuth2): 315 | respx.post(client_revoke.revoke_token_endpoint).mock( 316 | return_value=Response(400, json=load_mock("error")) 317 | ) 318 | 319 | with pytest.raises(RevokeTokenError) as excinfo: 320 | await client_revoke.revoke_token("TOKEN", "TOKEN_TYPE_HINT") 321 | assert isinstance(excinfo.value.response, Response) 322 | 323 | @respx.mock 324 | async def test_revoke_token_http_error(self, client_revoke: OAuth2): 325 | respx.post(client_revoke.revoke_token_endpoint).mock( 326 | side_effect=HTTPError("ERROR") 327 | ) 328 | 329 | with pytest.raises(RevokeTokenError) as excinfo: 330 | await client_revoke.revoke_token("TOKEN", "TOKEN_TYPE_HINT") 331 | assert excinfo.value.response is None 332 | 333 | 334 | @pytest.mark.asyncio 335 | class TestGetProfile: 336 | async def test_not_implemented(self, client: OAuth2): 337 | with pytest.raises(NotImplementedError): 338 | await client.get_profile("TOKEN") 339 | 340 | 341 | @pytest.mark.asyncio 342 | class TestGetIdEmail: 343 | async def test_not_implemented(self, client: OAuth2): 344 | with pytest.raises(NotImplementedError): 345 | await client.get_id_email("TOKEN") 346 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.9" 3 | 4 | [[package]] 5 | name = "anyio" 6 | version = "4.7.0" 7 | source = { registry = "https://pypi.org/simple" } 8 | dependencies = [ 9 | { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 10 | { name = "idna" }, 11 | { name = "sniffio" }, 12 | { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 13 | ] 14 | sdist = { url = "https://files.pythonhosted.org/packages/f6/40/318e58f669b1a9e00f5c4453910682e2d9dd594334539c7b7817dabb765f/anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48", size = 177076 } 15 | wheels = [ 16 | { url = "https://files.pythonhosted.org/packages/a0/7a/4daaf3b6c08ad7ceffea4634ec206faeff697526421c20f07628c7372156/anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352", size = 93052 }, 17 | ] 18 | 19 | [[package]] 20 | name = "certifi" 21 | version = "2024.12.14" 22 | source = { registry = "https://pypi.org/simple" } 23 | sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 } 24 | wheels = [ 25 | { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 }, 26 | ] 27 | 28 | [[package]] 29 | name = "exceptiongroup" 30 | version = "1.2.2" 31 | source = { registry = "https://pypi.org/simple" } 32 | sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } 33 | wheels = [ 34 | { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, 35 | ] 36 | 37 | [[package]] 38 | name = "h11" 39 | version = "0.14.0" 40 | source = { registry = "https://pypi.org/simple" } 41 | sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } 42 | wheels = [ 43 | { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, 44 | ] 45 | 46 | [[package]] 47 | name = "httpcore" 48 | version = "1.0.7" 49 | source = { registry = "https://pypi.org/simple" } 50 | dependencies = [ 51 | { name = "certifi" }, 52 | { name = "h11" }, 53 | ] 54 | sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } 55 | wheels = [ 56 | { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, 57 | ] 58 | 59 | [[package]] 60 | name = "httpx" 61 | version = "0.28.1" 62 | source = { registry = "https://pypi.org/simple" } 63 | dependencies = [ 64 | { name = "anyio" }, 65 | { name = "certifi" }, 66 | { name = "httpcore" }, 67 | { name = "idna" }, 68 | ] 69 | sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } 70 | wheels = [ 71 | { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, 72 | ] 73 | 74 | [[package]] 75 | name = "httpx-oauth" 76 | version = "0.16.0" 77 | source = { editable = "." } 78 | dependencies = [ 79 | { name = "httpx" }, 80 | ] 81 | 82 | [package.metadata] 83 | requires-dist = [{ name = "httpx", specifier = ">=0.18" }] 84 | 85 | [[package]] 86 | name = "idna" 87 | version = "3.10" 88 | source = { registry = "https://pypi.org/simple" } 89 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } 90 | wheels = [ 91 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, 92 | ] 93 | 94 | [[package]] 95 | name = "sniffio" 96 | version = "1.3.1" 97 | source = { registry = "https://pypi.org/simple" } 98 | sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } 99 | wheels = [ 100 | { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, 101 | ] 102 | 103 | [[package]] 104 | name = "typing-extensions" 105 | version = "4.12.2" 106 | source = { registry = "https://pypi.org/simple" } 107 | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } 108 | wheels = [ 109 | { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, 110 | ] 111 | --------------------------------------------------------------------------------