├── .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 | [](https://github.com/frankie567/httpx-oauth/actions)
8 | [](https://codecov.io/gh/frankie567/httpx-oauth)
9 | [](https://badge.fury.io/py/httpx-oauth)
10 |
11 |
12 |
13 | [](#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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------