├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── docs.yml │ ├── lint.yml │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .isort.cfg ├── .markdownlint.json ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── build-docs.sh ├── codecov.yaml ├── docs ├── contributing.md ├── generate_reference.py ├── how-to-guides │ ├── 00-installation.md │ ├── additional-query-params.md │ ├── additional-scopes.md │ ├── fastapi-security.png │ ├── http-development.md │ ├── key-error.md │ ├── redirect-uri-request-time.md │ ├── state-return-url.md │ └── use-with-fastapi-security.md ├── index.md └── tutorials.md ├── examples ├── README.md ├── bitbucket.py ├── discord.py ├── facebook.py ├── fitbit.py ├── generic.py ├── github.py ├── gitlab.py ├── google.py ├── kakao.py ├── line.py ├── linkedin.py ├── microsoft.py ├── naver.py ├── notion.py ├── seznam.py ├── twitter.py └── yandex.py ├── fastapi_sso ├── __init__.py ├── pkce.py ├── py.typed ├── sso │ ├── __init__.py │ ├── base.py │ ├── bitbucket.py │ ├── discord.py │ ├── facebook.py │ ├── fitbit.py │ ├── generic.py │ ├── github.py │ ├── gitlab.py │ ├── google.py │ ├── kakao.py │ ├── line.py │ ├── linkedin.py │ ├── microsoft.py │ ├── naver.py │ ├── notion.py │ ├── seznam.py │ ├── spotify.py │ ├── twitter.py │ └── yandex.py └── state.py ├── mkdocs.yml ├── mypy.ini ├── poetry.lock ├── pyproject.toml ├── tests ├── test_base.py ├── test_generic_provider.py ├── test_openid_responses.py ├── test_pkce.py ├── test_providers.py ├── test_providers_individual.py ├── test_race_condition.py └── utils.py └── tox.ini /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | groups: 5 | all: 6 | patterns: 7 | - "*" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | labels: 12 | - dependencies 13 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: "29 16 * * 0" 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v3 24 | 25 | # Initializes the CodeQL tools for scanning. 26 | - name: Initialize CodeQL 27 | uses: github/codeql-action/init@v2 28 | with: 29 | languages: "python" 30 | queries: security-extended,security-and-quality 31 | 32 | - name: Perform CodeQL Analysis 33 | uses: github/codeql-action/analyze@v2 34 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: publish docs 2 | on: 3 | push: 4 | branches: 5 | - master 6 | workflow_dispatch: 7 | 8 | concurrency: 9 | group: pages 10 | cancel-in-progress: false 11 | 12 | permissions: 13 | contents: write 14 | id-token: write 15 | pages: write 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | env: 21 | POETRY_VIRTUALENVS_CREATE: "false" 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: actions/setup-python@v4 25 | with: 26 | python-version: 3.12 27 | - name: Install pipx 28 | run: python -m pip install pipx && python -m pipx ensurepath 29 | - name: Install poetry 30 | run: pipx install poetry && poetry --version 31 | - name: Install dependencies 32 | run: poetry install 33 | - name: Build docs 34 | run: poetry run poe docs 35 | - name: Setup Pages 36 | id: pages 37 | uses: actions/configure-pages@v3 38 | - name: Upload pages artifact 39 | uses: actions/upload-pages-artifact@v3 40 | with: 41 | path: ./public 42 | 43 | deploy: 44 | needs: build 45 | runs-on: ubuntu-latest 46 | environment: 47 | name: github-pages 48 | url: ${{ steps.deployment.outputs.page_url }} 49 | steps: 50 | - name: Deploy to Github pages 51 | id: deployment 52 | uses: actions/deploy-pages@v4 53 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Code quality 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: 11 | - "3.9" 12 | - "3.10" 13 | - "3.11" 14 | - "3.12" 15 | - "3.13" 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v3 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install pipx 23 | run: python -m pip install pipx && python -m pipx ensurepath 24 | - name: Install poetry 25 | run: pipx install poetry && poetry --version 26 | - name: Install dependencies 27 | run: | 28 | POETRY_VIRTUALENVS_CREATE=false poetry install 29 | - name: Analysing the code with ruff 30 | run: poetry run poe ruff 31 | - name: Static type-checking using mypy 32 | run: poetry run poe mypy 33 | - name: Format checking using black 34 | run: poetry run poe black-check 35 | - name: Format checking using isort 36 | run: poetry run poe isort-check 37 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Set up Python 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: 3.11 18 | 19 | - name: Install Poetry 20 | run: | 21 | curl -sSL https://install.python-poetry.org | python3 - 22 | 23 | - name: Install dependencies 24 | run: poetry install 25 | 26 | - name: Publish package 27 | run: poetry publish --build 28 | env: 29 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | env: 9 | POETRY_VIRTUALENVS_CREATE: "false" 10 | coverage_json: "{}" 11 | strategy: 12 | matrix: 13 | python-version: 14 | - "3.9" 15 | - "3.10" 16 | - "3.11" 17 | - "3.12" 18 | - "3.13" 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v3 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install pipx 26 | run: python -m pip install pipx && python -m pipx ensurepath 27 | - name: Install poetry 28 | run: pipx install poetry && poetry --version 29 | - name: Install dependencies 30 | run: | 31 | POETRY_VIRTUALENVS_CREATE=false poetry install 32 | - name: pytest 33 | run: poetry run poe test --junitxml=pytest-results-${{ matrix.python-version }}.xml 34 | - name: coverage 35 | run: poetry run poe coverage 36 | - name: Upload coverage to Codecov 37 | uses: codecov/codecov-action@v3 38 | with: 39 | token: ${{ secrets.CODECOV_TOKEN }} 40 | env_vars: OS,PYTHON 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # We need requirements.txt for tox, but it may not be updated properly, so let's keep it all in pyproject.toml 2 | requirements.txt 3 | 4 | .python-version 5 | 6 | .vscode/ 7 | main.py 8 | 9 | docs/reference/ 10 | docs/examples.md 11 | public/ 12 | 13 | # Byte-compiled / optimized / DLL files 14 | __pycache__/ 15 | *.py[cod] 16 | *$py.class 17 | 18 | # C extensions 19 | *.so 20 | 21 | # Distribution / packaging 22 | .Python 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | wheels/ 35 | share/python-wheels/ 36 | *.egg-info/ 37 | .installed.cfg 38 | *.egg 39 | MANIFEST 40 | 41 | # PyInstaller 42 | # Usually these files are written by a python script from a template 43 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 44 | *.manifest 45 | *.spec 46 | 47 | # Installer logs 48 | pip-log.txt 49 | pip-delete-this-directory.txt 50 | 51 | # Unit test / coverage reports 52 | htmlcov/ 53 | .tox/ 54 | .nox/ 55 | .coverage 56 | .coverage.* 57 | .cache 58 | nosetests.xml 59 | coverage.xml 60 | coverage.json 61 | *.cover 62 | *.py,cover 63 | .hypothesis/ 64 | .pytest_cache/ 65 | cover/ 66 | 67 | # Translations 68 | *.mo 69 | *.pot 70 | 71 | # Django stuff: 72 | *.log 73 | local_settings.py 74 | db.sqlite3 75 | db.sqlite3-journal 76 | 77 | # Flask stuff: 78 | instance/ 79 | .webassets-cache 80 | 81 | # Scrapy stuff: 82 | .scrapy 83 | 84 | # Sphinx documentation 85 | docs/_build/ 86 | 87 | # PyBuilder 88 | .pybuilder/ 89 | target/ 90 | 91 | # Jupyter Notebook 92 | .ipynb_checkpoints 93 | 94 | # IPython 95 | profile_default/ 96 | ipython_config.py 97 | 98 | # pyenv 99 | # For a library or package, you might want to ignore these files since the code is 100 | # intended to run in multiple environments; otherwise, check them in: 101 | # .python-version 102 | 103 | # pipenv 104 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 105 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 106 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 107 | # install all needed dependencies. 108 | #Pipfile.lock 109 | 110 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 111 | __pypackages__/ 112 | 113 | # Celery stuff 114 | celerybeat-schedule 115 | celerybeat.pid 116 | 117 | # SageMath parsed files 118 | *.sage.py 119 | 120 | # Environments 121 | .env 122 | .venv 123 | env/ 124 | venv/ 125 | ENV/ 126 | env.bak/ 127 | venv.bak/ 128 | 129 | # Spyder project settings 130 | .spyderproject 131 | .spyproject 132 | 133 | # Rope project settings 134 | .ropeproject 135 | 136 | # mkdocs documentation 137 | /site 138 | 139 | # mypy 140 | .mypy_cache/ 141 | .dmypy.json 142 | dmypy.json 143 | 144 | # Pyre type checker 145 | .pyre/ 146 | 147 | # pytype static type analyzer 148 | .pytype/ 149 | 150 | # Cython debug symbols 151 | cython_debug/ 152 | 153 | # Apple specific 154 | .DS_Store 155 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | multi_line_output=3 3 | include_trailing_comma=True 4 | line_length=120 5 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD041": false, 3 | "line-length": { 4 | "line_length": 120 5 | }, 6 | "no-inline-html": false 7 | } 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: ruff 5 | name: ruff 6 | entry: poe ruff --fix 7 | language: system 8 | types: [python] 9 | pass_filenames: false 10 | 11 | - id: black 12 | name: black 13 | entry: poe black-check 14 | language: system 15 | types: [python] 16 | pass_filenames: false 17 | 18 | - id: mypy 19 | name: mypy 20 | entry: poe mypy 21 | language: system 22 | types: [python] 23 | pass_filenames: false 24 | 25 | - id: isort 26 | name: isort 27 | entry: poe isort-check 28 | language: system 29 | types: [python] 30 | pass_filenames: false 31 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | In order to add a new login provider, please make sure to adhere to the following guidelines to the best 4 | of your abilities and possibilities. 5 | 6 | ## Dependencies management 7 | 8 | Seeing the file `poetry.lock` you may have guessed this project relies on [Poetry](https://python-poetry.org/) 9 | to manage dependencies. 10 | 11 | If there is a need for a 3rd party dependency in order to integrate login provider, please try to make 12 | use of [extras](https://python-poetry.org/docs/pyproject/#extras) in order not to make `fastapi-sso` 13 | any heavier. Any dependency apart from the ones listed in `tool.poetry.dependencies` in 14 | [`pyproject.toml`](https://github.com/tomasvotava/fastapi-sso/tree/master/pyproject.toml) 15 | should be an extra along with it being optional. If you are not shure how to do this, let me know 16 | the dependency in PR and I will add it before merging your code. 17 | 18 | Also, **please strictly separate runtime dependencies from dev dependencies**. 19 | 20 | ## Provide examples 21 | 22 | Please, try to provide examples for the login provider in the 23 | [`examples/`](https://github.com/tomasvotava/fastapi-sso/tree/master/examples) directory. 24 | **Always make sure your code contains no credentials before submitting the PR**. 25 | 26 | ## Code quality 27 | 28 | I am myself rather a dirty programmer and so it feels a little out of place for me to talk about 29 | code quality, but let's keep the code up to at least some standards. 30 | 31 | ### Formatting 32 | 33 | As visible in `pyproject.toml`, I use `black` as a formatter with all the default settings except for 34 | the `line_length` parameter. As seen in the file, I set it to 120 characters. Please try to keep 35 | the code formatted this way. 36 | 37 | It is easy to reformat the code by calling `black` from the repository root: 38 | 39 | ```console 40 | $ poe black 41 | 42 | All done! ✨ 🍰 ✨ 43 | 13 files left unchanged. 44 | ``` 45 | 46 | ### Linting 47 | 48 | I use `ruff`. Detailed configuration is to be found in `pyproject.toml` file. 49 | 50 | Check your code by calling: 51 | 52 | ```console 53 | $ poe ruff 54 | 55 | Poe => ruff check fastapi_sso 56 | All checks passed! 57 | ``` 58 | 59 | If your code doesn't pass and you feel you have a good reason for it not to be, you may use 60 | `noqa: ...` magic comments throughout the code, but please expect me to ask about it 61 | when you submit the PR. 62 | 63 | ### Typechecking 64 | 65 | Try to keep the code statically typechecked using `mypy`. Check that everything is alright by running: 66 | 67 | ```console 68 | $ poe mypy 69 | 70 | Success: no issues found in 13 source files 71 | ``` 72 | 73 | ### Pre-commit 74 | 75 | I use `pre-commit` to run all the above checks before committing. You can install it by calling: 76 | 77 | ```console 78 | $ poe pre-commit install 79 | pre-commit installed at .git/hooks/pre-commit 80 | ``` 81 | 82 | ### Tests 83 | 84 | I use `pytest` for testing. Please try to provide tests for your code. If you are not sure how to 85 | do it, let me know in the PR and I'll try to help you. 86 | 87 | Run the tests by calling: 88 | 89 | ```console 90 | poe test 91 | ``` 92 | 93 | ## Documentation 94 | 95 | Please try to provide documentation for your code. I use `mkdocs` to generate the documentation. 96 | In most cases, it should be enough to use docstrings and to provide 97 | examples in the aforementioned `examples/` directory. 98 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2021 Tomas Votava 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI SSO 2 | 3 | ![Supported Python Versions](https://img.shields.io/pypi/pyversions/fastapi-sso) 4 | [![Test coverage](https://codecov.io/gh/tomasvotava/fastapi-sso/graph/badge.svg?token=SIFCTVSSOS)](https://codecov.io/gh/tomasvotava/fastapi-sso) 5 | ![Tests Workflow Status](https://img.shields.io/github/actions/workflow/status/tomasvotava/fastapi-sso/test.yml?label=tests) 6 | ![Lint Workflow Status](https://img.shields.io/github/actions/workflow/status/tomasvotava/fastapi-sso/lint.yml?label=ruff) 7 | ![Mypy Workflow Status](https://img.shields.io/github/actions/workflow/status/tomasvotava/fastapi-sso/lint.yml?label=mypy) 8 | ![Black Workflow Status](https://img.shields.io/github/actions/workflow/status/tomasvotava/fastapi-sso/lint.yml?label=black) 9 | ![CodeQL Workflow Status](https://img.shields.io/github/actions/workflow/status/tomasvotava/fastapi-sso/codeql-analysis.yml?label=CodeQL) 10 | ![PyPi weekly downloads](https://img.shields.io/pypi/dw/fastapi-sso) 11 | ![Project License](https://img.shields.io/github/license/tomasvotava/fastapi-sso) 12 | ![PyPi Version](https://img.shields.io/pypi/v/fastapi-sso) 13 | 14 | FastAPI plugin to enable SSO to most common providers (such as Facebook login, Google login and login via 15 | Microsoft Office 365 account). 16 | 17 | This allows you to implement the famous `Login with Google/Facebook/Microsoft` buttons functionality on your 18 | backend very easily. 19 | 20 | **Documentation**: [https://tomasvotava.github.io/fastapi-sso/](https://tomasvotava.github.io/fastapi-sso/) 21 | 22 | **Source Code**: [https://github.com/tomasvotava/fastapi-sso](https://github.com/tomasvotava/fastapi-sso/) 23 | 24 | ## Demo site 25 | 26 | An awesome demo site was created and is maintained by even awesomer 27 | [Chris Karvouniaris (@chrisK824)](https://github.com/chrisK824). Chris has also posted multiple 28 | Medium articles about FastAPI and FastAPI SSO. 29 | 30 | Be sure to see his tutorials, follow him and show him some appreciation! 31 | 32 | Please see his [announcement](https://github.com/tomasvotava/fastapi-sso/discussions/150) with all the links. 33 | 34 | Quick links for the eager ones: 35 | 36 | - [Demo site](https://fastapi-sso-example.vercel.app/) 37 | - [Medium articles](https://medium.com/@christos.karvouniaris247) 38 | 39 | ## Security Notice 40 | 41 | ### Version `0.16.0` Update: Race Condition Bug Fix & Context Manager Change 42 | 43 | A race condition bug in the login flow that could, in rare cases, allow one user 44 | to assume the identity of another due to concurrent login requests was recently discovered 45 | by [@parikls](https://github.com/parikls). 46 | This issue was reported in [#186](https://github.com/tomasvotava/fastapi-sso/issues/186) and has been resolved 47 | in version `0.16.0`. 48 | 49 | **Details of the Fix:** 50 | 51 | The bug was mitigated by introducing an async lock mechanism that ensures only one user can attempt the login 52 | process at any given time. This prevents race conditions that could lead to unintended user identity crossover. 53 | 54 | **Important Change:** 55 | 56 | To fully support this fix, **users must now use the SSO instance within an `async with` 57 | context manager**. This adjustment is necessary for proper handling of asynchronous operations. 58 | 59 | The synchronous `with` context manager is now deprecated and will produce a warning. 60 | It will be removed in future versions to ensure best practices for async handling. 61 | 62 | **Impact:** 63 | 64 | This bug could potentially affect deployments with high concurrency or scenarios where multiple users initiate 65 | login requests simultaneously. To prevent potential issues and deprecation warnings, **update to 66 | version `0.16.0` or later and modify your code to use the async with context**. 67 | 68 | Code Example Update: 69 | 70 | ```python 71 | # Before (deprecated) 72 | with sso: 73 | openid = await sso.verify_and_process(request) 74 | 75 | # After (recommended) 76 | async with sso: 77 | openid = await sso.verify_and_process(request) 78 | ``` 79 | 80 | Thanks to both [@parikls](https://github.com/parikls) and the community for helping me identify and improve the 81 | security of `fastapi-sso`. If you encounter any issues or potential vulnerabilities, please report them 82 | immediately so they can be addressed. 83 | 84 | For more details, refer to Issue [#186](https://github.com/tomasvotava/fastapi-sso/issues/186) 85 | and PR [#189](https://github.com/tomasvotava/fastapi-sso/pull/189). 86 | 87 | ## Support this project 88 | 89 | If you'd like to support this project, consider [buying me a coffee ☕](https://www.buymeacoffee.com/tomas.votava). 90 | I tend to process Pull Requests faster when properly caffeinated 😉. 91 | 92 | 93 | Buy Me A Coffee 95 | 96 | ## Supported login providers 97 | 98 | ### Official 99 | 100 | - Google 101 | - Microsoft 102 | - Facebook 103 | - Spotify 104 | - Fitbit 105 | - Github (credits to [Brandl](https://github.com/Brandl) for hint using `accept` header) 106 | - generic (see [docs](https://tomasvotava.github.io/fastapi-sso/reference/sso.generic/)) 107 | - Notion 108 | - Twitter (X) 109 | 110 | ### Contributed 111 | 112 | - Kakao (by Jae-Baek Song - [thdwoqor](https://github.com/thdwoqor)) 113 | - Naver (by 1tang2bang92) - [1tang2bang92](https://github.com/1tang2bang92) 114 | - Gitlab (by Alessandro Pischedda) - [Cereal84](https://github.com/Cereal84) 115 | - Line (by Jimmy Yeh) - [jimmyyyeh](https://github.com/jimmyyyeh) 116 | - LinkedIn (by Alessandro Pischedda) - [Cereal84](https://github.com/Cereal84) 117 | - Yandex (by Akim Faskhutdinov) – [akimrx](https://github.com/akimrx) 118 | - Seznam (by Tomas Koutek) - [TomasKoutek](https://github.com/TomasKoutek) 119 | - Discord (by Kaelian Baudelet) - [afi-dev](https://github.com/afi-dev) 120 | - Bitbucket (by Kaelian Baudelet) - [afi-dev](https://github.com/afi-dev) 121 | 122 | See [Contributing](#contributing) for a guide on how to contribute your own login provider. 123 | 124 | ## Installation 125 | 126 | ### Install using `pip` 127 | 128 | ```console 129 | pip install fastapi-sso 130 | ``` 131 | 132 | ### Install using `poetry` 133 | 134 | ```console 135 | poetry add fastapi-sso 136 | ``` 137 | 138 | ## Contributing 139 | 140 | If you'd like to contribute and add your specific login provider, please see 141 | [Contributing](https://tomasvotava.github.io/fastapi-sso/contributing) file. 142 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Overview 4 | Security is of paramount importance to this project, especially since it deals with login functionalities. 5 | That being said, an oopsie may happen and it is crucial for me to be informed promptly. This document provides an overview of the supported 6 | versions and instructions on reporting any security-related issues or vulnerabilities you might discover. 7 | 8 | ## Supported Versions 9 | `fastapi-sso` is still in its developmental phases, and we haven't rolled out a 1.0.0 release yet. Currently, I am offering support for all releases `0.7.0` and newer. 10 | 11 | | Version | Supported | 12 | | -----------| ------------------ | 13 | | >= 0.7.0 | :white_check_mark: | 14 | 15 | ## Reporting a Vulnerability 16 | Addressing security issues can be time-consuming, but rest assured, I take them very seriously and endeavor to resolve them as swiftly as possible. If you identify a security vulnerability in `fastapi-sso`, I urge you to notify me. 17 | 18 | ### Steps to Report a Vulnerability: 19 | 1. Create a new issue in our [Issue Tracker](https://github.com/tomasvotava/fastapi-sso/issues). 20 | 2. Assign the `security` label to the issue. 21 | 3. Furnish a detailed description of the issue, specifying where the vulnerability occurs, the steps to reproduce it, and its potential impacts. 22 | 23 | ### What to Expect 24 | I will acknowledge the receipt of your vulnerability report and keep you posted on the progress regularly. 25 | 26 | ### Disclosure Policy 27 | In the realm of coding etiquette, it is generally frowned upon to publicly disclose issues without prior communication with me. 28 | Therefore, I ask you to discuss any grievances or concerns about `fastapi-sso` with me before publicizing them. 29 | 30 | In other words, if there's something concerning `fastapi-sso` you'd like to bitch about, let me know and we'll bitch about it together. 31 | 32 | ## Thank You 33 | Raising an issue is a significant contribution, and I always appreciate discovering that people are using `fastapi-sso`. I am thankful for any insights or feedback provided. 34 | -------------------------------------------------------------------------------- /build-docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | rm -rf ./docs/ 4 | pdoc3 --html -o ./docs/ fastapi_sso 5 | mv ./docs/fastapi_sso/* ./docs/ 6 | rm -rf ./docs/fastapi_sso 7 | -------------------------------------------------------------------------------- /codecov.yaml: -------------------------------------------------------------------------------- 1 | comment: 2 | layout: "header, diff, flags, files" 3 | behavior: default 4 | require_changes: no 5 | 6 | coverage: 7 | status: 8 | patch: 9 | default: 10 | target: auto 11 | threshold: 1% 12 | if_not_found: success 13 | only_pulls: true 14 | project: 15 | default: 16 | target: auto 17 | threshold: 1% 18 | if_not_found: success 19 | only_pulls: true 20 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | ../CONTRIBUTING.md -------------------------------------------------------------------------------- /docs/generate_reference.py: -------------------------------------------------------------------------------- 1 | """Generate reference pages for the documentation.""" 2 | 3 | from pathlib import Path 4 | from typing import TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | import mkdocs.config.defaults # pragma: no cover 8 | 9 | 10 | SKIPPED_MODULES = ("fastapi_sso.sso", "fastapi_sso") 11 | 12 | 13 | def generate_reference_pages(docs_dir: str, nav: list): 14 | """Generate reference pages for the documentation.""" 15 | reference_path = Path(docs_dir, "reference") 16 | reference_path.mkdir(exist_ok=True) 17 | source_path = Path("./fastapi_sso") 18 | reference_nav = [] 19 | for path in sorted(source_path.rglob("*.py")): 20 | module_path = path.relative_to(".").with_suffix("") 21 | doc_path = str(path.relative_to(source_path).with_suffix(".md")).replace("/", ".") 22 | full_doc_path = reference_path / doc_path 23 | nav_path = (reference_path / doc_path).relative_to(docs_dir).as_posix() 24 | 25 | parts = module_path.parts 26 | 27 | if parts[-1] == "__init__": 28 | if len(parts) == 1: 29 | parts = ["fastapi_sso"] 30 | else: 31 | parts = parts[:-1] 32 | elif parts[-1] == "__main__": 33 | continue 34 | 35 | import_path = ".".join(parts) 36 | 37 | if import_path in SKIPPED_MODULES: 38 | continue 39 | 40 | full_doc_path.parent.mkdir(exist_ok=True, parents=True) 41 | with open(full_doc_path, "w", encoding="utf-8") as file: 42 | file.write(f"::: {import_path}\n") 43 | 44 | reference_nav.append({import_path: Path(nav_path).as_posix()}) 45 | nav.append({"Reference": reference_nav}) 46 | 47 | 48 | def generate_example_pages(docs_dir: str, nav: list): 49 | """Generate example pages for the documentation.""" 50 | examples_path = Path(docs_dir, "examples.md") 51 | source_path = Path("./examples") 52 | examples_path.unlink(missing_ok=True) 53 | with examples_path.open("w", encoding="utf-8") as file: 54 | file.write("# Examples\n\n") 55 | for path in sorted(source_path.rglob("*.py")): 56 | page_title = path.stem.replace("_", " ").title() 57 | file.write(f"## {page_title}\n\n```python\n{path.read_text(encoding='utf-8')}\n```\n\n") 58 | nav.append({"Examples": "examples.md"}) 59 | 60 | 61 | def on_config(config: "mkdocs.config.defaults.MkDocsConfig"): 62 | """Generate reference pages for the documentation.""" 63 | generate_example_pages(config.docs_dir, config.nav) 64 | generate_reference_pages(config.docs_dir, config.nav) 65 | -------------------------------------------------------------------------------- /docs/how-to-guides/00-installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Install using `poetry` 4 | 5 | ```console 6 | poetry add fastapi-sso 7 | ``` 8 | 9 | ## Install using `pip` 10 | 11 | ```console 12 | pip install fastapi-sso 13 | ``` 14 | -------------------------------------------------------------------------------- /docs/how-to-guides/additional-query-params.md: -------------------------------------------------------------------------------- 1 | # Additional query parameters 2 | 3 | !!! info "Added in `0.4.0`" 4 | 5 | You may provide additional query parameters to be sent to the login screen. 6 | 7 | E.g. sometimes you want to specify `access_type=offline` or `prompt=consent` in order for Google to return `refresh_token`. 8 | 9 | ```python 10 | # ... other imports and code ... 11 | 12 | @app.get("/google/login") 13 | async def google_login(request: Request): 14 | async with google_sso: 15 | return await google_sso.get_login_redirect( 16 | redirect_uri=request.url_for("google_callback"), 17 | params={"prompt": "consent", "access_type": "offline"} 18 | ) 19 | 20 | @app.get("/google/callback") 21 | async def google_callback(request: Request): 22 | async with google_sso: 23 | user = await google_sso.verify_and_process(request) 24 | # you may now use google_sso.refresh_token to refresh the access token 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/how-to-guides/additional-scopes.md: -------------------------------------------------------------------------------- 1 | # Request additional scopes 2 | 3 | !!! info "Added in `0.4.0`" 4 | 5 | You may specify `scope` when initializing the SSO class. 6 | This is useful when you need to request additional scopes from the user. 7 | The access token returned after verification will contain all the scopes 8 | and you may use it to access the user's data. 9 | 10 | ```python 11 | # ... other imports and code ... 12 | 13 | sso = GoogleSSO(client_id="client-id", client_secret="client-secret", scope=["openid", "email", "https://www.googleapis.com/auth/calendar"]) 14 | 15 | @app.get("/google/login") 16 | async def google_login(): 17 | async with sso: 18 | return await sso.get_login_redirect(redirect_uri=request.url_for("google_callback")) 19 | 20 | @app.get("/google/callback") 21 | async def google_callback(request: Request): 22 | async with sso: 23 | await sso.verify_and_process(request) 24 | # you may now use sso.access_token to access user's Google calendar 25 | async with httpx.AsyncClient() as client: 26 | response = await client.get( 27 | "https://www.googleapis.com/calendar/v3/users/me/calendarList", 28 | headers={"Authorization": f"Bearer {sso.access_token}"} 29 | ) 30 | return response.json() 31 | ``` 32 | -------------------------------------------------------------------------------- /docs/how-to-guides/fastapi-security.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomasvotava/fastapi-sso/51a3507e151fd34cc3435b110194eb9a424e5d12/docs/how-to-guides/fastapi-security.png -------------------------------------------------------------------------------- /docs/how-to-guides/http-development.md: -------------------------------------------------------------------------------- 1 | # HTTP and development 2 | 3 | !!! danger "You should always use `https` in production" 4 | 5 | In case you need to test on `localhost` and do not want to 6 | use a self-signed certificate, make sure you set up redirect uri within your SSO provider to `http://localhost:{port}` 7 | and then add this to your environment: 8 | 9 | !!! info "Since `0.9.0` OAUTHLIB_INSECURE_TRANSPORT is set to `1` automatically if `allow_insecure_http` is `True` and this is not needed anymore." 10 | 11 | ```bash 12 | OAUTHLIB_INSECURE_TRANSPORT=1 13 | ``` 14 | 15 | And make sure you pass `allow_insecure_http = True` to SSO class' constructor, such as: 16 | 17 | ```python 18 | import os 19 | from fastapi_sso.sso.google import GoogleSSO 20 | 21 | os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" 22 | 23 | google_sso = GoogleSSO("client-id", "client-secret", allow_insecure_http=True) 24 | ``` 25 | 26 | See [this issue](https://github.com/tomasvotava/fastapi-sso/issues/2) for more information. 27 | -------------------------------------------------------------------------------- /docs/how-to-guides/key-error.md: -------------------------------------------------------------------------------- 1 | # `KeyError` and missing keys in response 2 | 3 | As seen in quite a lot of issues ([#81](https://github.com/tomasvotava/fastapi-sso/issues/81), 4 | [#54](https://github.com/tomasvotava/fastapi-sso/issues/54), 5 | [#51](https://github.com/tomasvotava/fastapi-sso/issues/51), 6 | [#32](https://github.com/tomasvotava/fastapi-sso/issues/32)), some SSO providers misbehave and either 7 | change the response from time to time or return incomplete data. 8 | 9 | In some cases this may be overcome by using the `scope` parameter to request additional scopes 10 | ([see how to do it](./additional-scopes.md)). 11 | 12 | For example, if you are using Microsoft SSO within your organization, you may require the `User.Read.All` scope 13 | or `email` scope to get the user's email address. 14 | 15 | !!! info "`email` was added in `0.8.0` as the default scope for Microsoft SSO." 16 | -------------------------------------------------------------------------------- /docs/how-to-guides/redirect-uri-request-time.md: -------------------------------------------------------------------------------- 1 | # Specify `redirect_uri` at request time 2 | 3 | In scenarios when you cannot provide the `redirect_uri` upon the SSO class initialization, you may simply omit 4 | the parameter and provide it when calling `get_login_redirect` method. 5 | 6 | ```python 7 | # ... other imports and code ... 8 | 9 | google_sso = GoogleSSO("my-client-id", "my-client-secret") 10 | 11 | @app.get("/google/login") 12 | async def google_login(request: Request): 13 | """Dynamically generate login url and return redirect""" 14 | async with google_sso: 15 | return await google_sso.get_login_redirect(redirect_uri=request.url_for("google_callback")) 16 | 17 | @app.get("/google/callback") 18 | async def google_callback(request: Request): 19 | # ... handle callback ... 20 | 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/how-to-guides/state-return-url.md: -------------------------------------------------------------------------------- 1 | # State and return url 2 | 3 | State is useful if you want the server to return something back to you to help you understand in what 4 | context the authentication was initiated. It is mostly used to store the url you want your user to be redirected 5 | to after successful login. You may use `.state` property to get the state returned from the server or access 6 | it from the `state` parameter in the callback function. 7 | 8 | Example: 9 | 10 | ```python 11 | 12 | from fastapi import Request 13 | from fastapi.responses import RedirectResponse 14 | 15 | google_sso = GoogleSSO("client-id", "client-secret") 16 | 17 | # E.g. https://example.com/auth/login?return_url=https://example.com/welcome 18 | async def google_login(return_url: str): 19 | async with google_sso: 20 | # Send return_url to Google as a state so that Google knows to return it back to us 21 | return await google_sso.get_login_redirect(redirect_uri=request.url_for("google_callback"), state=return_url) 22 | 23 | async def google_callback(request: Request, state: str | None = None): 24 | async with google_sso: 25 | user = await google_sso.verify_and_process(request) 26 | if state is not None: 27 | return RedirectResponse(state) 28 | else: 29 | return user 30 | ``` 31 | -------------------------------------------------------------------------------- /docs/how-to-guides/use-with-fastapi-security.md: -------------------------------------------------------------------------------- 1 | # Using with fastapi's Security 2 | 3 | Even though `fastapi-sso` does not try to solve login and authentication, it is clear that you 4 | will probably mostly use it to protect your endpoints. This is why it is important to know how 5 | to use it with fastapi's security. 6 | 7 | You were asking how to put the lock 🔒 icon to your Swagger docs 8 | in [this issue](https://github.com/tomasvotava/fastapi-sso/issues/33). This is how you do it. 9 | 10 | ## Requirements 11 | 12 | - `fastapi` - obviously 13 | - `fastapi-sso` - duh 14 | - `python-jose[cryptography]` - to sign and verify our JWTs 15 | 16 | ## Explanation 17 | 18 | Fastapi-SSO is here to arrange the communication between your app and the login provider (such as Google). 19 | It does not store any state of this communication and so it is up to you to make sure you don't have to 20 | ask the user to login again and again. 21 | 22 | There are millions of ways how to do this, but the most common one is to use JWTs. You can read more about 23 | them [here](https://jwt.io/introduction/). In short, JWT is a token that contains some data and is signed 24 | by a secret key. This means that you can verify that the token was created by you and that the data inside 25 | the token was not changed. 26 | 27 | This makes JWTs very helpful, because it's the thing that comes from the user that you can actually trust. 28 | 29 | In this example, we will save the JWT into a cookie so that the user sends it with every request. We will 30 | also use fastapi's `Depends` to make sure that the user is authenticated before accessing the endpoint. 31 | 32 | ## Example 33 | 34 | ```python 35 | import datetime # to calculate expiration of the JWT 36 | from fastapi import FastAPI, Depends, HTTPException, Security, Request 37 | from fastapi.responses import RedirectResponse 38 | from fastapi.security import APIKeyCookie # this is the part that puts the lock icon to the docs 39 | from fastapi_sso.sso.google import GoogleSSO # pip install fastapi-sso 40 | from fastapi_sso.sso.base import OpenID 41 | 42 | from jose import jwt # pip install python-jose[cryptography] 43 | 44 | SECRET_KEY = "this-is-very-secret" # used to sign JWTs, make sure it is really secret 45 | CLIENT_ID = "your-client-id" # your Google OAuth2 client ID 46 | CLIENT_SECRET = "your-client-secret" # your Google OAuth2 client secret 47 | 48 | sso = GoogleSSO(client_id=CLIENT_ID, client_secret=CLIENT_SECRET, redirect_uri="http://127.0.0.1:5000/auth/callback") 49 | 50 | app = FastAPI() 51 | 52 | 53 | async def get_logged_user(cookie: str = Security(APIKeyCookie(name="token"))) -> OpenID: 54 | """Get user's JWT stored in cookie 'token', parse it and return the user's OpenID.""" 55 | try: 56 | claims = jwt.decode(cookie, key=SECRET_KEY, algorithms=["HS256"]) 57 | return OpenID(**claims["pld"]) 58 | except Exception as error: 59 | raise HTTPException(status_code=401, detail="Invalid authentication credentials") from error 60 | 61 | 62 | @app.get("/protected") 63 | async def protected_endpoint(user: OpenID = Depends(get_logged_user)): 64 | """This endpoint will say hello to the logged user. 65 | If the user is not logged, it will return a 401 error from `get_logged_user`.""" 66 | return { 67 | "message": f"You are very welcome, {user.email}!", 68 | } 69 | 70 | 71 | @app.get("/auth/login") 72 | async def login(): 73 | """Redirect the user to the Google login page.""" 74 | async with sso: 75 | return await sso.get_login_redirect() 76 | 77 | 78 | @app.get("/auth/logout") 79 | async def logout(): 80 | """Forget the user's session.""" 81 | response = RedirectResponse(url="/protected") 82 | response.delete_cookie(key="token") 83 | return response 84 | 85 | 86 | @app.get("/auth/callback") 87 | async def login_callback(request: Request): 88 | """Process login and redirect the user to the protected endpoint.""" 89 | async with sso: 90 | openid = await sso.verify_and_process(request) 91 | if not openid: 92 | raise HTTPException(status_code=401, detail="Authentication failed") 93 | # Create a JWT with the user's OpenID 94 | expiration = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1) 95 | token = jwt.encode({"pld": openid.dict(), "exp": expiration, "sub": openid.id}, key=SECRET_KEY, algorithm="HS256") 96 | response = RedirectResponse(url="/protected") 97 | response.set_cookie( 98 | key="token", value=token, expires=expiration 99 | ) # This cookie will make sure /protected knows the user 100 | return response 101 | 102 | 103 | if __name__ == "__main__": 104 | import uvicorn 105 | 106 | uvicorn.run(app, host="127.0.0.1", port=5000) 107 | ``` 108 | 109 | ## Result 110 | 111 | ### Docs now show the lock icon 112 | 113 | Visit [`http://127.0.0.1:5000/docs/`](http://127.0.0.1:5000/docs/) 114 | 115 | ![Swagger docs with lock icon](./fastapi-security.png) 116 | 117 | ### Accessing the `/protected` endpoint before login 118 | 119 | Try visiting [`http://127.0.0.1:5000/protected`](http://127.0.0.1:5000/protected). You will get a 401 error. 120 | 121 | ```json 122 | { 123 | "detail": "Not authenticated" 124 | } 125 | ``` 126 | 127 | ### Accessing the `/protected` endpoint after login 128 | 129 | First visit [`http://127.0.0.1:5000/auth/login`](http://127.0.0.1:5000/auth/login) to login with Google. 130 | Then visit [`http://127.0.0.1:5000/protected`](http://127.0.0.1:5000/protected). 131 | 132 | ```json 133 | { 134 | "message": "You are very welcome, ijustfarted@example.com" 135 | } 136 | ``` 137 | 138 | If you want to retry everything, either delete the cookie from your browser or visit 139 | [`http://127.0.0.1:5000/auth/logout`](http://127.0.0.1:5000/auth/logout). 140 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | {!../README.md!} 2 | -------------------------------------------------------------------------------- /docs/tutorials.md: -------------------------------------------------------------------------------- 1 | # Tutorials 2 | 3 | ## A minimal example 4 | 5 | In order to make the following code work, you need to create a Google 6 | OAuth2 client and set up the redirect URI to `http://localhost:3000/google/callback`. 7 | 8 | Visit [Google Cloud Platform Console Credentials page](https://console.cloud.google.com/apis/credentials), 9 | create a project, if you don't have one already, and create a new OAuth2 client. 10 | 11 | Fill in the `Authorized redirect URIs` field with `http://localhost:3000/google/callback`. 12 | 13 | Then, copy the `Client ID` and `Client secret` and paste them into the following code: 14 | 15 | ```python 16 | from fastapi import FastAPI 17 | from starlette.requests import Request 18 | from fastapi_sso.sso.google import GoogleSSO 19 | 20 | app = FastAPI() 21 | 22 | CLIENT_ID = "your-google-client-id" # <-- paste your client id here 23 | CLIENT_SECRET = "your-google-client-secret" # <-- paste your client secret here 24 | 25 | google_sso = GoogleSSO(CLIENT_ID, CLIENT_SECRET, "http://localhost:3000/google/callback") 26 | 27 | @app.get("/google/login") 28 | async def google_login(): 29 | async with google_sso: 30 | return await google_sso.get_login_redirect() 31 | 32 | @app.get("/google/callback") 33 | async def google_callback(request: Request): 34 | async with google_sso: 35 | user = await google_sso.verify_and_process(request) 36 | return user 37 | ``` 38 | 39 | Save the file as `example.py` and run it using `uvicorn example:app`. 40 | 41 | Now, visit [http://localhost:3000/google/login](http://localhost:3000/google/login). 42 | 43 | !!! note "Does it work?" 44 | You should be redirected to Google login page. After successful login, you should be redirected back to 45 | `http://localhost:3000/google/callback` and see a JSON response containing your user data. 46 | 47 | ## Using SSO as a dependency 48 | 49 | You may use SSO as a dependency in your FastAPI application. 50 | This is useful if you want to use the same SSO instance in multiple endpoints and make sure the state is cleared after 51 | the request is processed. You may even omit the `with` statement in this case. 52 | 53 | ```python 54 | from fastapi import Depends, FastAPI, Request 55 | from fastapi_sso.sso.google import GoogleSSO 56 | 57 | app = FastAPI() 58 | 59 | CLIENT_ID = "your-google-client-id" # <-- paste your client id here 60 | CLIENT_SECRET = "your-google-client-secret" # <-- paste your client secret here 61 | 62 | def get_google_sso() -> GoogleSSO: 63 | return GoogleSSO(CLIENT_ID, CLIENT_SECRET, redirect_uri="http://localhost:3000/google/callback") 64 | 65 | @app.get("/google/login") 66 | async def google_login(google_sso: GoogleSSO = Depends(get_google_sso)): 67 | return await google_sso.get_login_redirect() 68 | 69 | @app.get("/google/callback") 70 | async def google_callback(request: Request, google_sso: GoogleSSO = Depends(get_google_sso)): 71 | user = await google_sso.verify_and_process(request) 72 | return user 73 | ``` 74 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # FastAPI-SSO Examples 2 | 3 | See individual Python files for working examples. 4 | 5 | ## Running examples 6 | 7 | ```console 8 | CLIENT_ID="client-id" CLIENT_SECRET="client-secret" python examples/google.py 9 | ``` 10 | -------------------------------------------------------------------------------- /examples/bitbucket.py: -------------------------------------------------------------------------------- 1 | """BitBucket Login Example 2 | """ 3 | 4 | import os 5 | import uvicorn 6 | from fastapi import FastAPI, Request 7 | from fastapi_sso.sso.bitbucket import BitbucketSSO 8 | 9 | CLIENT_ID = os.environ["CLIENT_ID"] 10 | CLIENT_SECRET = os.environ["CLIENT_SECRET"] 11 | 12 | app = FastAPI() 13 | 14 | sso = BitbucketSSO( 15 | client_id=CLIENT_ID, 16 | client_secret=CLIENT_SECRET, 17 | redirect_uri="http://localhost:5000/auth/callback", 18 | allow_insecure_http=True, 19 | ) 20 | 21 | 22 | @app.get("/auth/login") 23 | async def auth_init(): 24 | """Initialize auth and redirect""" 25 | with sso: 26 | return await sso.get_login_redirect() 27 | 28 | 29 | @app.get("/auth/callback") 30 | async def auth_callback(request: Request): 31 | """Verify login""" 32 | with sso: 33 | user = await sso.verify_and_process(request) 34 | return user 35 | 36 | 37 | if __name__ == "__main__": 38 | uvicorn.run(app="examples.bitbucket:app", host="127.0.0.1", port=5000) 39 | -------------------------------------------------------------------------------- /examples/discord.py: -------------------------------------------------------------------------------- 1 | """Discord Login Example 2 | """ 3 | 4 | import os 5 | import uvicorn 6 | from fastapi import FastAPI, Request 7 | from fastapi_sso.sso.discord import DiscordSSO 8 | 9 | CLIENT_ID = os.environ["CLIENT_ID"] 10 | CLIENT_SECRET = os.environ["CLIENT_SECRET"] 11 | 12 | app = FastAPI() 13 | 14 | sso = DiscordSSO( 15 | client_id=CLIENT_ID, 16 | client_secret=CLIENT_SECRET, 17 | redirect_uri="http://localhost:5000/auth/callback", 18 | allow_insecure_http=True, 19 | ) 20 | 21 | 22 | @app.get("/auth/login") 23 | async def auth_init(): 24 | """Initialize auth and redirect""" 25 | with sso: 26 | return await sso.get_login_redirect() 27 | 28 | 29 | @app.get("/auth/callback") 30 | async def auth_callback(request: Request): 31 | """Verify login""" 32 | with sso: 33 | user = await sso.verify_and_process(request) 34 | return user 35 | 36 | 37 | if __name__ == "__main__": 38 | uvicorn.run(app="examples.discord:app", host="127.0.0.1", port=5000) 39 | -------------------------------------------------------------------------------- /examples/facebook.py: -------------------------------------------------------------------------------- 1 | """Facebook Login Example""" 2 | 3 | import os 4 | 5 | import uvicorn 6 | from fastapi import FastAPI, Request 7 | 8 | from fastapi_sso.sso.facebook import FacebookSSO 9 | 10 | CLIENT_ID = os.environ["CLIENT_ID"] 11 | CLIENT_SECRET = os.environ["CLIENT_SECRET"] 12 | 13 | app = FastAPI() 14 | 15 | sso = FacebookSSO( 16 | client_id=CLIENT_ID, 17 | client_secret=CLIENT_SECRET, 18 | redirect_uri="http://localhost:5000/auth/callback", 19 | allow_insecure_http=True, 20 | ) 21 | 22 | 23 | @app.get("/auth/login") 24 | async def auth_init(): 25 | """Initialize auth and redirect""" 26 | async with sso: 27 | return await sso.get_login_redirect(params={"prompt": "consent", "access_type": "offline"}) 28 | 29 | 30 | @app.get("/auth/callback") 31 | async def auth_callback(request: Request): 32 | """Verify login""" 33 | async with sso: 34 | user = await sso.verify_and_process(request) 35 | return user 36 | 37 | 38 | if __name__ == "__main__": 39 | uvicorn.run(app="examples.facebook:app", host="127.0.0.1", port=5000) 40 | -------------------------------------------------------------------------------- /examples/fitbit.py: -------------------------------------------------------------------------------- 1 | """Fitbit Login Example""" 2 | 3 | import os 4 | import uvicorn 5 | from fastapi import FastAPI, Request 6 | from fastapi_sso.sso.fitbit import FitbitSSO 7 | 8 | CLIENT_ID = os.environ["CLIENT_ID"] 9 | CLIENT_SECRET = os.environ["CLIENT_SECRET"] 10 | 11 | app = FastAPI() 12 | 13 | sso = FitbitSSO( 14 | client_id=CLIENT_ID, 15 | client_secret=CLIENT_SECRET, 16 | redirect_uri="http://localhost:3000/auth/callback", 17 | allow_insecure_http=True, 18 | ) 19 | 20 | 21 | @app.get("/auth/login") 22 | async def auth_init(): 23 | """Initialize auth and redirect""" 24 | async with sso: 25 | return await sso.get_login_redirect() 26 | 27 | 28 | @app.get("/auth/callback") 29 | async def auth_callback(request: Request): 30 | """Verify login""" 31 | async with sso: 32 | return await sso.verify_and_process(request) 33 | 34 | 35 | if __name__ == "__main__": 36 | uvicorn.run(app="examples.fitbit:app", host="127.0.0.1", port=3000) 37 | -------------------------------------------------------------------------------- /examples/generic.py: -------------------------------------------------------------------------------- 1 | """This is an example usage of fastapi-sso.""" 2 | 3 | from typing import Any, Union 4 | from httpx import AsyncClient 5 | import uvicorn 6 | from fastapi import FastAPI, HTTPException 7 | from starlette.requests import Request 8 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID 9 | from fastapi_sso.sso.generic import create_provider 10 | 11 | app = FastAPI() 12 | 13 | # Try running: 14 | # docker run \ 15 | # -p 9090:9090 \ 16 | # -e PORT=9090 \ 17 | # -e HOST=localhost \ 18 | # -e CLIENT_ID=test \ 19 | # -e CLIENT_SECRET=secret \ 20 | # -e CLIENT_REDIRECT_URI=http://localhost:8080/callback \ 21 | # -e CLIENT_LOGOUT_REDIRECT_URI=http://localhost:8080 \ 22 | # quay.io/appvia/mock-oidc-user-server:v0.0.2 23 | # and then python examples/generic.py 24 | 25 | 26 | def convert_openid(response: dict[str, Any], _client: Union[AsyncClient, None]) -> OpenID: 27 | """Convert user information returned by OIDC""" 28 | print(response) 29 | return OpenID(display_name=response["sub"]) 30 | 31 | 32 | discovery_document: DiscoveryDocument = { 33 | "authorization_endpoint": "http://localhost:9090/auth", 34 | "token_endpoint": "http://localhost:9090/token", 35 | "userinfo_endpoint": "http://localhost:9090/me", 36 | } 37 | 38 | GenericSSO = create_provider(name="oidc", discovery_document=discovery_document, response_convertor=convert_openid) 39 | 40 | sso = GenericSSO( 41 | client_id="test", client_secret="secret", redirect_uri="http://localhost:8080/callback", allow_insecure_http=True 42 | ) 43 | 44 | 45 | @app.get("/login") 46 | async def sso_login(): 47 | """Generate login url and redirect""" 48 | async with sso: 49 | return await sso.get_login_redirect() 50 | 51 | 52 | @app.get("/callback") 53 | async def sso_callback(request: Request): 54 | """Process login response from OIDC and return user info""" 55 | async with sso: 56 | user = await sso.verify_and_process(request) 57 | if user is None: 58 | raise HTTPException(401, "Failed to fetch user information") 59 | return { 60 | "id": user.id, 61 | "picture": user.picture, 62 | "display_name": user.display_name, 63 | "email": user.email, 64 | "provider": user.provider, 65 | } 66 | 67 | 68 | if __name__ == "__main__": 69 | uvicorn.run(app="examples.generic:app", host="127.0.0.1", port=8080) 70 | -------------------------------------------------------------------------------- /examples/github.py: -------------------------------------------------------------------------------- 1 | """Github Login Example""" 2 | 3 | import os 4 | import uvicorn 5 | from fastapi import FastAPI, Request 6 | from fastapi_sso.sso.github import GithubSSO 7 | 8 | CLIENT_ID = os.environ["CLIENT_ID"] 9 | CLIENT_SECRET = os.environ["CLIENT_SECRET"] 10 | 11 | app = FastAPI() 12 | 13 | sso = GithubSSO( 14 | client_id=CLIENT_ID, 15 | client_secret=CLIENT_SECRET, 16 | redirect_uri="http://localhost:5000/auth/callback", 17 | allow_insecure_http=True, 18 | ) 19 | 20 | 21 | @app.get("/auth/login") 22 | async def auth_init(): 23 | """Initialize auth and redirect""" 24 | async with sso: 25 | return await sso.get_login_redirect() 26 | 27 | 28 | @app.get("/auth/callback") 29 | async def auth_callback(request: Request): 30 | """Verify login""" 31 | async with sso: 32 | user = await sso.verify_and_process(request) 33 | return user 34 | 35 | 36 | if __name__ == "__main__": 37 | uvicorn.run(app="examples.github:app", host="127.0.0.1", port=5000) 38 | -------------------------------------------------------------------------------- /examples/gitlab.py: -------------------------------------------------------------------------------- 1 | """Github Login Example""" 2 | 3 | import os 4 | import uvicorn 5 | from fastapi import FastAPI, Request 6 | from fastapi_sso.sso.gitlab import GitlabSSO 7 | 8 | CLIENT_ID = os.environ["CLIENT_ID"] 9 | CLIENT_SECRET = os.environ["CLIENT_SECRET"] 10 | BASE_ENDPOINT_URL = os.environ.get("GITLAB_ENDPOINT_URL", "https://gitlab.com") 11 | 12 | app = FastAPI() 13 | 14 | sso = GitlabSSO( 15 | client_id=CLIENT_ID, 16 | client_secret=CLIENT_SECRET, 17 | base_endpoint_url=BASE_ENDPOINT_URL, 18 | redirect_uri="http://localhost:5000/auth/callback", 19 | allow_insecure_http=True, 20 | ) 21 | 22 | 23 | @app.get("/auth/login") 24 | async def auth_init(): 25 | """Initialize auth and redirect""" 26 | async with sso: 27 | return await sso.get_login_redirect() 28 | 29 | 30 | @app.get("/auth/callback") 31 | async def auth_callback(request: Request): 32 | """Verify login""" 33 | async with sso: 34 | user = await sso.verify_and_process(request) 35 | return user 36 | 37 | 38 | if __name__ == "__main__": 39 | uvicorn.run(app="examples.gitlab:app", host="127.0.0.1", port=5000) 40 | -------------------------------------------------------------------------------- /examples/google.py: -------------------------------------------------------------------------------- 1 | """Google Login Example""" 2 | 3 | import os 4 | import uvicorn 5 | from fastapi import FastAPI, Request 6 | from fastapi_sso.sso.google import GoogleSSO 7 | 8 | CLIENT_ID = os.environ["CLIENT_ID"] 9 | CLIENT_SECRET = os.environ["CLIENT_SECRET"] 10 | 11 | app = FastAPI() 12 | 13 | sso = GoogleSSO( 14 | client_id=CLIENT_ID, 15 | client_secret=CLIENT_SECRET, 16 | redirect_uri="http://localhost:5000/auth/callback", 17 | allow_insecure_http=True, 18 | ) 19 | 20 | 21 | @app.get("/auth/login") 22 | async def auth_init(): 23 | """Initialize auth and redirect""" 24 | async with sso: 25 | return await sso.get_login_redirect(params={"prompt": "consent", "access_type": "offline"}) 26 | 27 | 28 | @app.get("/auth/callback") 29 | async def auth_callback(request: Request): 30 | """Verify login""" 31 | async with sso: 32 | user = await sso.verify_and_process(request) 33 | return user 34 | 35 | 36 | if __name__ == "__main__": 37 | uvicorn.run(app="examples.google:app", host="127.0.0.1", port=5000) 38 | -------------------------------------------------------------------------------- /examples/kakao.py: -------------------------------------------------------------------------------- 1 | """Kakao Login Example""" 2 | 3 | import os 4 | import uvicorn 5 | from fastapi import FastAPI, Request 6 | from fastapi_sso.sso.kakao import KakaoSSO 7 | 8 | CLIENT_ID = os.environ["CLIENT_ID"] 9 | CLIENT_SECRET = os.environ["CLIENT_SECRET"] 10 | 11 | app = FastAPI() 12 | 13 | sso = KakaoSSO( 14 | client_id=CLIENT_ID, 15 | client_secret=CLIENT_SECRET, 16 | redirect_uri="http://localhost:5000/auth/callback", 17 | allow_insecure_http=True, 18 | ) 19 | 20 | 21 | @app.get("/auth/login") 22 | async def auth_init(): 23 | """Initialize auth and redirect""" 24 | async with sso: 25 | return await sso.get_login_redirect() 26 | 27 | 28 | @app.get("/auth/callback") 29 | async def auth_callback(request: Request): 30 | """Verify login""" 31 | async with sso: 32 | return await sso.verify_and_process(request, params={"client_secret": CLIENT_SECRET}) 33 | 34 | 35 | if __name__ == "__main__": 36 | uvicorn.run(app="examples.kakao:app", host="127.0.0.1", port=5000, reload=True) 37 | -------------------------------------------------------------------------------- /examples/line.py: -------------------------------------------------------------------------------- 1 | """Line Login Example""" 2 | 3 | import os 4 | import uvicorn 5 | from fastapi import FastAPI, Request 6 | from fastapi_sso.sso.line import LineSSO 7 | 8 | CLIENT_ID = os.environ["CLIENT_ID"] 9 | CLIENT_SECRET = os.environ["CLIENT_SECRET"] 10 | 11 | app = FastAPI() 12 | 13 | sso = LineSSO( 14 | client_id=CLIENT_ID, 15 | client_secret=CLIENT_SECRET, 16 | redirect_uri="http://localhost:5000/auth/callback", 17 | allow_insecure_http=True, 18 | ) 19 | 20 | 21 | @app.get("/auth/login") 22 | async def auth_init(): 23 | """Initialize auth and redirect""" 24 | async with sso: 25 | return await sso.get_login_redirect(state="randomstate") 26 | 27 | 28 | @app.get("/auth/callback") 29 | async def auth_callback(request: Request): 30 | """Verify login""" 31 | async with sso: 32 | user = await sso.verify_and_process(request) 33 | return user 34 | 35 | 36 | if __name__ == "__main__": 37 | uvicorn.run(app="examples.line:app", host="127.0.0.1", port=5000) 38 | -------------------------------------------------------------------------------- /examples/linkedin.py: -------------------------------------------------------------------------------- 1 | """Github Login Example""" 2 | 3 | import os 4 | import uvicorn 5 | from fastapi import FastAPI, Request 6 | from fastapi_sso.sso.linkedin import LinkedInSSO 7 | 8 | CLIENT_ID = os.environ["CLIENT_ID"] 9 | CLIENT_SECRET = os.environ["CLIENT_SECRET"] 10 | 11 | app = FastAPI() 12 | 13 | sso = LinkedInSSO( 14 | client_id=CLIENT_ID, 15 | client_secret=CLIENT_SECRET, 16 | redirect_uri="http://localhost:5050/auth/callback", 17 | allow_insecure_http=True, 18 | ) 19 | 20 | 21 | @app.get("/auth/login") 22 | async def auth_init(): 23 | """Initialize auth and redirect""" 24 | async with sso: 25 | return await sso.get_login_redirect() 26 | 27 | 28 | @app.get("/auth/callback") 29 | async def auth_callback(request: Request): 30 | """Verify login""" 31 | async with sso: 32 | user = await sso.verify_and_process(request) 33 | return user 34 | 35 | 36 | if __name__ == "__main__": 37 | uvicorn.run(app="examples.linkedin:app", host="127.0.0.1", port=5050) 38 | -------------------------------------------------------------------------------- /examples/microsoft.py: -------------------------------------------------------------------------------- 1 | """Microsoft Login Example""" 2 | 3 | import os 4 | import uvicorn 5 | from fastapi import FastAPI, Request 6 | from fastapi_sso.sso.microsoft import MicrosoftSSO 7 | 8 | CLIENT_ID = os.environ["CLIENT_ID"] 9 | CLIENT_SECRET = os.environ["CLIENT_SECRET"] 10 | TENANT = os.environ["TENANT"] 11 | 12 | app = FastAPI() 13 | 14 | sso = MicrosoftSSO( 15 | client_id=CLIENT_ID, 16 | client_secret=CLIENT_SECRET, 17 | tenant=TENANT, 18 | redirect_uri="http://localhost:8080/auth/callback", 19 | allow_insecure_http=True, 20 | ) 21 | 22 | 23 | @app.get("/auth/login") 24 | async def auth_init(): 25 | """Initialize auth and redirect""" 26 | async with sso: 27 | return await sso.get_login_redirect() 28 | 29 | 30 | @app.get("/auth/callback") 31 | async def auth_callback(request: Request): 32 | """Verify login""" 33 | async with sso: 34 | return await sso.verify_and_process(request) 35 | 36 | 37 | if __name__ == "__main__": 38 | uvicorn.run(app="examples.microsoft:app", host="127.0.0.1", port=8080) 39 | -------------------------------------------------------------------------------- /examples/naver.py: -------------------------------------------------------------------------------- 1 | """Naver Login Example""" 2 | 3 | import os 4 | import uvicorn 5 | from fastapi import FastAPI, Request 6 | from fastapi_sso.sso.naver import NaverSSO 7 | 8 | CLIENT_ID = os.environ["CLIENT_ID"] 9 | CLIENT_SECRET = os.environ["CLIENT_SECRET"] 10 | 11 | app = FastAPI() 12 | 13 | sso = NaverSSO( 14 | client_id=CLIENT_ID, 15 | client_secret=CLIENT_SECRET, 16 | redirect_uri="http://127.0.0.1:5000/auth/callback", 17 | allow_insecure_http=True, 18 | ) 19 | 20 | 21 | @app.get("/auth/login") 22 | async def auth_init(): 23 | """Initialize auth and redirect""" 24 | async with sso: 25 | return await sso.get_login_redirect() 26 | 27 | 28 | @app.get("/auth/callback") 29 | async def auth_callback(request: Request): 30 | """Verify login""" 31 | async with sso: 32 | return await sso.verify_and_process(request, params={"client_secret": CLIENT_SECRET}) 33 | 34 | 35 | if __name__ == "__main__": 36 | uvicorn.run(app="examples.naver:app", host="127.0.0.1", port=5000, reload=True) 37 | -------------------------------------------------------------------------------- /examples/notion.py: -------------------------------------------------------------------------------- 1 | """Github Login Example""" 2 | 3 | import os 4 | import uvicorn 5 | from fastapi import FastAPI, Request 6 | from fastapi_sso.sso.notion import NotionSSO 7 | 8 | CLIENT_ID = os.environ["CLIENT_ID"] 9 | CLIENT_SECRET = os.environ["CLIENT_SECRET"] 10 | 11 | app = FastAPI() 12 | 13 | sso = NotionSSO( 14 | client_id=CLIENT_ID, 15 | client_secret=CLIENT_SECRET, 16 | redirect_uri="http://localhost:3000/oauth2/callback", 17 | allow_insecure_http=True, 18 | ) 19 | 20 | 21 | @app.get("/oauth2/login") 22 | async def auth_init(): 23 | """Initialize auth and redirect""" 24 | async with sso: 25 | return await sso.get_login_redirect() 26 | 27 | 28 | @app.get("/oauth2/callback") 29 | async def auth_callback(request: Request): 30 | """Verify login""" 31 | async with sso: 32 | user = await sso.verify_and_process(request) 33 | return user 34 | 35 | 36 | if __name__ == "__main__": 37 | uvicorn.run(app="examples.notion:app", host="127.0.0.1", port=3000) 38 | -------------------------------------------------------------------------------- /examples/seznam.py: -------------------------------------------------------------------------------- 1 | """Seznam Login Example""" 2 | 3 | import os 4 | import uvicorn 5 | from fastapi import FastAPI 6 | from fastapi import Request 7 | 8 | from fastapi_sso.sso.seznam import SeznamSSO 9 | 10 | CLIENT_ID = os.environ["CLIENT_ID"] 11 | CLIENT_SECRET = os.environ["CLIENT_SECRET"] 12 | 13 | app = FastAPI() 14 | 15 | sso = SeznamSSO( 16 | client_id=CLIENT_ID, 17 | client_secret=CLIENT_SECRET, 18 | redirect_uri="http://localhost:5000/auth/callback", 19 | allow_insecure_http=True, 20 | ) 21 | 22 | 23 | @app.get("/auth/login") 24 | async def auth_init(): 25 | """Initialize auth and redirect""" 26 | with sso: 27 | return await sso.get_login_redirect() 28 | 29 | 30 | @app.get("/auth/callback") 31 | async def auth_callback(request: Request): 32 | """Verify login""" 33 | with sso: 34 | user = await sso.verify_and_process(request, params={"client_secret": CLIENT_SECRET}) # <- "client_secret" parameter is needed! 35 | return user 36 | 37 | 38 | if __name__ == "__main__": 39 | uvicorn.run(app="examples.seznam:app", host="127.0.0.1", port=5000) 40 | -------------------------------------------------------------------------------- /examples/twitter.py: -------------------------------------------------------------------------------- 1 | """Twitter (X) Login Example""" 2 | 3 | import os 4 | import uvicorn 5 | from fastapi import FastAPI, Request 6 | from fastapi_sso.sso.twitter import TwitterSSO 7 | 8 | CLIENT_ID = os.environ["CLIENT_ID"] 9 | CLIENT_SECRET = os.environ["CLIENT_SECRET"] 10 | 11 | app = FastAPI() 12 | 13 | sso = TwitterSSO( 14 | client_id=CLIENT_ID, 15 | client_secret=CLIENT_SECRET, 16 | redirect_uri="http://127.0.0.1:5000/auth/callback", 17 | allow_insecure_http=True, 18 | ) 19 | 20 | 21 | @app.get("/auth/login") 22 | async def auth_init(): 23 | """Initialize auth and redirect""" 24 | async with sso: 25 | return await sso.get_login_redirect() 26 | 27 | 28 | @app.get("/auth/callback") 29 | async def auth_callback(request: Request): 30 | """Verify login""" 31 | async with sso: 32 | user = await sso.verify_and_process(request) 33 | return user 34 | 35 | 36 | if __name__ == "__main__": 37 | uvicorn.run(app="examples.twitter:app", host="127.0.0.1", port=5000) 38 | -------------------------------------------------------------------------------- /examples/yandex.py: -------------------------------------------------------------------------------- 1 | """Yandex Login Example""" 2 | 3 | import os 4 | import uvicorn 5 | from fastapi import FastAPI, Request 6 | from fastapi_sso.sso.yandex import YandexSSO 7 | 8 | CLIENT_ID = os.environ["CLIENT_ID"] 9 | CLIENT_SECRET = os.environ["CLIENT_SECRET"] 10 | 11 | app = FastAPI() 12 | 13 | sso = YandexSSO( 14 | client_id=CLIENT_ID, 15 | client_secret=CLIENT_SECRET, 16 | redirect_uri="http://localhost:5000/auth/callback", 17 | allow_insecure_http=True, 18 | ) 19 | 20 | 21 | @app.get("/auth/login") 22 | async def auth_init(): 23 | """Initialize auth and redirect""" 24 | async with sso: 25 | return await sso.get_login_redirect() 26 | 27 | 28 | @app.get("/auth/callback") 29 | async def auth_callback(request: Request): 30 | """Verify login""" 31 | async with sso: 32 | user = await sso.verify_and_process(request) 33 | return user 34 | 35 | 36 | if __name__ == "__main__": 37 | uvicorn.run(app="examples.yandex:app", host="127.0.0.1", port=5000) 38 | -------------------------------------------------------------------------------- /fastapi_sso/__init__.py: -------------------------------------------------------------------------------- 1 | """FastAPI plugin to enable SSO to most common providers. 2 | 3 | (such as Facebook login, Google login and login via Microsoft Office 365 account) 4 | """ 5 | 6 | from .sso.base import OpenID, SSOBase, SSOLoginError 7 | from .sso.bitbucket import BitbucketSSO 8 | from .sso.discord import DiscordSSO 9 | from .sso.facebook import FacebookSSO 10 | from .sso.fitbit import FitbitSSO 11 | from .sso.generic import create_provider 12 | from .sso.github import GithubSSO 13 | from .sso.gitlab import GitlabSSO 14 | from .sso.google import GoogleSSO 15 | from .sso.kakao import KakaoSSO 16 | from .sso.line import LineSSO 17 | from .sso.linkedin import LinkedInSSO 18 | from .sso.microsoft import MicrosoftSSO 19 | from .sso.naver import NaverSSO 20 | from .sso.notion import NotionSSO 21 | from .sso.spotify import SpotifySSO 22 | from .sso.twitter import TwitterSSO 23 | 24 | __all__ = [ 25 | "BitbucketSSO", 26 | "DiscordSSO", 27 | "FacebookSSO", 28 | "FitbitSSO", 29 | "GithubSSO", 30 | "GitlabSSO", 31 | "GoogleSSO", 32 | "KakaoSSO", 33 | "LineSSO", 34 | "LinkedInSSO", 35 | "MicrosoftSSO", 36 | "NaverSSO", 37 | "NotionSSO", 38 | "OpenID", 39 | "SSOBase", 40 | "SSOLoginError", 41 | "SpotifySSO", 42 | "TwitterSSO", 43 | "create_provider", 44 | ] 45 | -------------------------------------------------------------------------------- /fastapi_sso/pkce.py: -------------------------------------------------------------------------------- 1 | """PKCE-related helper functions.""" 2 | 3 | import base64 4 | import hashlib 5 | import os 6 | 7 | 8 | def get_code_verifier(length: int = 96) -> str: 9 | """Get code verifier for PKCE challenge.""" 10 | length = max(43, min(length, 128)) 11 | bytes_length = int(length * 3 / 4) 12 | return base64.urlsafe_b64encode(os.urandom(bytes_length)).decode("utf-8").replace("=", "")[:length] 13 | 14 | 15 | def get_pkce_challenge_pair(verifier_length: int = 96) -> tuple[str, str]: 16 | """Get tuple of (verifier, challenge) for PKCE challenge.""" 17 | code_verifier = get_code_verifier(verifier_length) 18 | code_challenge = ( 19 | base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("utf-8")).digest()) 20 | .decode("utf-8") 21 | .replace("=", "") 22 | ) 23 | 24 | return (code_verifier, code_challenge) 25 | -------------------------------------------------------------------------------- /fastapi_sso/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomasvotava/fastapi-sso/51a3507e151fd34cc3435b110194eb9a424e5d12/fastapi_sso/py.typed -------------------------------------------------------------------------------- /fastapi_sso/sso/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomasvotava/fastapi-sso/51a3507e151fd34cc3435b110194eb9a424e5d12/fastapi_sso/sso/__init__.py -------------------------------------------------------------------------------- /fastapi_sso/sso/base.py: -------------------------------------------------------------------------------- 1 | """SSO login base dependency.""" 2 | 3 | import asyncio 4 | import json 5 | import logging 6 | import os 7 | import sys 8 | import warnings 9 | from types import TracebackType 10 | from typing import Any, ClassVar, Literal, Optional, TypedDict, TypeVar, Union, overload 11 | 12 | import httpx 13 | import jwt 14 | import pydantic 15 | from oauthlib.oauth2 import WebApplicationClient 16 | from starlette.exceptions import HTTPException 17 | from starlette.requests import Request 18 | from starlette.responses import RedirectResponse 19 | 20 | from fastapi_sso.pkce import get_pkce_challenge_pair 21 | from fastapi_sso.state import generate_random_state 22 | 23 | if sys.version_info < (3, 10): 24 | from typing import Callable # pragma: no cover 25 | 26 | from typing_extensions import ParamSpec # pragma: no cover 27 | else: 28 | from collections.abc import Callable 29 | from typing import ParamSpec 30 | 31 | logger = logging.getLogger(__name__) 32 | 33 | T = TypeVar("T") 34 | P = ParamSpec("P") 35 | 36 | 37 | def _decode_id_token(id_token: str, verify: bool = False) -> dict: 38 | return jwt.decode(id_token, options={"verify_signature": verify}) 39 | 40 | 41 | class DiscoveryDocument(TypedDict): 42 | """Discovery document.""" 43 | 44 | authorization_endpoint: str 45 | token_endpoint: str 46 | userinfo_endpoint: str 47 | 48 | 49 | class UnsetStateWarning(UserWarning): 50 | """Warning about unset state parameter.""" 51 | 52 | 53 | class ReusedOauthClientWarning(UserWarning): 54 | """Warning about reused oauth client instance.""" 55 | 56 | 57 | class SSOLoginError(HTTPException): 58 | """Raised when any login-related error ocurrs. 59 | 60 | Such as when user is not verified or if there was an attempt for fake login. 61 | """ 62 | 63 | 64 | class OpenID(pydantic.BaseModel): 65 | """Class (schema) to represent information got from sso provider in a common form.""" 66 | 67 | id: Optional[str] = None 68 | email: Optional[pydantic.EmailStr] = None 69 | first_name: Optional[str] = None 70 | last_name: Optional[str] = None 71 | display_name: Optional[str] = None 72 | picture: Optional[str] = None 73 | provider: Optional[str] = None 74 | 75 | 76 | class SecurityWarning(UserWarning): 77 | """Raised when insecure usage is detected""" 78 | 79 | 80 | def requires_async_context(func: Callable[P, T]) -> Callable[P, T]: 81 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: 82 | if not args or not isinstance(args[0], SSOBase): 83 | return func(*args, **kwargs) 84 | if not args[0]._in_stack: 85 | warnings.warn( 86 | "Please make sure you are using SSO provider in an async context (using 'async with provider:'). " 87 | "See https://github.com/tomasvotava/fastapi-sso/issues/186 for more information.", 88 | category=SecurityWarning, 89 | stacklevel=1, 90 | ) 91 | return func(*args, **kwargs) 92 | 93 | return wrapper 94 | 95 | 96 | class SSOBase: 97 | """Base class for all SSO providers.""" 98 | 99 | provider: str = NotImplemented 100 | client_id: str = NotImplemented 101 | client_secret: str = NotImplemented 102 | redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = NotImplemented 103 | scope: ClassVar[list[str]] = [] 104 | additional_headers: ClassVar[Optional[dict[str, Any]]] = None 105 | uses_pkce: bool = False 106 | requires_state: bool = False 107 | use_id_token_for_user_info: ClassVar[bool] = False 108 | 109 | _pkce_challenge_length: int = 96 110 | 111 | def __init__( 112 | self, 113 | client_id: str, 114 | client_secret: str, 115 | redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = None, 116 | allow_insecure_http: bool = False, 117 | use_state: bool = False, 118 | scope: Optional[list[str]] = None, 119 | ): 120 | """Base class (mixin) for all SSO providers.""" 121 | self.client_id: str = client_id 122 | self.client_secret: str = client_secret 123 | self.redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = redirect_uri 124 | self.allow_insecure_http: bool = allow_insecure_http 125 | self._login_lock = asyncio.Lock() 126 | self._in_stack = False 127 | self._oauth_client: Optional[WebApplicationClient] = None 128 | self._generated_state: Optional[str] = None 129 | 130 | if self.allow_insecure_http: 131 | logger.debug("Initializing %s with allow_insecure_http=True", self.__class__.__name__) 132 | os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" 133 | 134 | # TODO: Remove use_state argument and attribute 135 | if use_state: 136 | warnings.warn( 137 | ( 138 | "Argument 'use_state' of SSOBase's constructor is deprecated and will be removed in " 139 | "future releases. Use 'state' argument of individual methods instead." 140 | ), 141 | DeprecationWarning, 142 | ) 143 | self._scope = scope or self.scope 144 | self._refresh_token: Optional[str] = None 145 | self._id_token: Optional[str] = None 146 | self._state: Optional[str] = None 147 | self._pkce_code_challenge: Optional[str] = None 148 | self._pkce_code_verifier: Optional[str] = None 149 | self._pkce_challenge_method = "S256" 150 | 151 | @property 152 | def state(self) -> Optional[str]: 153 | """Retrieves the state as it was returned from the server. 154 | 155 | Warning: 156 | This will emit a warning if the state is unset, implying either that 157 | the server didn't return a state or `verify_and_process` hasn't been 158 | called yet. 159 | 160 | Returns: 161 | Optional[str]: The state parameter returned from the server. 162 | """ 163 | if self._state is None: 164 | warnings.warn( 165 | "'state' parameter is unset. This means the server either " 166 | "didn't return state (was this expected?) or 'verify_and_process' hasn't been called yet.", 167 | UnsetStateWarning, 168 | ) 169 | return self._state 170 | 171 | @property 172 | @requires_async_context 173 | def oauth_client(self) -> WebApplicationClient: 174 | """Retrieves the OAuth Client to aid in generating requests and parsing responses. 175 | 176 | Raises: 177 | NotImplementedError: If the provider is not supported or `client_id` is not set. 178 | 179 | Returns: 180 | WebApplicationClient: OAuth client instance. 181 | """ 182 | if self.client_id == NotImplemented: 183 | raise NotImplementedError(f"Provider {self.provider} not supported") # pragma: no cover 184 | if self._oauth_client is None: 185 | self._oauth_client = WebApplicationClient(self.client_id) 186 | return self._oauth_client 187 | 188 | @property 189 | @requires_async_context 190 | def access_token(self) -> Optional[str]: 191 | """Retrieves the access token from token endpoint. 192 | 193 | Returns: 194 | Optional[str]: The access token if available. 195 | """ 196 | return self.oauth_client.access_token 197 | 198 | @property 199 | @requires_async_context 200 | def refresh_token(self) -> Optional[str]: 201 | """Retrieves the refresh token if returned from provider. 202 | 203 | Returns: 204 | Optional[str]: The refresh token if available. 205 | """ 206 | return self._refresh_token or self.oauth_client.refresh_token 207 | 208 | @property 209 | @requires_async_context 210 | def id_token(self) -> Optional[str]: 211 | """Retrieves the id token if returned from provider. 212 | 213 | Returns: 214 | Optional[str]: The id token if available. 215 | """ 216 | return self._id_token 217 | 218 | async def openid_from_response(self, response: dict, session: Optional[httpx.AsyncClient] = None) -> OpenID: 219 | """Converts a response from the provider's user info endpoint to an OpenID object. 220 | 221 | Args: 222 | response (dict): The response from the user info endpoint. 223 | session (Optional[httpx.AsyncClient]): The HTTPX AsyncClient session. 224 | 225 | Raises: 226 | NotImplementedError: If the provider is not supported. 227 | 228 | Returns: 229 | OpenID: The user information in a standardized format. 230 | """ 231 | raise NotImplementedError(f"Provider {self.provider} not supported") 232 | 233 | async def openid_from_token(self, id_token: dict, session: Optional[httpx.AsyncClient] = None) -> OpenID: 234 | """Converts an ID token from the provider's token endpoint to an OpenID object. 235 | 236 | Args: 237 | id_token (dict): The id token data retrieved from the token endpoint. 238 | session: (Optional[httpx.AsyncClient]): The HTTPX AsyncClient session. 239 | 240 | Returns: 241 | OpenID: The user information in a standardized format. 242 | """ 243 | raise NotImplementedError(f"Provider {self.provider} not supported") 244 | 245 | async def get_discovery_document(self) -> DiscoveryDocument: 246 | """Retrieves the discovery document containing useful URLs. 247 | 248 | Raises: 249 | NotImplementedError: If the provider is not supported. 250 | 251 | Returns: 252 | DiscoveryDocument: A dictionary containing important endpoints like authorization, token and userinfo. 253 | """ 254 | raise NotImplementedError(f"Provider {self.provider} not supported") 255 | 256 | @property 257 | async def authorization_endpoint(self) -> Optional[str]: 258 | """Return `authorization_endpoint` from discovery document.""" 259 | discovery = await self.get_discovery_document() 260 | return discovery.get("authorization_endpoint") 261 | 262 | @property 263 | async def token_endpoint(self) -> Optional[str]: 264 | """Return `token_endpoint` from discovery document.""" 265 | discovery = await self.get_discovery_document() 266 | return discovery.get("token_endpoint") 267 | 268 | @property 269 | async def userinfo_endpoint(self) -> Optional[str]: 270 | """Return `userinfo_endpoint` from discovery document.""" 271 | discovery = await self.get_discovery_document() 272 | return discovery.get("userinfo_endpoint") 273 | 274 | async def get_login_url( 275 | self, 276 | *, 277 | redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = None, 278 | params: Optional[dict[str, Any]] = None, 279 | state: Optional[str] = None, 280 | ) -> str: 281 | """Generates and returns the prepared login URL. 282 | 283 | Args: 284 | redirect_uri (Optional[str]): Overrides the `redirect_uri` specified on this instance. 285 | params (Optional[dict[str, Any]]): Additional query parameters to add to the login request. 286 | state (Optional[str]): The state parameter for the OAuth 2.0 authorization request. 287 | 288 | Raises: 289 | ValueError: If `redirect_uri` is not provided either at construction or request time. 290 | 291 | Returns: 292 | str: The prepared login URL. 293 | """ 294 | params = params or {} 295 | redirect_uri = redirect_uri or self.redirect_uri 296 | if redirect_uri is None: 297 | raise ValueError("redirect_uri must be provided, either at construction or request time") 298 | if self.uses_pkce and not all((self._pkce_code_verifier, self._pkce_code_challenge)): 299 | warnings.warn( 300 | f"{self.__class__.__name__!r} uses PKCE and no code was generated yet. " 301 | "Use SSO class as a context manager to get rid of this warning and possible errors." 302 | ) 303 | if self.requires_state and not state: 304 | if self._generated_state is None: 305 | warnings.warn( 306 | f"{self.__class__.__name__!r} requires state in the request but none was provided nor " 307 | "generated automatically. Use SSO as a context manager. The login process will most probably fail." 308 | ) 309 | state = self._generated_state 310 | request_uri = self.oauth_client.prepare_request_uri( 311 | await self.authorization_endpoint, 312 | redirect_uri=redirect_uri, 313 | state=state, 314 | scope=self._scope, 315 | code_challenge=self._pkce_code_challenge, 316 | code_challenge_method=self._pkce_challenge_method, 317 | **params, 318 | ) 319 | return request_uri 320 | 321 | async def get_login_redirect( 322 | self, 323 | *, 324 | redirect_uri: Optional[str] = None, 325 | params: Optional[dict[str, Any]] = None, 326 | state: Optional[str] = None, 327 | ) -> RedirectResponse: 328 | """Constructs and returns a redirect response to the login page of OAuth SSO provider. 329 | 330 | Args: 331 | redirect_uri (Optional[str]): Overrides the `redirect_uri` specified on this instance. 332 | params (Optional[dict[str, Any]]): Additional query parameters to add to the login request. 333 | state (Optional[str]): The state parameter for the OAuth 2.0 authorization request. 334 | 335 | Returns: 336 | RedirectResponse: A Starlette response directing to the login page of the OAuth SSO provider. 337 | """ 338 | if self.requires_state and not state: 339 | state = self._generated_state 340 | login_uri = await self.get_login_url(redirect_uri=redirect_uri, params=params, state=state) 341 | response = RedirectResponse(login_uri, 303) 342 | if self.uses_pkce: 343 | response.set_cookie("pkce_code_verifier", str(self._pkce_code_verifier)) 344 | return response 345 | 346 | @overload 347 | async def verify_and_process( 348 | self, 349 | request: Request, 350 | *, 351 | params: Optional[dict[str, Any]] = None, 352 | headers: Optional[dict[str, Any]] = None, 353 | redirect_uri: Optional[str] = None, 354 | convert_response: Literal[True] = True, 355 | ) -> Optional[OpenID]: ... 356 | 357 | @overload 358 | async def verify_and_process( 359 | self, 360 | request: Request, 361 | *, 362 | params: Optional[dict[str, Any]] = None, 363 | headers: Optional[dict[str, Any]] = None, 364 | redirect_uri: Optional[str] = None, 365 | convert_response: Literal[False], 366 | ) -> Optional[dict[str, Any]]: ... 367 | 368 | @requires_async_context 369 | async def verify_and_process( 370 | self, 371 | request: Request, 372 | *, 373 | params: Optional[dict[str, Any]] = None, 374 | headers: Optional[dict[str, Any]] = None, 375 | redirect_uri: Optional[str] = None, 376 | convert_response: Union[Literal[True], Literal[False]] = True, 377 | ) -> Union[Optional[OpenID], Optional[dict[str, Any]]]: 378 | """Processes the login given a FastAPI (Starlette) Request object. This should be used for the /callback path. 379 | 380 | Args: 381 | request (Request): FastAPI or Starlette request object. 382 | params (Optional[dict[str, Any]]): Additional query parameters to pass to the provider. 383 | headers (Optional[dict[str, Any]]): Additional headers to pass to the provider. 384 | redirect_uri (Optional[str]): Overrides the `redirect_uri` specified on this instance. 385 | convert_response (bool): If True, userinfo response is converted to OpenID object. 386 | 387 | Raises: 388 | SSOLoginError: If the 'code' parameter is not found in the callback request. 389 | 390 | Returns: 391 | Optional[OpenID]: User information as OpenID instance (if convert_response == True) 392 | Optional[dict[str, Any]]: The original JSON response from the API. 393 | """ 394 | headers = headers or {} 395 | code = request.query_params.get("code") 396 | if code is None: 397 | logger.debug( 398 | "Callback request:\n\tURI: %s\n\tHeaders: %s\n\tQuery params: %s", 399 | request.url, 400 | request.headers, 401 | request.query_params, 402 | ) 403 | raise SSOLoginError(400, "'code' parameter was not found in callback request") 404 | self._state = request.query_params.get("state") 405 | pkce_code_verifier: Optional[str] = None 406 | if self.uses_pkce: 407 | pkce_code_verifier = request.cookies.get("pkce_code_verifier") 408 | if pkce_code_verifier is None: 409 | warnings.warn( 410 | "PKCE code verifier was not found in the request Cookie. This will probably lead to a login error." 411 | ) 412 | return await self.process_login( 413 | code, 414 | request, 415 | params=params, 416 | additional_headers=headers, 417 | redirect_uri=redirect_uri, 418 | pkce_code_verifier=pkce_code_verifier, 419 | convert_response=convert_response, 420 | ) 421 | 422 | def __enter__(self) -> "SSOBase": 423 | warnings.warn( 424 | "SSO Providers are supposed to be used in async context, please change 'with provider' to " 425 | "'async with provider'. See https://github.com/tomasvotava/fastapi-sso/issues/186 for more information.", 426 | DeprecationWarning, 427 | stacklevel=1, 428 | ) 429 | self._oauth_client = None 430 | self._refresh_token = None 431 | self._id_token = None 432 | self._state = None 433 | if self.requires_state: 434 | self._generated_state = generate_random_state() 435 | if self.uses_pkce: 436 | self._pkce_code_verifier, self._pkce_code_challenge = get_pkce_challenge_pair(self._pkce_challenge_length) 437 | return self 438 | 439 | async def __aenter__(self) -> "SSOBase": 440 | await self._login_lock.acquire() 441 | self._in_stack = True 442 | self._oauth_client = None 443 | self._refresh_token = None 444 | self._id_token = None 445 | self._state = None 446 | if self.requires_state: 447 | self._generated_state = generate_random_state() 448 | if self.uses_pkce: 449 | self._pkce_code_verifier, self._pkce_code_challenge = get_pkce_challenge_pair(self._pkce_challenge_length) 450 | return self 451 | 452 | async def __aexit__( 453 | self, 454 | _exc_type: Optional[type[BaseException]], 455 | _exc_val: Optional[BaseException], 456 | _exc_tb: Optional[TracebackType], 457 | ) -> None: 458 | self._in_stack = False 459 | self._login_lock.release() 460 | 461 | def __exit__( 462 | self, 463 | _exc_type: Optional[type[BaseException]], 464 | _exc_val: Optional[BaseException], 465 | _exc_tb: Optional[TracebackType], 466 | ) -> None: 467 | return None 468 | 469 | @property 470 | def _extra_query_params(self) -> dict: 471 | return {} 472 | 473 | @overload 474 | async def process_login( 475 | self, 476 | code: str, 477 | request: Request, 478 | *, 479 | params: Optional[dict[str, Any]] = None, 480 | additional_headers: Optional[dict[str, Any]] = None, 481 | redirect_uri: Optional[str] = None, 482 | pkce_code_verifier: Optional[str] = None, 483 | convert_response: Literal[True] = True, 484 | ) -> Optional[OpenID]: ... 485 | 486 | @overload 487 | async def process_login( 488 | self, 489 | code: str, 490 | request: Request, 491 | *, 492 | params: Optional[dict[str, Any]] = None, 493 | additional_headers: Optional[dict[str, Any]] = None, 494 | redirect_uri: Optional[str] = None, 495 | pkce_code_verifier: Optional[str] = None, 496 | convert_response: Literal[False], 497 | ) -> Optional[dict[str, Any]]: ... 498 | 499 | @requires_async_context 500 | async def process_login( 501 | self, 502 | code: str, 503 | request: Request, 504 | *, 505 | params: Optional[dict[str, Any]] = None, 506 | additional_headers: Optional[dict[str, Any]] = None, 507 | redirect_uri: Optional[str] = None, 508 | pkce_code_verifier: Optional[str] = None, 509 | convert_response: Union[Literal[True], Literal[False]] = True, 510 | ) -> Union[Optional[OpenID], Optional[dict[str, Any]]]: 511 | """Processes login from the callback endpoint to verify the user and request user info endpoint. 512 | It's a lower-level method, typically, you should use `verify_and_process` instead. 513 | 514 | Args: 515 | code (str): The authorization code. 516 | request (Request): FastAPI or Starlette request object. 517 | params (Optional[dict[str, Any]]): Additional query parameters to pass to the provider. 518 | additional_headers (Optional[dict[str, Any]]): Additional headers to be added to all requests. 519 | redirect_uri (Optional[str]): Overrides the `redirect_uri` specified on this instance. 520 | pkce_code_verifier (Optional[str]): A PKCE code verifier sent to the server to verify the login request. 521 | convert_response (bool): If True, userinfo response is converted to OpenID object. 522 | 523 | Raises: 524 | ReusedOauthClientWarning: If the SSO object is reused, which is not safe and caused security issues. 525 | 526 | Returns: 527 | Optional[OpenID]: User information in OpenID format if the login was successful (convert_response == True). 528 | Optional[dict[str, Any]]: Original userinfo API endpoint response. 529 | """ 530 | if self._oauth_client is not None: # pragma: no cover 531 | self._oauth_client = None 532 | self._refresh_token = None 533 | self._id_token = None 534 | warnings.warn( 535 | ( 536 | "Reusing the SSO object is not safe and caused a security issue in previous versions." 537 | "To make sure you don't see this warning, please use the SSO object as a context manager." 538 | ), 539 | ReusedOauthClientWarning, 540 | ) 541 | params = params or {} 542 | params.update(self._extra_query_params) 543 | additional_headers = additional_headers or {} 544 | additional_headers.update(self.additional_headers or {}) 545 | 546 | url = request.url 547 | 548 | if not self.allow_insecure_http and url.scheme != "https": 549 | current_url = str(url).replace("http://", "https://") 550 | else: 551 | current_url = str(url) 552 | 553 | current_path = f"{url.scheme}://{url.netloc}{url.path}" 554 | 555 | if pkce_code_verifier: 556 | params.update({"code_verifier": pkce_code_verifier}) 557 | 558 | token_url, headers, body = self.oauth_client.prepare_token_request( 559 | await self.token_endpoint, 560 | authorization_response=current_url, 561 | redirect_url=redirect_uri or self.redirect_uri or current_path, 562 | code=code, 563 | **params, 564 | ) # type: ignore 565 | 566 | if token_url is None: # pragma: no cover 567 | return None 568 | 569 | headers.update(additional_headers) 570 | 571 | auth = httpx.BasicAuth(self.client_id, self.client_secret) 572 | 573 | async with httpx.AsyncClient() as session: 574 | response = await session.post(token_url, headers=headers, content=body, auth=auth) 575 | content = response.json() 576 | self._refresh_token = content.get("refresh_token") 577 | self._id_token = content.get("id_token") 578 | self.oauth_client.parse_request_body_response(json.dumps(content)) 579 | 580 | uri, headers, _ = self.oauth_client.add_token(await self.userinfo_endpoint) 581 | headers.update(additional_headers) 582 | session.headers.update(headers) 583 | response = await session.get(uri) 584 | content = response.json() 585 | if convert_response: 586 | if self.use_id_token_for_user_info: 587 | if not self._id_token: 588 | raise SSOLoginError(401, f"Provider {self.provider!r} did not return id token.") 589 | return await self.openid_from_token(_decode_id_token(self._id_token), session) 590 | return await self.openid_from_response(content, session) 591 | return content 592 | -------------------------------------------------------------------------------- /fastapi_sso/sso/bitbucket.py: -------------------------------------------------------------------------------- 1 | """BitBucket SSO Oauth Helper class""" 2 | 3 | from typing import TYPE_CHECKING, ClassVar, Optional, Union 4 | 5 | import pydantic 6 | 7 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase 8 | 9 | if TYPE_CHECKING: 10 | import httpx # pragma: no cover 11 | 12 | 13 | class BitbucketSSO(SSOBase): 14 | """Class providing login using BitBucket OAuth""" 15 | 16 | provider = "bitbucket" 17 | scope: ClassVar = ["account", "email"] 18 | version = "2.0" 19 | 20 | def __init__( 21 | self, 22 | client_id: str, 23 | client_secret: str, 24 | redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = None, 25 | allow_insecure_http: bool = False, 26 | scope: Optional[list[str]] = None, 27 | ): 28 | super().__init__( 29 | client_id=client_id, 30 | client_secret=client_secret, 31 | redirect_uri=redirect_uri, 32 | allow_insecure_http=allow_insecure_http, 33 | scope=scope, 34 | ) 35 | 36 | async def get_useremail(self, session: Optional["httpx.AsyncClient"] = None) -> dict: 37 | """Get user email""" 38 | if session is None: 39 | raise ValueError("Session is required to make HTTP requests") 40 | 41 | response = await session.get(f"https://api.bitbucket.org/{self.version}/user/emails") 42 | return response.json() 43 | 44 | async def get_discovery_document(self) -> DiscoveryDocument: 45 | return { 46 | "authorization_endpoint": "https://bitbucket.org/site/oauth2/authorize", 47 | "token_endpoint": "https://bitbucket.org/site/oauth2/access_token", 48 | "userinfo_endpoint": f"https://api.bitbucket.org/{self.version}/user", 49 | } 50 | 51 | async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID: 52 | email = await self.get_useremail(session=session) 53 | return OpenID( 54 | email=email["values"][0]["email"], 55 | display_name=response.get("display_name"), 56 | provider=self.provider, 57 | id=str(response.get("uuid")).strip("{}"), 58 | first_name=response.get("nickname"), 59 | picture=response.get("links", {}).get("avatar", {}).get("href"), 60 | ) 61 | -------------------------------------------------------------------------------- /fastapi_sso/sso/discord.py: -------------------------------------------------------------------------------- 1 | """Discord SSO Oauth Helper class""" 2 | 3 | from typing import TYPE_CHECKING, ClassVar, Optional, Union 4 | 5 | import pydantic 6 | 7 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase 8 | 9 | if TYPE_CHECKING: 10 | import httpx # pragma: no cover 11 | 12 | 13 | class DiscordSSO(SSOBase): 14 | """Class providing login using Discord OAuth""" 15 | 16 | provider = "discord" 17 | scope: ClassVar = ["identify", "email", "openid"] 18 | 19 | def __init__( 20 | self, 21 | client_id: str, 22 | client_secret: str, 23 | redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = None, 24 | allow_insecure_http: bool = False, 25 | scope: Optional[list[str]] = None, 26 | ): 27 | super().__init__( 28 | client_id=client_id, 29 | client_secret=client_secret, 30 | redirect_uri=redirect_uri, 31 | allow_insecure_http=allow_insecure_http, 32 | scope=scope, 33 | ) 34 | 35 | async def get_discovery_document(self) -> DiscoveryDocument: 36 | return { 37 | "authorization_endpoint": "https://discord.com/oauth2/authorize", 38 | "token_endpoint": "https://discord.com/api/oauth2/token", 39 | "userinfo_endpoint": "https://discord.com/api/users/@me", 40 | } 41 | 42 | async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID: 43 | user_id = response.get("id") 44 | avatar = response.get("avatar") 45 | picture = None 46 | if user_id and avatar: 47 | picture = f"https://cdn.discordapp.com/avatars/{user_id}/{avatar}.png" 48 | 49 | return OpenID( 50 | email=response.get("email"), 51 | display_name=response.get("global_name"), 52 | provider=self.provider, 53 | id=user_id, 54 | first_name=response.get("username"), 55 | picture=picture, 56 | ) 57 | -------------------------------------------------------------------------------- /fastapi_sso/sso/facebook.py: -------------------------------------------------------------------------------- 1 | """Facebook SSO Login Helper.""" 2 | 3 | from typing import TYPE_CHECKING, ClassVar, Optional 4 | 5 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase 6 | 7 | if TYPE_CHECKING: 8 | import httpx # pragma: no cover 9 | 10 | 11 | class FacebookSSO(SSOBase): 12 | """Class providing login via Facebook OAuth.""" 13 | 14 | provider = "facebook" 15 | base_url = "https://graph.facebook.com/v19.0" 16 | scope: ClassVar = ["email"] 17 | 18 | async def get_discovery_document(self) -> DiscoveryDocument: 19 | """Get document containing handy urls.""" 20 | return { 21 | "authorization_endpoint": "https://www.facebook.com/v9.0/dialog/oauth", 22 | "token_endpoint": f"{self.base_url}/oauth/access_token", 23 | "userinfo_endpoint": f"{self.base_url}/me?fields=id,name,email,first_name,last_name,picture", 24 | } 25 | 26 | async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID: 27 | """Return OpenID from user information provided by Facebook.""" 28 | 29 | return OpenID( 30 | email=response.get("email"), 31 | first_name=response.get("first_name"), 32 | last_name=response.get("last_name"), 33 | display_name=response.get("name"), 34 | provider=self.provider, 35 | id=response.get("id"), 36 | picture=response.get("picture", {}).get("data", {}).get("url", None), 37 | ) 38 | -------------------------------------------------------------------------------- /fastapi_sso/sso/fitbit.py: -------------------------------------------------------------------------------- 1 | """Fitbit OAuth Login Helper.""" 2 | 3 | from typing import TYPE_CHECKING, ClassVar, Optional 4 | 5 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase, SSOLoginError 6 | 7 | if TYPE_CHECKING: 8 | import httpx # pragma: no cover 9 | 10 | 11 | class FitbitSSO(SSOBase): 12 | """Class providing login via Fitbit OAuth.""" 13 | 14 | provider = "fitbit" 15 | scope: ClassVar = ["profile"] 16 | 17 | async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID: 18 | """Return OpenID from user information provided by Google.""" 19 | info = response.get("user") 20 | if not info: 21 | raise SSOLoginError(401, "Failed to process login via Fitbit") 22 | return OpenID( 23 | id=info["encodedId"], 24 | first_name=info["fullName"], 25 | display_name=info["displayName"], 26 | picture=info["avatar"], 27 | provider=self.provider, 28 | ) 29 | 30 | async def get_discovery_document(self) -> DiscoveryDocument: 31 | """Get document containing handy urls.""" 32 | return { 33 | "authorization_endpoint": "https://www.fitbit.com/oauth2/authorize?response_type=code", 34 | "token_endpoint": "https://api.fitbit.com/oauth2/token", 35 | "userinfo_endpoint": "https://api.fitbit.com/1/user/-/profile.json", 36 | } 37 | -------------------------------------------------------------------------------- /fastapi_sso/sso/generic.py: -------------------------------------------------------------------------------- 1 | """A generic OAuth client that can be used to quickly create support for any OAuth provider 2 | with close to no code. 3 | """ 4 | 5 | import logging 6 | from typing import TYPE_CHECKING, Any, Callable, Optional, Union 7 | 8 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase 9 | 10 | if TYPE_CHECKING: 11 | import httpx # pragma: no cover 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def create_provider( 17 | *, 18 | name: str = "generic", 19 | default_scope: Optional[list[str]] = None, 20 | discovery_document: Union[DiscoveryDocument, Callable[[SSOBase], DiscoveryDocument]], 21 | response_convertor: Optional[Callable[[dict[str, Any], Optional["httpx.AsyncClient"]], OpenID]] = None 22 | ) -> type[SSOBase]: 23 | """A factory to create a generic OAuth client usable with almost any OAuth provider. 24 | Returns a class. 25 | 26 | Args: 27 | name: Name of the provider 28 | default_scope: default list of scopes (can be overriden in constructor) 29 | discovery_document: a dictionary containing discovery document or a callable returning it 30 | response_convertor: a callable that will receive JSON response from the userinfo endpoint 31 | and should return OpenID object 32 | 33 | Example: 34 | ```python 35 | from fastapi_sso.sso.generic import create_provider 36 | 37 | discovery = { 38 | "authorization_endpoint": "http://localhost:9090/auth", 39 | "token_endpoint": "http://localhost:9090/token", 40 | "userinfo_endpoint": "http://localhost:9090/me", 41 | } 42 | 43 | SSOProvider = create_provider(name="oidc", discovery_document=discovery) 44 | sso = SSOProvider( 45 | client_id="test", 46 | client_secret="secret", 47 | redirect_uri="http://localhost:8080/callback", 48 | allow_insecure_http=True 49 | ) 50 | ``` 51 | 52 | """ 53 | 54 | class GenericSSOProvider(SSOBase): 55 | """SSO Provider Template.""" 56 | 57 | provider = name 58 | scope = default_scope or ["openid"] 59 | 60 | async def get_discovery_document(self) -> DiscoveryDocument: 61 | """Get document containing handy urls.""" 62 | if callable(discovery_document): 63 | return discovery_document(self) 64 | return discovery_document 65 | 66 | async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID: 67 | if not response_convertor: 68 | logger.warning("No response convertor was provided, returned OpenID will always be empty") 69 | return OpenID( 70 | provider=self.provider, 71 | ) 72 | return response_convertor(response, session) 73 | 74 | return GenericSSOProvider 75 | -------------------------------------------------------------------------------- /fastapi_sso/sso/github.py: -------------------------------------------------------------------------------- 1 | """Github SSO Oauth Helper class.""" 2 | 3 | from typing import TYPE_CHECKING, ClassVar, Optional 4 | 5 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase 6 | 7 | if TYPE_CHECKING: 8 | import httpx # pragma: no cover 9 | 10 | 11 | class GithubSSO(SSOBase): 12 | """Class providing login via Github SSO.""" 13 | 14 | provider = "github" 15 | scope: ClassVar = ["user:email"] 16 | additional_headers: ClassVar = {"accept": "application/json"} 17 | emails_endpoint = "https://api.github.com/user/emails" 18 | 19 | async def get_discovery_document(self) -> DiscoveryDocument: 20 | return { 21 | "authorization_endpoint": "https://github.com/login/oauth/authorize", 22 | "token_endpoint": "https://github.com/login/oauth/access_token", 23 | "userinfo_endpoint": "https://api.github.com/user", 24 | } 25 | 26 | async def _get_primary_email(self, session: Optional["httpx.AsyncClient"] = None) -> Optional[str]: 27 | """Attempt to get primary email from Github for a current user. 28 | The session received must be authenticated. 29 | """ 30 | if not session: 31 | return None 32 | response = await session.get(self.emails_endpoint) 33 | if response.status_code != 200: 34 | return None 35 | emails = response.json() 36 | for email in emails: 37 | if email["primary"]: 38 | return email["email"] 39 | return None 40 | 41 | async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID: 42 | return OpenID( 43 | email=response.get("email") or (await self._get_primary_email(session)), 44 | provider=self.provider, 45 | id=str(response["id"]), 46 | display_name=response["login"], 47 | picture=response["avatar_url"], 48 | ) 49 | -------------------------------------------------------------------------------- /fastapi_sso/sso/gitlab.py: -------------------------------------------------------------------------------- 1 | """Gitlab SSO Oauth Helper class.""" 2 | 3 | from typing import TYPE_CHECKING, ClassVar, Optional, Union 4 | from urllib.parse import urljoin 5 | 6 | import pydantic 7 | 8 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase 9 | 10 | if TYPE_CHECKING: 11 | import httpx # pragma: no cover 12 | 13 | 14 | class GitlabSSO(SSOBase): 15 | """Class providing login via Gitlab SSO.""" 16 | 17 | provider = "gitlab" 18 | scope: ClassVar = ["read_user", "openid", "profile"] 19 | additional_headers: ClassVar = {"accept": "application/json"} 20 | base_endpoint_url = "https://gitlab.com" 21 | 22 | def __init__( 23 | self, 24 | client_id: str, 25 | client_secret: str, 26 | redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = None, 27 | allow_insecure_http: bool = False, 28 | use_state: bool = False, # TODO: Remove use_state argument 29 | scope: Optional[list[str]] = None, 30 | base_endpoint_url: Optional[str] = None, 31 | ) -> None: 32 | super().__init__( 33 | client_id, 34 | client_secret, 35 | redirect_uri, 36 | allow_insecure_http, 37 | use_state, # TODO: Remove use_state argument 38 | scope, 39 | ) 40 | self.base_endpoint_url = base_endpoint_url or self.base_endpoint_url 41 | 42 | async def get_discovery_document(self) -> DiscoveryDocument: 43 | """Override the discovery document method to return Yandex OAuth endpoints.""" 44 | return { 45 | "authorization_endpoint": urljoin(self.base_endpoint_url, "/oauth/authorize"), 46 | "token_endpoint": urljoin(self.base_endpoint_url, "/oauth/token"), 47 | "userinfo_endpoint": urljoin(self.base_endpoint_url, "/api/v4/user"), 48 | } 49 | 50 | def _parse_name(self, full_name: Optional[str]) -> tuple[Union[str, None], Union[str, None]]: 51 | """Parses the full name from Gitlab into the first and last name.""" 52 | if not full_name or not isinstance(full_name, str): 53 | return None, None 54 | 55 | name_parts = full_name.split() 56 | 57 | if len(name_parts) == 1: 58 | return name_parts[0], None 59 | 60 | first_name = name_parts[0] 61 | last_name = " ".join(name_parts[1:]) 62 | return first_name, last_name 63 | 64 | async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID: 65 | """Converts Gitlab user info response to OpenID object.""" 66 | first_name, last_name = self._parse_name(response.get("name")) 67 | 68 | return OpenID( 69 | email=response["email"], 70 | provider=self.provider, 71 | id=str(response["id"]), 72 | first_name=first_name, 73 | last_name=last_name, 74 | display_name=response["username"], 75 | picture=response["avatar_url"], 76 | ) 77 | -------------------------------------------------------------------------------- /fastapi_sso/sso/google.py: -------------------------------------------------------------------------------- 1 | """Google SSO Login Helper.""" 2 | 3 | from typing import ClassVar, Optional 4 | 5 | import httpx 6 | 7 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase, SSOLoginError 8 | 9 | 10 | class GoogleSSO(SSOBase): 11 | """Class providing login via Google OAuth.""" 12 | 13 | discovery_url = "https://accounts.google.com/.well-known/openid-configuration" 14 | provider = "google" 15 | scope: ClassVar = ["openid", "email", "profile"] 16 | 17 | async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID: 18 | """Return OpenID from user information provided by Google.""" 19 | if response.get("email_verified"): 20 | return OpenID( 21 | email=response.get("email"), 22 | provider=self.provider, 23 | id=response.get("sub"), 24 | first_name=response.get("given_name"), 25 | last_name=response.get("family_name"), 26 | display_name=response.get("name"), 27 | picture=response.get("picture"), 28 | ) 29 | raise SSOLoginError(401, f"User {response.get('email')} is not verified with Google") 30 | 31 | async def get_discovery_document(self) -> DiscoveryDocument: 32 | """Get document containing handy urls.""" 33 | async with httpx.AsyncClient() as session: 34 | response = await session.get(self.discovery_url) 35 | content = response.json() 36 | return content 37 | -------------------------------------------------------------------------------- /fastapi_sso/sso/kakao.py: -------------------------------------------------------------------------------- 1 | """Kakao SSO Oauth Helper class.""" 2 | 3 | from typing import TYPE_CHECKING, ClassVar, Optional 4 | 5 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase 6 | 7 | if TYPE_CHECKING: 8 | import httpx # pragma: no cover 9 | 10 | 11 | class KakaoSSO(SSOBase): 12 | """Class providing login using Kakao OAuth.""" 13 | 14 | provider = "kakao" 15 | scop: ClassVar = ["openid"] 16 | version = "v2" 17 | 18 | async def get_discovery_document(self) -> DiscoveryDocument: 19 | return { 20 | "authorization_endpoint": "https://kauth.kakao.com/oauth/authorize", 21 | "token_endpoint": "https://kauth.kakao.com/oauth/token", 22 | "userinfo_endpoint": f"https://kapi.kakao.com/{self.version}/user/me", 23 | } 24 | 25 | async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID: 26 | return OpenID(display_name=response["properties"]["nickname"], provider=self.provider) 27 | -------------------------------------------------------------------------------- /fastapi_sso/sso/line.py: -------------------------------------------------------------------------------- 1 | """Line SSO Login Helper.""" 2 | 3 | from typing import TYPE_CHECKING, ClassVar, Optional 4 | 5 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase 6 | 7 | if TYPE_CHECKING: 8 | import httpx # pragma: no cover 9 | 10 | 11 | class LineSSO(SSOBase): 12 | """Class providing login via Line OAuth.""" 13 | 14 | provider = "line" 15 | base_url = "https://api.line.me/oauth2/v2.1" 16 | scope: ClassVar = ["email", "profile", "openid"] 17 | 18 | async def get_discovery_document(self) -> DiscoveryDocument: 19 | """Get document containing handy urls.""" 20 | return { 21 | "authorization_endpoint": "https://access.line.me/oauth2/v2.1/authorize", 22 | "token_endpoint": f"{self.base_url}/token", 23 | "userinfo_endpoint": f"{self.base_url}/userinfo", 24 | } 25 | 26 | async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID: 27 | """Return OpenID from user information provided by Line.""" 28 | return OpenID( 29 | email=response.get("email"), 30 | first_name=None, 31 | last_name=None, 32 | display_name=response.get("name"), 33 | provider=self.provider, 34 | id=response.get("sub"), 35 | picture=response.get("picture"), 36 | ) 37 | -------------------------------------------------------------------------------- /fastapi_sso/sso/linkedin.py: -------------------------------------------------------------------------------- 1 | """LinkedIn SSO Oauth Helper class.""" 2 | 3 | from typing import TYPE_CHECKING, ClassVar, Optional 4 | 5 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase 6 | 7 | if TYPE_CHECKING: 8 | import httpx # pragma: no cover 9 | 10 | 11 | class LinkedInSSO(SSOBase): 12 | """Class providing login via LinkedIn SSO.""" 13 | 14 | provider = "linkedin" 15 | scope: ClassVar = ["openid", "profile", "email"] 16 | additional_headers: ClassVar = {"accept": "application/json"} 17 | use_id_token_for_user_info: ClassVar = True 18 | 19 | @property 20 | def _extra_query_params(self) -> dict: 21 | return {"client_secret": self.client_secret} 22 | 23 | async def get_discovery_document(self) -> DiscoveryDocument: 24 | return { 25 | "authorization_endpoint": "https://www.linkedin.com/oauth/v2/authorization", 26 | "token_endpoint": "https://www.linkedin.com/oauth/v2/accessToken", 27 | "userinfo_endpoint": "https://api.linkedin.com/v2/userinfo", 28 | } 29 | 30 | async def openid_from_token(self, id_token: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID: 31 | return await self.openid_from_response(id_token, session) 32 | 33 | async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID: 34 | return OpenID( 35 | email=response.get("email"), 36 | provider=self.provider, 37 | id=response.get("sub"), 38 | first_name=response.get("given_name"), 39 | last_name=response.get("family_name"), 40 | picture=response.get("picture"), 41 | ) 42 | -------------------------------------------------------------------------------- /fastapi_sso/sso/microsoft.py: -------------------------------------------------------------------------------- 1 | """Microsoft SSO Oauth Helper class.""" 2 | 3 | from typing import TYPE_CHECKING, ClassVar, Optional, Union 4 | 5 | import pydantic 6 | 7 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase 8 | 9 | if TYPE_CHECKING: 10 | import httpx # pragma: no cover 11 | 12 | 13 | class MicrosoftSSO(SSOBase): 14 | """Class providing login using Microsoft OAuth.""" 15 | 16 | provider = "microsoft" 17 | scope: ClassVar = ["openid", "User.Read", "email"] 18 | version = "v1.0" 19 | tenant: str = "common" 20 | 21 | def __init__( 22 | self, 23 | client_id: str, 24 | client_secret: str, 25 | redirect_uri: Optional[Union[pydantic.AnyHttpUrl, str]] = None, 26 | allow_insecure_http: bool = False, 27 | use_state: bool = False, # TODO: Remove use_state argument 28 | scope: Optional[list[str]] = None, 29 | tenant: Optional[str] = None, 30 | ): 31 | super().__init__( 32 | client_id=client_id, 33 | client_secret=client_secret, 34 | redirect_uri=redirect_uri, 35 | allow_insecure_http=allow_insecure_http, 36 | use_state=use_state, # TODO: Remove use_state argument 37 | scope=scope, 38 | ) 39 | self.tenant = tenant or self.tenant 40 | 41 | async def get_discovery_document(self) -> DiscoveryDocument: 42 | return { 43 | "authorization_endpoint": f"https://login.microsoftonline.com/{self.tenant}/oauth2/v2.0/authorize", 44 | "token_endpoint": f"https://login.microsoftonline.com/{self.tenant}/oauth2/v2.0/token", 45 | "userinfo_endpoint": f"https://graph.microsoft.com/{self.version}/me", 46 | } 47 | 48 | async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID: 49 | return OpenID( 50 | email=response.get("mail"), 51 | display_name=response.get("displayName"), 52 | provider=self.provider, 53 | id=response.get("id"), 54 | first_name=response.get("givenName"), 55 | last_name=response.get("surname"), 56 | ) 57 | -------------------------------------------------------------------------------- /fastapi_sso/sso/naver.py: -------------------------------------------------------------------------------- 1 | """Naver SSO Oauth Helper class.""" 2 | 3 | from typing import TYPE_CHECKING, ClassVar, Optional 4 | 5 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase 6 | 7 | if TYPE_CHECKING: 8 | import httpx # pragma: no cover 9 | 10 | 11 | class NaverSSO(SSOBase): 12 | """Class providing login using Naver OAuth.""" 13 | 14 | provider = "naver" 15 | scope: ClassVar[list[str]] = [] 16 | additional_headers: ClassVar = {"accept": "application/json"} 17 | 18 | async def get_discovery_document(self) -> DiscoveryDocument: 19 | return { 20 | "authorization_endpoint": "https://nid.naver.com/oauth2.0/authorize", 21 | "token_endpoint": "https://nid.naver.com/oauth2.0/token", 22 | "userinfo_endpoint": "https://openapi.naver.com/v1/nid/me", 23 | } 24 | 25 | async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID: 26 | return OpenID( 27 | id=response["response"]["id"], 28 | email=response["response"].get("email"), 29 | display_name=response["response"].get("nickname"), 30 | picture=response["response"].get("profile_image"), 31 | provider=self.provider, 32 | ) 33 | -------------------------------------------------------------------------------- /fastapi_sso/sso/notion.py: -------------------------------------------------------------------------------- 1 | """Notion SSO Oauth Helper class.""" 2 | 3 | from typing import TYPE_CHECKING, ClassVar, Optional 4 | 5 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase, SSOLoginError 6 | 7 | if TYPE_CHECKING: 8 | import httpx # pragma: no cover 9 | 10 | 11 | class NotionSSO(SSOBase): 12 | """Class providing login using Notion OAuth.""" 13 | 14 | provider = "notion" 15 | scope: ClassVar = ["openid"] 16 | additional_headers: ClassVar = {"Notion-Version": "2022-06-28"} 17 | 18 | async def get_discovery_document(self) -> DiscoveryDocument: 19 | return { 20 | "authorization_endpoint": "https://api.notion.com/v1/oauth/authorize?owner=user", 21 | "token_endpoint": "https://api.notion.com/v1/oauth/token", 22 | "userinfo_endpoint": "https://api.notion.com/v1/users/me", 23 | } 24 | 25 | async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID: 26 | owner = response["bot"]["owner"] 27 | if owner["type"] != "user": 28 | raise SSOLoginError(401, f"Notion login failed, owner is not a user but {response['bot']['owner']['type']}") 29 | return OpenID( 30 | id=owner["user"]["id"], 31 | email=owner["user"]["person"]["email"], 32 | picture=owner["user"]["avatar_url"], 33 | display_name=owner["user"]["name"], 34 | provider=self.provider, 35 | ) 36 | -------------------------------------------------------------------------------- /fastapi_sso/sso/seznam.py: -------------------------------------------------------------------------------- 1 | """Seznam SSO Login Helper.""" 2 | 3 | from typing import TYPE_CHECKING, ClassVar, Optional 4 | 5 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase 6 | 7 | if TYPE_CHECKING: 8 | import httpx # pragma: no cover 9 | 10 | 11 | # https://vyvojari.seznam.cz/oauth/doc 12 | 13 | 14 | class SeznamSSO(SSOBase): 15 | """Class providing login via Seznam OAuth.""" 16 | 17 | provider = "seznam" 18 | base_url = "https://login.szn.cz/api/v1" 19 | scope: ClassVar = ["identity", "avatar"] # + ["contact-phone", "adulthood", "birthday", "gender"] 20 | 21 | async def get_discovery_document(self) -> DiscoveryDocument: 22 | """Get document containing handy urls.""" 23 | return { 24 | "authorization_endpoint": f"{self.base_url}/oauth/auth", 25 | "token_endpoint": f"{self.base_url}/oauth/token", 26 | "userinfo_endpoint": f"{self.base_url}/user", 27 | } 28 | 29 | async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID: 30 | """Return OpenID from user information provided by Seznam.""" 31 | return OpenID( 32 | email=response.get("email"), 33 | first_name=response.get("firstname"), 34 | last_name=response.get("lastname"), 35 | display_name=response.get("accountDisplayName"), 36 | provider=self.provider, 37 | id=response.get("oauth_user_id"), 38 | picture=response.get("avatar_url"), 39 | ) 40 | -------------------------------------------------------------------------------- /fastapi_sso/sso/spotify.py: -------------------------------------------------------------------------------- 1 | """Spotify SSO Login Helper.""" 2 | 3 | from typing import TYPE_CHECKING, ClassVar, Optional 4 | 5 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase 6 | 7 | if TYPE_CHECKING: 8 | import httpx # pragma: no cover 9 | 10 | 11 | class SpotifySSO(SSOBase): 12 | """Class providing login via Spotify OAuth.""" 13 | 14 | provider = "spotify" 15 | scope: ClassVar = ["user-read-private", "user-read-email"] 16 | 17 | async def get_discovery_document(self) -> DiscoveryDocument: 18 | """Get document containing handy urls.""" 19 | return { 20 | "authorization_endpoint": "https://accounts.spotify.com/authorize", 21 | "token_endpoint": "https://accounts.spotify.com/api/token", 22 | "userinfo_endpoint": "https://api.spotify.com/v1/me", 23 | } 24 | 25 | async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID: 26 | """Return OpenID from user information provided by Spotify.""" 27 | picture = response["images"][0]["url"] if response.get("images", []) else None 28 | return OpenID( 29 | email=response.get("email"), 30 | display_name=response.get("display_name"), 31 | provider=self.provider, 32 | id=response.get("id"), 33 | picture=picture, 34 | ) 35 | -------------------------------------------------------------------------------- /fastapi_sso/sso/twitter.py: -------------------------------------------------------------------------------- 1 | """Twitter (X) SSO Oauth Helper class.""" 2 | 3 | from typing import TYPE_CHECKING, ClassVar, Optional 4 | 5 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase 6 | 7 | if TYPE_CHECKING: 8 | import httpx # pragma: no cover 9 | 10 | 11 | class TwitterSSO(SSOBase): 12 | """Class providing login via Twitter SSO.""" 13 | 14 | provider = "twitter" 15 | scope: ClassVar = ["users.read", "tweet.read"] 16 | uses_pkce = True 17 | requires_state = True 18 | 19 | async def get_discovery_document(self) -> DiscoveryDocument: 20 | return { 21 | "authorization_endpoint": "https://twitter.com/i/oauth2/authorize", 22 | "token_endpoint": "https://api.twitter.com/2/oauth2/token", 23 | "userinfo_endpoint": "https://api.twitter.com/2/users/me", 24 | } 25 | 26 | async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID: 27 | first_name, *last_name_parts = response["data"].get("name", "").split(" ") 28 | last_name = " ".join(last_name_parts) if last_name_parts else None 29 | return OpenID( 30 | id=str(response["data"]["id"]), 31 | display_name=response["data"]["username"], 32 | first_name=first_name, 33 | last_name=last_name, 34 | provider=self.provider, 35 | ) 36 | -------------------------------------------------------------------------------- /fastapi_sso/sso/yandex.py: -------------------------------------------------------------------------------- 1 | """Yandex SSO Login Helper.""" 2 | 3 | from typing import TYPE_CHECKING, ClassVar, Optional 4 | 5 | from fastapi_sso.sso.base import DiscoveryDocument, OpenID, SSOBase 6 | 7 | if TYPE_CHECKING: 8 | import httpx # pragma: no cover 9 | 10 | 11 | class YandexSSO(SSOBase): 12 | """Class providing login using Yandex OAuth.""" 13 | 14 | provider = "yandex" 15 | scope: ClassVar = ["login:email", "login:info", "login:avatar"] 16 | avatar_url = "https://avatars.yandex.net/get-yapic" 17 | 18 | async def get_discovery_document(self) -> DiscoveryDocument: 19 | """Override the discovery document method to return Yandex OAuth endpoints.""" 20 | return { 21 | "authorization_endpoint": "https://oauth.yandex.ru/authorize", 22 | "token_endpoint": "https://oauth.yandex.ru/token", 23 | "userinfo_endpoint": "https://login.yandex.ru/info", 24 | } 25 | 26 | async def openid_from_response(self, response: dict, session: Optional["httpx.AsyncClient"] = None) -> OpenID: 27 | """Converts Yandex user info response to OpenID object.""" 28 | picture = None 29 | 30 | if (avatar_id := response.get("default_avatar_id")) is not None: 31 | picture = f"{self.avatar_url}/{avatar_id}/islands-200" 32 | 33 | return OpenID( 34 | email=response.get("default_email"), 35 | display_name=response.get("display_name"), 36 | provider=self.provider, 37 | id=response.get("id"), 38 | first_name=response.get("first_name"), 39 | last_name=response.get("last_name"), 40 | picture=picture, 41 | ) 42 | -------------------------------------------------------------------------------- /fastapi_sso/state.py: -------------------------------------------------------------------------------- 1 | """Helper functions to generate state param.""" 2 | 3 | import base64 4 | import os 5 | 6 | 7 | def generate_random_state(length: int = 64) -> str: 8 | """Generate a url-safe string to use as a state.""" 9 | bytes_length = int(length * 3 / 4) 10 | return base64.urlsafe_b64encode(os.urandom(bytes_length)).decode("utf-8") 11 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: fastapi-sso 2 | site_url: https://tomasvotava.github.io/fastapi-sso/ 3 | site_author: Tomas Votava 4 | site_dir: ./public 5 | theme: 6 | features: 7 | - navigation.instant 8 | # - navigation.sections 9 | # - navigation.expand 10 | - navigation.path 11 | - toc.follow 12 | - toc.integrate 13 | name: material 14 | palette: 15 | - media: "(prefers-color-scheme: light)" 16 | scheme: default 17 | primary: blue grey 18 | accent: red 19 | toggle: 20 | icon: material/brightness-7 21 | - media: "(prefers-color-scheme: dark)" 22 | scheme: slate 23 | primary: blue grey 24 | accent: yellow 25 | toggle: 26 | icon: material/brightness-4 27 | font: 28 | text: Roboto 29 | code: Roboto Mono 30 | language: en 31 | icon: 32 | logo: fontawesome/solid/users 33 | repo_url: https://github.com/tomasvotava/fastapi-sso 34 | extra: 35 | consent: 36 | title: Cookie consent 37 | description: >- 38 | We use cookies to recognize your repeated visits and preferences, as well 39 | as to measure the effectiveness of our documentation and whether users 40 | find what they're searching for. With your consent, you're helping us to 41 | make our documentation better. 42 | cookies: 43 | github: GitHub 44 | analytics: Google analytics 45 | analytics: 46 | provider: google 47 | property: 407765678 48 | social: 49 | - icon: material/github 50 | link: https://github.com/tomasvotava/fastapi-sso 51 | - icon: simple/buymeacoffee 52 | link: https://www.buymeacoffee.com/tomas.votava 53 | copyright: > 54 | Copyright © 2023 Tomas Votava – 55 | Change cookie settings 56 | plugins: 57 | - search 58 | - social 59 | - mkdocstrings: 60 | handlers: 61 | python: 62 | options: 63 | show_source: false 64 | show_symbol_type_heading: true 65 | show_symbol_type_toc: true 66 | summary: true 67 | docstring_style: google 68 | markdown_extensions: 69 | - admonition 70 | - pymdownx.highlight: 71 | anchor_linenums: true 72 | auto_title: true 73 | linenums: true 74 | - pymdownx.superfences 75 | - markdown_include.include: 76 | base_path: docs 77 | - toc: 78 | toc_depth: 2 79 | 80 | hooks: 81 | - ./docs/generate_reference.py 82 | 83 | nav: 84 | - index.md 85 | - tutorials.md 86 | - How-to Guides: 87 | - how-to-guides/00-installation.md 88 | - how-to-guides/additional-query-params.md 89 | - how-to-guides/additional-scopes.md 90 | - how-to-guides/http-development.md 91 | - how-to-guides/redirect-uri-request-time.md 92 | - how-to-guides/state-return-url.md 93 | - how-to-guides/use-with-fastapi-security.md 94 | - how-to-guides/key-error.md 95 | - contributing.md 96 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = true 3 | exclude = tests/ 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fastapi-sso" 3 | version = "0.18.0" 4 | description = "FastAPI plugin to enable SSO to most common providers (such as Facebook login, Google login and login via Microsoft Office 365 Account)" 5 | authors = ["Tomas Votava "] 6 | readme = "README.md" 7 | license = "MIT" 8 | repository = "https://github.com/tomasvotava/fastapi-sso" 9 | homepage = "https://tomasvotava.github.io/fastapi-sso/" 10 | documentation = "https://tomasvotava.github.io/fastapi-sso/" 11 | keywords = [ 12 | "fastapi", 13 | "sso", 14 | "oauth", 15 | "google", 16 | "facebook", 17 | "spotify", 18 | "linkedin", 19 | ] 20 | include = ["fastapi_sso/py.typed"] 21 | 22 | 23 | [tool.pytest.ini_options] 24 | testpaths = ["tests/"] 25 | asyncio_mode = "auto" 26 | addopts = [ 27 | "-v", 28 | "--cov=fastapi_sso", 29 | "--cov-report=xml:coverage.xml", 30 | "--cov-report=json:coverage.json", 31 | "--cov-report=term-missing", 32 | ] 33 | 34 | 35 | [tool.black] 36 | line-length = 120 37 | 38 | [tool.ruff] 39 | target-version = "py39" 40 | line-length = 120 41 | 42 | [tool.ruff.lint] 43 | select = [ 44 | #"D", 45 | "E", 46 | "F", 47 | "B", 48 | "I", 49 | "N", 50 | "UP", 51 | "S", 52 | "A", 53 | "DTZ", 54 | "PT", 55 | "SIM", 56 | "PTH", 57 | "PD", 58 | "RUF", 59 | "T20", 60 | ] 61 | 62 | ignore = [ 63 | "B028", # allow warning without specifying `stacklevel` 64 | ] 65 | 66 | [tool.ruff.lint.pydocstyle] 67 | convention = "google" 68 | 69 | [tool.ruff.lint.isort] 70 | known-first-party = ["fastapi_sso"] 71 | 72 | [tool.ruff.lint.per-file-ignores] 73 | "tests*/**/*.py" = ["S101"] # Allow asserts in tests 74 | "**/__init__.py" = ["D104"] # Allow missing docstrings in __init__ files 75 | 76 | [tool.poe.tasks] 77 | ruff = "ruff check fastapi_sso" 78 | black = "black fastapi_sso" 79 | isort = "isort --settings-path .isort.cfg fastapi_sso" 80 | mypy = "mypy --config-file mypy.ini fastapi_sso" 81 | black-check = "black --check fastapi_sso" 82 | isort-check = "isort --settings-path .isort.cfg --check-only fastapi_sso" 83 | 84 | format = ["black", "isort"] 85 | lint = ["ruff", "mypy", "black-check", "isort-check"] 86 | pre-commit = "pre-commit" 87 | 88 | test = "pytest" 89 | coverage = "coverage report" 90 | 91 | docs = "mkdocs build --clean" 92 | 93 | [tool.poetry.group.dev.dependencies] 94 | black = ">=23.7.0" 95 | isort = ">=5,<7" 96 | markdown-include = "^0.8.1" 97 | mkdocs-material = { extras = ["imaging"], version = "^9.3.2" } 98 | mkdocstrings = { extras = ["python"], version = ">=0.23,<0.30" } 99 | mypy = "^1" 100 | poethepoet = ">=0.21.1,<0.35.0" 101 | pre-commit = ">=3,<5" 102 | pytest = ">=7,<9" 103 | pytest-asyncio = ">=0.24,<0.27" 104 | pytest-cov = ">=4,<7" 105 | uvicorn = ">=0.23.1" 106 | ruff = ">=0.4.2,<0.12.0" 107 | 108 | [tool.poetry.dependencies] 109 | fastapi = ">=0.80" 110 | httpx = ">=0.23.0" 111 | oauthlib = ">=3.1.0" 112 | pydantic = { extras = ["email"], version = ">=1.8.0" } 113 | python = ">=3.9,<4.0" 114 | typing-extensions = { version = "^4.12.2", python = "<3.10" } 115 | pyjwt = "^2.10.1" 116 | 117 | [build-system] 118 | requires = ["poetry-core>=1.0.0"] 119 | build-backend = "poetry.core.masonry.api" 120 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | 3 | import os 4 | 5 | import pytest 6 | from utils import Request 7 | 8 | from fastapi_sso.sso.base import SecurityWarning, SSOBase, SSOLoginError, UnsetStateWarning, requires_async_context 9 | 10 | 11 | class TestSSOBase: 12 | def test_base(self): 13 | sso = SSOBase("client_id", "client_secret") 14 | assert sso.client_id == "client_id" 15 | assert sso.client_secret == "client_secret" 16 | assert sso._oauth_client is None 17 | assert sso._refresh_token is None 18 | assert sso._state is None 19 | with pytest.warns(SecurityWarning, match="Please make sure you are using SSO provider in an async context"): 20 | assert sso.oauth_client is not None 21 | assert sso.access_token is None 22 | assert sso.refresh_token is None 23 | assert sso.id_token is None 24 | 25 | async def test_unset_usage(self): 26 | sso = SSOBase("client_id", "client_secret") 27 | with pytest.warns(UnsetStateWarning): 28 | assert sso.state is None 29 | 30 | with pytest.raises(ValueError): 31 | await sso.get_login_url() 32 | 33 | def test_state_warning(self): 34 | with pytest.warns(UnsetStateWarning): 35 | sso = SSOBase("client_id", "client_secret") 36 | sso.state 37 | 38 | def test_deprecated_use_state_warning(self): 39 | with pytest.warns(DeprecationWarning): 40 | SSOBase("client_id", "client_secret", use_state=True) 41 | 42 | async def test_not_implemented_ssobase(self): 43 | sso = SSOBase("client_id", "client_secret") 44 | with pytest.raises(NotImplementedError): 45 | await sso.openid_from_response({}) 46 | with pytest.raises(NotImplementedError): 47 | await sso.get_discovery_document() 48 | with pytest.raises(NotImplementedError): 49 | await sso.openid_from_token({}) 50 | 51 | request = Request() 52 | request.query_params["code"] = "code" 53 | with pytest.raises(NotImplementedError), pytest.warns( 54 | SecurityWarning, match="Please make sure you are using SSO provider in an async context" 55 | ): 56 | await sso.verify_and_process(request) 57 | 58 | sso.client_id = NotImplemented 59 | with pytest.raises(NotImplementedError), pytest.warns( 60 | SecurityWarning, match="Please make sure you are using SSO provider in an async context" 61 | ): 62 | _ = sso.oauth_client 63 | 64 | async def test_login_error(self): 65 | sso = SSOBase("client_id", "client_secret") 66 | 67 | with pytest.raises(SSOLoginError), pytest.warns( 68 | SecurityWarning, match="Please make sure you are using SSO provider in an async context" 69 | ): 70 | await sso.verify_and_process(Request()) 71 | 72 | def test_autoset_insecure_transport_env_var(self): 73 | assert not os.getenv( 74 | "OAUTHLIB_INSECURE_TRANSPORT" 75 | ), "OAUTHLIB_INSECURE_TRANSPORT should not be true before test" 76 | SSOBase("client_id", "client_secret", allow_insecure_http=True) 77 | assert os.getenv("OAUTHLIB_INSECURE_TRANSPORT"), "OAUTHLIB_INSECURE_TRANSPORT should be truthy after test" 78 | 79 | def test_warns_on_sync_context(self): 80 | sso = SSOBase("client_id", "client_secret") 81 | with pytest.warns(DeprecationWarning, match="SSO Providers are supposed to be used in async context"), sso: 82 | assert sso._state is None 83 | assert sso._oauth_client is None 84 | assert sso._id_token is None 85 | assert sso._refresh_token is None 86 | assert bool(sso._generated_state) == sso.requires_state 87 | assert bool(sso._pkce_code_verifier) == bool(sso.uses_pkce) 88 | assert bool(sso._pkce_code_challenge) == bool(sso.uses_pkce) 89 | 90 | def test_async_context_decorator(self): 91 | sso = SSOBase("client_id", "client_secret") 92 | sso._in_stack = False 93 | 94 | @requires_async_context 95 | def method(sso: SSOBase): 96 | return 97 | 98 | @requires_async_context 99 | def function(ret): 100 | return ret 101 | 102 | with pytest.warns(SecurityWarning, match="Please make sure you are using SSO provider in an async context"): 103 | method(sso) 104 | 105 | assert function(42) == 42 106 | -------------------------------------------------------------------------------- /tests/test_generic_provider.py: -------------------------------------------------------------------------------- 1 | from fastapi_sso.sso.base import OpenID 2 | from fastapi_sso.sso.generic import create_provider 3 | 4 | DISCOVERY = { 5 | "authorization_endpoint": "http://localhost:9090/auth", 6 | "token_endpoint": "http://localhost:9090/token", 7 | "userinfo_endpoint": "http://localhost:9090/me", 8 | } 9 | 10 | 11 | class TestGenericProvider: 12 | async def test_discovery_document_static(self): 13 | Provider = create_provider(discovery_document=DISCOVERY) 14 | sso = Provider("client_id", "client_secret") 15 | document = await sso.get_discovery_document() 16 | assert document == DISCOVERY 17 | 18 | async def test_discovery_document_callable(self): 19 | Provider = create_provider(discovery_document=lambda _: DISCOVERY) 20 | sso = Provider("client_id", "client_secret") 21 | document = await sso.get_discovery_document() 22 | assert document == DISCOVERY 23 | 24 | async def test_empty_response_convertor(self): 25 | Provider = create_provider(discovery_document=DISCOVERY) 26 | sso = Provider("client_id", "client_secret") 27 | openid = await sso.openid_from_response({}) 28 | assert openid.provider == Provider.provider 29 | assert openid.id is None 30 | 31 | async def test_response_convertor(self): 32 | Provider = create_provider( 33 | discovery_document=DISCOVERY, 34 | response_convertor=lambda response, _: OpenID( 35 | id=response["id"], email=response["email"], display_name=response["display_name"] 36 | ), 37 | ) 38 | sso = Provider("client_id", "client_secret") 39 | openid_response = {"id": "test", "email": "email@example.com", "display_name": "Test"} 40 | openid = await sso.openid_from_response(openid_response) 41 | assert openid.provider is None 42 | assert openid.id == openid_response["id"] 43 | assert openid.email == openid_response["email"] 44 | assert openid.display_name == openid_response["display_name"] 45 | -------------------------------------------------------------------------------- /tests/test_openid_responses.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | 5 | from fastapi_sso.sso.base import OpenID, SSOBase 6 | from fastapi_sso.sso.discord import DiscordSSO 7 | from fastapi_sso.sso.facebook import FacebookSSO 8 | from fastapi_sso.sso.fitbit import FitbitSSO 9 | from fastapi_sso.sso.github import GithubSSO 10 | from fastapi_sso.sso.gitlab import GitlabSSO 11 | from fastapi_sso.sso.kakao import KakaoSSO 12 | from fastapi_sso.sso.line import LineSSO 13 | from fastapi_sso.sso.linkedin import LinkedInSSO 14 | from fastapi_sso.sso.microsoft import MicrosoftSSO 15 | from fastapi_sso.sso.naver import NaverSSO 16 | from fastapi_sso.sso.spotify import SpotifySSO 17 | from fastapi_sso.sso.twitter import TwitterSSO 18 | from fastapi_sso.sso.yandex import YandexSSO 19 | 20 | sso_test_cases: tuple[tuple[type[SSOBase], dict[str, Any], OpenID], ...] = ( 21 | ( 22 | TwitterSSO, 23 | {"data": {"id": "test", "username": "TestUser1234", "name": "Test User"}}, 24 | OpenID(id="test", display_name="TestUser1234", first_name="Test", last_name="User", provider="twitter"), 25 | ), 26 | ( 27 | SpotifySSO, 28 | {"email": "test@example.com", "display_name": "testuser", "id": "test", "images": [{"url": "https://myimage"}]}, 29 | OpenID( 30 | id="test", provider="spotify", display_name="testuser", email="test@example.com", picture="https://myimage" 31 | ), 32 | ), 33 | ( 34 | NaverSSO, 35 | { 36 | "response": { 37 | "nickname": "test", 38 | "profile_image": "https://myimage", 39 | "id": "test", 40 | "email": "test@example.com", 41 | } 42 | }, 43 | OpenID(id="test", email="test@example.com", display_name="test", provider="naver", picture="https://myimage"), 44 | ), 45 | ( 46 | MicrosoftSSO, 47 | {"mail": "test@example.com", "displayName": "Test User", "id": "test", "givenName": "Test", "surname": "User"}, 48 | OpenID( 49 | email="test@example.com", 50 | display_name="Test User", 51 | id="test", 52 | provider="microsoft", 53 | first_name="Test", 54 | last_name="User", 55 | ), 56 | ), 57 | ( 58 | LinkedInSSO, 59 | { 60 | "email": "test@example.com", 61 | "sub": "test", 62 | "given_name": "Test", 63 | "family_name": "User", 64 | "picture": "https://myimage", 65 | }, 66 | OpenID( 67 | email="test@example.com", 68 | id="test", 69 | first_name="Test", 70 | last_name="User", 71 | provider="linkedin", 72 | picture="https://myimage", 73 | ), 74 | ), 75 | ( 76 | LineSSO, 77 | {"email": "test@example.com", "name": "Test User", "sub": "test", "picture": "https://myimage"}, 78 | OpenID( 79 | email="test@example.com", display_name="Test User", id="test", picture="https://myimage", provider="line" 80 | ), 81 | ), 82 | (KakaoSSO, {"properties": {"nickname": "Test User"}}, OpenID(provider="kakao", display_name="Test User")), 83 | ( 84 | # Gitlab Case 1: full name is empty 85 | GitlabSSO, 86 | {"email": "test@example.com", "id": "test", "username": "test_user", "avatar_url": "https://myimage"}, 87 | OpenID( 88 | email="test@example.com", id="test", display_name="test_user", picture="https://myimage", provider="gitlab" 89 | ), 90 | ), 91 | ( 92 | # Gitlab Case 2: full name contains only first name 93 | GitlabSSO, 94 | { 95 | "email": "test@example.com", 96 | "id": "test", 97 | "username": "test_user", 98 | "avatar_url": "https://myimage", 99 | "name": "Test", 100 | }, 101 | OpenID( 102 | email="test@example.com", 103 | id="test", 104 | display_name="test_user", 105 | picture="https://myimage", 106 | first_name="Test", 107 | last_name=None, 108 | provider="gitlab", 109 | ), 110 | ), 111 | ( 112 | # Gitlab Case 3: full name contains long last name 113 | GitlabSSO, 114 | { 115 | "email": "test@example.com", 116 | "id": "test", 117 | "username": "test_user", 118 | "avatar_url": "https://myimage", 119 | "name": "Test User Long Last Name", 120 | }, 121 | OpenID( 122 | email="test@example.com", 123 | id="test", 124 | display_name="test_user", 125 | picture="https://myimage", 126 | first_name="Test", 127 | last_name="User Long Last Name", 128 | provider="gitlab", 129 | ), 130 | ), 131 | ( 132 | # Gitlab Case 4: full name contains standard first and last names 133 | GitlabSSO, 134 | { 135 | "email": "test@example.com", 136 | "id": "test", 137 | "username": "test_user", 138 | "avatar_url": "https://myimage", 139 | "name": "Test User", 140 | }, 141 | OpenID( 142 | email="test@example.com", 143 | id="test", 144 | display_name="test_user", 145 | picture="https://myimage", 146 | first_name="Test", 147 | last_name="User", 148 | provider="gitlab", 149 | ), 150 | ), 151 | ( 152 | # Gitlab Case 5: full name contains invalid type or data 153 | GitlabSSO, 154 | { 155 | "email": "test@example.com", 156 | "id": "test", 157 | "username": "test_user", 158 | "avatar_url": "https://myimage", 159 | "name": {"invalid": 1}, 160 | }, 161 | OpenID( 162 | email="test@example.com", 163 | id="test", 164 | display_name="test_user", 165 | picture="https://myimage", 166 | first_name=None, 167 | last_name=None, 168 | provider="gitlab", 169 | ), 170 | ), 171 | ( 172 | GithubSSO, 173 | {"email": "test@example.com", "id": "test", "login": "testuser", "avatar_url": "https://myimage"}, 174 | OpenID( 175 | email="test@example.com", id="test", display_name="testuser", picture="https://myimage", provider="github" 176 | ), 177 | ), 178 | ( 179 | FitbitSSO, 180 | {"user": {"encodedId": "test", "fullName": "Test", "displayName": "Test User", "avatar": "https://myimage"}}, 181 | OpenID(id="test", first_name="Test", display_name="Test User", provider="fitbit", picture="https://myimage"), 182 | ), 183 | ( 184 | FacebookSSO, 185 | { 186 | "email": "test@example.com", 187 | "first_name": "Test", 188 | "last_name": "User", 189 | "name": "Test User", 190 | "id": "test", 191 | "picture": {"data": {"url": "https://myimage"}}, 192 | }, 193 | OpenID( 194 | email="test@example.com", 195 | first_name="Test", 196 | last_name="User", 197 | display_name="Test User", 198 | id="test", 199 | provider="facebook", 200 | picture="https://myimage", 201 | ), 202 | ), 203 | ( 204 | YandexSSO, 205 | { 206 | "id": "test", 207 | "display_name": "test", 208 | "first_name": "Test", 209 | "last_name": "User", 210 | "default_email": "test@example.com", 211 | "default_avatar_id": "123456", 212 | }, 213 | OpenID( 214 | email="test@example.com", 215 | first_name="Test", 216 | last_name="User", 217 | display_name="test", 218 | id="test", 219 | provider="yandex", 220 | picture="https://avatars.yandex.net/get-yapic/123456/islands-200", 221 | ), 222 | ), 223 | ( 224 | DiscordSSO, 225 | { 226 | "id": "test", 227 | "avatar": "avatar", 228 | "email": "test@example.com", 229 | "global_name": "Test User", 230 | "username": "testuser", 231 | }, 232 | OpenID( 233 | email="test@example.com", 234 | first_name="testuser", 235 | id="test", 236 | picture="https://cdn.discordapp.com/avatars/test/avatar.png", 237 | provider="discord", 238 | display_name="Test User", 239 | ), 240 | ), 241 | ) 242 | 243 | 244 | @pytest.mark.parametrize(("ProviderClass", "response", "openid"), sso_test_cases) 245 | async def test_provider_openid_by_response( 246 | ProviderClass: type[SSOBase], response: dict[str, Any], openid: OpenID 247 | ) -> None: 248 | sso = ProviderClass("client_id", "client_secret") 249 | async with sso: 250 | assert await sso.openid_from_response(response) == openid 251 | -------------------------------------------------------------------------------- /tests/test_pkce.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fastapi_sso.pkce import get_code_verifier 4 | 5 | 6 | @pytest.mark.parametrize(("requested", "expected"), [(100, 100), (20, 43), (200, 128)]) 7 | def test_pkce_selected_length(requested: int, expected: int) -> None: 8 | assert expected == len(get_code_verifier(requested)) 9 | -------------------------------------------------------------------------------- /tests/test_providers.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | 3 | from urllib.parse import quote_plus 4 | 5 | import jwt 6 | import pytest 7 | from fastapi.responses import RedirectResponse 8 | from utils import AnythingDict, Request, Response, make_fake_async_client 9 | 10 | from fastapi_sso.sso.base import OpenID, SecurityWarning, SSOBase, SSOLoginError 11 | from fastapi_sso.sso.bitbucket import BitbucketSSO 12 | from fastapi_sso.sso.discord import DiscordSSO 13 | from fastapi_sso.sso.facebook import FacebookSSO 14 | from fastapi_sso.sso.fitbit import FitbitSSO 15 | from fastapi_sso.sso.generic import create_provider 16 | from fastapi_sso.sso.github import GithubSSO 17 | from fastapi_sso.sso.gitlab import GitlabSSO 18 | from fastapi_sso.sso.google import GoogleSSO 19 | from fastapi_sso.sso.kakao import KakaoSSO 20 | from fastapi_sso.sso.line import LineSSO 21 | from fastapi_sso.sso.linkedin import LinkedInSSO 22 | from fastapi_sso.sso.microsoft import MicrosoftSSO 23 | from fastapi_sso.sso.naver import NaverSSO 24 | from fastapi_sso.sso.notion import NotionSSO 25 | from fastapi_sso.sso.seznam import SeznamSSO 26 | from fastapi_sso.sso.spotify import SpotifySSO 27 | from fastapi_sso.sso.twitter import TwitterSSO 28 | from fastapi_sso.sso.yandex import YandexSSO 29 | 30 | fake_id_token = jwt.encode({"email": "user@idtoken.com"}, key="test", algorithm="HS256") 31 | 32 | GenericProvider = create_provider( 33 | name="generic", 34 | discovery_document={ 35 | "authorization_endpoint": "https://example.com/auth", 36 | "token_endpoint": "https://example.com/token", 37 | "userinfo_endpoint": "https://example.com/userinfo", 38 | }, 39 | response_convertor=lambda _, __: OpenID(id="test", email="test@example.com", display_name="Test"), 40 | ) 41 | 42 | tested_providers = ( 43 | FacebookSSO, 44 | FitbitSSO, 45 | GithubSSO, 46 | GitlabSSO, 47 | GoogleSSO, 48 | KakaoSSO, 49 | LineSSO, 50 | MicrosoftSSO, 51 | NaverSSO, 52 | SpotifySSO, 53 | GenericProvider, 54 | NotionSSO, 55 | LinkedInSSO, 56 | TwitterSSO, 57 | YandexSSO, 58 | SeznamSSO, 59 | BitbucketSSO, 60 | DiscordSSO, 61 | ) 62 | 63 | # Run all tests for each of the listed providers 64 | pytestmark = pytest.mark.parametrize("Provider", tested_providers) 65 | 66 | 67 | @pytest.fixture(autouse=True) 68 | def mock_google_dicscovery_document(monkeypatch: pytest.MonkeyPatch): 69 | """GoogleSSO has a discovery document dependent on Google API""" 70 | 71 | async def _fake_get_discovery_document(_): 72 | return await GenericProvider("test", "test").get_discovery_document() 73 | 74 | monkeypatch.setattr(GoogleSSO, "get_discovery_document", _fake_get_discovery_document) 75 | 76 | 77 | class TestProviders: 78 | @pytest.mark.parametrize("item", ("authorization_endpoint", "token_endpoint", "userinfo_endpoint")) 79 | async def test_discovery_document(self, Provider: type[SSOBase], item: str): 80 | sso = Provider("client_id", "client_secret") 81 | async with sso: 82 | document = await sso.get_discovery_document() 83 | assert item in document, f"Discovery document for provider {sso.provider} must have {item}" 84 | assert ( 85 | await getattr(sso, item) == document[item] 86 | ), f"Discovery document for provider {sso.provider} must have {item}" 87 | 88 | async def test_login_url_request_time(self, Provider: type[SSOBase]): 89 | sso = Provider("client_id", "client_secret") 90 | async with sso: 91 | url = await sso.get_login_url(redirect_uri="http://localhost") 92 | assert url.startswith( 93 | await sso.authorization_endpoint 94 | ), f"Login URL must start with {await sso.authorization_endpoint}" 95 | assert "redirect_uri=http%3A%2F%2Flocalhost" in url, "Login URL must have redirect_uri query parameter" 96 | 97 | with pytest.raises(ValueError): 98 | await sso.get_login_url() 99 | 100 | async def test_login_url_construction_time(self, Provider: type[SSOBase]): 101 | sso = Provider("client_id", "client_secret", redirect_uri="http://localhost") 102 | 103 | async with sso: 104 | url = await sso.get_login_url() 105 | assert url.startswith( 106 | await sso.authorization_endpoint 107 | ), f"Login URL must start with {await sso.authorization_endpoint}" 108 | assert "redirect_uri=http%3A%2F%2Flocalhost" in url, "Login URL must have redirect_uri query parameter" 109 | 110 | async def assert_get_login_url_and_redirect(self, sso: SSOBase, **kwargs): 111 | async with sso: 112 | url = await sso.get_login_url(**kwargs) 113 | redirect = await sso.get_login_redirect(**kwargs) 114 | assert isinstance(url, str), "Login URL must be a string" 115 | assert isinstance(redirect, RedirectResponse), "Login redirect must be a RedirectResponse" 116 | assert redirect.headers["location"] == url, "Login redirect must have the same URL as login URL" 117 | return url, redirect 118 | 119 | async def test_login_url_additional_params(self, Provider: type[SSOBase]): 120 | sso = Provider("client_id", "client_secret", redirect_uri="http://localhost") 121 | 122 | url, _ = await self.assert_get_login_url_and_redirect(sso, params={"access_type": "offline", "param": "value"}) 123 | assert "access_type=offline" in url, "Login URL must have additional query parameters" 124 | assert "param=value" in url, "Login URL must have additional query parameters" 125 | 126 | async def test_login_url_state_at_request_time(self, Provider: type[SSOBase]): 127 | sso = Provider("client_id", "client_secret") 128 | url, _ = await self.assert_get_login_url_and_redirect(sso, redirect_uri="http://localhost", state="unique") 129 | assert "state=unique" in url, "Login URL must have state query parameter" 130 | 131 | async def test_login_url_scope_default(self, Provider: type[SSOBase]): 132 | sso = Provider("client_id", "client_secret") 133 | url, _ = await self.assert_get_login_url_and_redirect(sso, redirect_uri="http://localhost") 134 | assert quote_plus(" ".join(sso._scope)) in url, "Login URL must have all scopes" 135 | 136 | async def test_login_url_scope_additional(self, Provider: type[SSOBase]): 137 | sso = Provider("client_id", "client_secret", scope=["openid", "additional"]) 138 | url, _ = await self.assert_get_login_url_and_redirect(sso, redirect_uri="http://localhost") 139 | assert quote_plus(" ".join(sso._scope)) in url, "Login URL must have all scopes" 140 | 141 | async def test_process_login(self, Provider: type[SSOBase], monkeypatch: pytest.MonkeyPatch): 142 | sso = Provider("client_id", "client_secret") 143 | get_response = Response( 144 | url="https://localhost", 145 | json_content=AnythingDict( 146 | {"token_endpoint": "https://localhost", "userinfo_endpoint": "https://localhost"} 147 | ), 148 | ) 149 | 150 | FakeAsyncClient = make_fake_async_client( 151 | returns_post=Response(url="https://localhost", json_content={"access_token": "token"}), 152 | returns_get=get_response, 153 | ) 154 | 155 | async def fake_openid_from_response(_, __): 156 | return OpenID(id="test", email="email@example.com", display_name="Test") 157 | 158 | async def fake_openid_from_id_token(_, __): 159 | return OpenID(id="idtoken", email="user@idtoken.com", display_name="ID Token") 160 | 161 | async with sso: 162 | monkeypatch.setattr("httpx.AsyncClient", FakeAsyncClient) 163 | monkeypatch.setattr(sso, "openid_from_response", fake_openid_from_response) 164 | monkeypatch.setattr(sso, "openid_from_token", fake_openid_from_id_token) 165 | request = Request(url="https://localhost?code=code&state=unique") 166 | if sso.use_id_token_for_user_info: 167 | with pytest.raises(SSOLoginError, match="Provider .* did not return id token"): 168 | await sso.process_login("code", request) 169 | else: 170 | await sso.process_login("code", request) 171 | 172 | if sso.use_id_token_for_user_info: 173 | monkeypatch.setattr("jwt.decode", lambda _, options: {}) 174 | FakeAsyncClient = make_fake_async_client( 175 | returns_post=Response( 176 | url="https://localhost", json_content={"access_token": "token", "id_token": "fake id token"} 177 | ), 178 | returns_get=get_response, 179 | ) 180 | monkeypatch.setattr("httpx.AsyncClient", FakeAsyncClient) 181 | await sso.process_login("code", request) 182 | 183 | async def test_context_manager_behavior(self, Provider: type[SSOBase]): 184 | sso = Provider("client_id", "client_secret") 185 | assert sso._oauth_client is None, "OAuth client must be after initialization" 186 | assert sso._refresh_token is None, "Refresh token must be None after initialization" 187 | with pytest.warns(SecurityWarning, match="Please make sure you are using SSO provider in an async context"): 188 | sso.oauth_client 189 | sso._refresh_token = "test" 190 | assert sso._oauth_client is not None, "OAuth client must be initialized after first access" 191 | async with sso: 192 | assert sso._oauth_client is None, "OAuth client must be None within the context manager" 193 | assert sso._refresh_token is None, "Refresh token must be None within the context manager" 194 | -------------------------------------------------------------------------------- /tests/test_providers_individual.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | import pytest 4 | 5 | from fastapi_sso import BitbucketSSO, NotionSSO, OpenID, SSOLoginError 6 | 7 | 8 | async def test_notion_openid_response(): 9 | sso = NotionSSO("client_id", "client_secret") 10 | valid_response = { 11 | "bot": { 12 | "owner": { 13 | "type": "user", 14 | "user": { 15 | "id": "test", 16 | "person": {"email": "test@example.com"}, 17 | "avatar_url": "avatar", 18 | "name": "Test User", 19 | }, 20 | } 21 | } 22 | } 23 | invalid_response = {"bot": {"owner": {"type": "workspace", "workspace": {}}}} 24 | with pytest.raises(SSOLoginError): 25 | await sso.openid_from_response(invalid_response) 26 | openid = OpenID(id="test", email="test@example.com", display_name="Test User", picture="avatar", provider="notion") 27 | assert await sso.openid_from_response(valid_response) == openid 28 | 29 | 30 | async def test_bitbucket_openid_response(): 31 | sso = BitbucketSSO("client_id", "client_secret") 32 | valid_response = { 33 | "uuid": "00000000-0000-0000-0000-000000000000", 34 | "nickname": "testuser", 35 | "links": {"avatar": {"href": "https://example.com/myavatar.png"}}, 36 | "display_name": "Test User", 37 | } 38 | 39 | class FakeSesssion: 40 | async def get(self, url: str) -> MagicMock: 41 | response = MagicMock() 42 | response.json.return_value = {"values": [{"email": "test@example.com"}]} 43 | return response 44 | 45 | openid = OpenID( 46 | id=valid_response["uuid"], 47 | display_name=valid_response["display_name"], 48 | provider="bitbucket", 49 | email="test@example.com", 50 | first_name="testuser", 51 | picture=valid_response["links"]["avatar"]["href"], 52 | ) 53 | 54 | with pytest.raises(ValueError, match="Session is required to make HTTP requests"): 55 | await sso.openid_from_response(valid_response) 56 | 57 | assert openid == await sso.openid_from_response(valid_response, FakeSesssion()) 58 | -------------------------------------------------------------------------------- /tests/test_race_condition.py: -------------------------------------------------------------------------------- 1 | # See [!186](https://github.com/tomasvotava/fastapi-sso/issues/186) 2 | # Author: @parikls 3 | 4 | import asyncio 5 | from unittest.mock import Mock, patch 6 | 7 | from starlette.datastructures import URL 8 | 9 | from fastapi_sso import create_provider 10 | 11 | 12 | async def test__race_condition(): 13 | discovery = { 14 | "authorization_endpoint": "https://authorization_endpoint", 15 | "token_endpoint": "https://token_endpoint", 16 | "userinfo_endpoint": "https://userinfo_endpoint", 17 | } 18 | factory = create_provider(name="provider", discovery_document=discovery) 19 | 20 | provider = factory(client_id="client_id", client_secret="client_secret") # noqa: S106 21 | 22 | # mock for the response. will return a token which we've set 23 | class Response: 24 | def __init__(self, token: str): 25 | self.token = token 26 | 27 | def json(self): 28 | return { 29 | "refresh_token": self.token, 30 | "access_token": self.token, 31 | "id_token": self.token, 32 | } 33 | 34 | # mock of the httpx client 35 | class AsyncClient: 36 | post_responses = [] # list of the responses which a client will return for the `POST` requests 37 | 38 | def __init__(self): 39 | self.headers = {} 40 | 41 | async def __aenter__(self): 42 | return self 43 | 44 | async def __aexit__(self, exc_type, exc_val, exc_tb): 45 | pass 46 | 47 | async def post(self, *args, **kwargs): 48 | await asyncio.sleep(0) # simulate a loop switch cos it'll happen during a real HTTP request 49 | return self.post_responses.pop() 50 | 51 | # we actually don't care what GET will return for this particular test, 52 | # but this method is required to fully run the `process_login` code 53 | async def get(self, *args, **kwargs): 54 | await asyncio.sleep(0) 55 | return Response(token="") 56 | 57 | with patch("fastapi_sso.sso.base.httpx") as httpx: 58 | httpx.AsyncClient = AsyncClient 59 | 60 | first_response = Response(token="first_token") # noqa: S106 61 | second_response = Response(token="second_token") # noqa: S106 62 | 63 | AsyncClient.post_responses = [second_response, first_response] # reversed order because of `pop` 64 | 65 | async def process_login(): 66 | # this coro will be executed concurrently. 67 | # completely not caring about the params 68 | request = Mock() 69 | request.url = URL("https://url.com?state=state&code=code") 70 | async with provider: 71 | await provider.process_login( 72 | code="code", request=request, params=dict(state="state"), convert_response=False 73 | ) 74 | return provider.access_token 75 | 76 | # process login concurrently twice 77 | tasks = [process_login(), process_login()] 78 | results = await asyncio.gather(*tasks) 79 | 80 | # we would want to get the first and second tokens, 81 | # but we see that the first request actually obtained the second token as well 82 | assert results == [first_response.token, second_response.token] 83 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from starlette.datastructures import URL 2 | 3 | 4 | class Request: 5 | def __init__(self, url="http://localhost", query_params=None): 6 | self.url = URL(url) 7 | self.query_params = query_params or {} 8 | self.headers = {} 9 | 10 | 11 | class Response: 12 | def __init__(self, url="http://localhost", json_content=None): 13 | self.url = URL(url) 14 | self.json_content = json_content or {} 15 | 16 | def json(self): 17 | return self.json_content 18 | 19 | 20 | class AnythingDict: 21 | def __init__(self, data=None): 22 | self.data = data or {} 23 | 24 | def __getitem__(self, key): 25 | return self.data.get(key, "test") 26 | 27 | def __contains__(self, key): 28 | return True 29 | 30 | def __repr__(self): 31 | return "" 32 | 33 | def get(self, key, default=None): 34 | return self.data.get(key, default) or "test" 35 | 36 | 37 | def make_fake_async_client(returns_post: Response, returns_get: Response): 38 | class FakeAsyncClient: 39 | headers = {} 40 | 41 | async def __aenter__(self): 42 | return self 43 | 44 | async def __aexit__(self, *args): 45 | self.headers = {} 46 | return None 47 | 48 | async def get(self, *args, **kwargs) -> Response: 49 | return returns_get 50 | 51 | async def post(self, *args, **kwargs) -> Response: 52 | return returns_post 53 | 54 | return FakeAsyncClient 55 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = true 3 | envlist = py39,py310,py311,py312,py313 4 | skip_missing_interpreters = false 5 | 6 | [testenv] 7 | skip_install = true 8 | allowlist_externals = poetry 9 | 10 | commands_pre = 11 | poetry export --without-hashes --format requirements.txt --output requirements.txt --with dev 12 | pip install -qqq -r requirements.txt 13 | 14 | commands = 15 | python -m ruff check fastapi_sso 16 | python -m black --check fastapi_sso 17 | python -m mypy fastapi_sso 18 | python -m pytest 19 | python -m coverage 20 | --------------------------------------------------------------------------------