├── .env.example ├── .github ├── ISSUE_TEMPLATE │ └── feature_request.md └── dependabot.yml ├── .gitignore ├── .vercelignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── authentication.py ├── database.py ├── database_crud ├── __init__.py └── users_db_crud.py ├── db_models.py ├── img.png ├── main.py ├── requirements.txt ├── routers ├── __init__.py ├── auth.py ├── facebook_sso.py ├── github_sso.py ├── gitlab_sso.py ├── google_sso.py ├── linkedin_sso.py ├── microsoft_sso.py ├── spotify_sso.py └── xtwitter_sso.py ├── schemas.py ├── static ├── css │ └── main.css └── images │ └── favicon.png ├── templates ├── index.html ├── login.html └── privacy_policy.html └── vercel.json /.env.example: -------------------------------------------------------------------------------- 1 | HOST= 2 | CONTACT_EMAIL= 3 | OAUTHLIB_INSECURE_TRANSPORT=1 4 | DATABASE_URL= 5 | SECRET_KEY= 6 | SESSION_COOKIE_NAME= 7 | SPOTIFY_CLIENT_ID= 8 | SPOTIFY_CLIENT_SECRET= 9 | MICROSOFT_CLIENT_ID= 10 | MICROSOFT_CLIENT_SECRET= 11 | GOOGLE_CLIENT_ID= 12 | GOOGLE_CLIENT_SECRET= 13 | GITHUB_CLIENT_ID= 14 | GITHUB_CLIENT_SECRET= 15 | FACEBOOK_CLIENT_ID= 16 | FACEBOOK_CLIENT_SECRET= 17 | GITLAB_CLIENT_ID= 18 | GITLAB_CLIENT_SECRET= 19 | XTWITTER_CLIENT_ID= 20 | XTWITTER_CLIENT_SECRET= 21 | LINKEDIN_CLIENT_ID= 22 | LINKEDIN_CLIENT_SECRET= -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | 2 | version: 2 3 | updates: 4 | - package-ecosystem: "pip" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | assignees: 9 | - "chrisK824" 10 | reviewers: 11 | - "chrisK824" 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .env.vercel 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | .idea/ 162 | 163 | # Visual Studio Code 164 | .vscode/ 165 | 166 | # local sqlite db 167 | *.db -------------------------------------------------------------------------------- /.vercelignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ 161 | 162 | # Visual Studio Code 163 | .vscode/ 164 | 165 | # local sqlite db 166 | *.db -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | * Ask your question in discussions or raise an issue 2 | * Contribute by forking/cloning and creating a pull request 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Chris Karvouniaris 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-example 2 | FastAPI Single Sign On example with various providers and minimal home page that presents counters for users from each provider 3 | 4 | ## Cloud deployed demo 5 | https://fastapi-sso-example.vercel.app/ 6 | 7 | ## Installation 8 | 9 | * Create a python virtual environment: `python3.11 -m venv venv` 10 | * Activate the virtual environment: `source venv/bin/activate` 11 | * Install dependencies: `python3.11 -m pip install -r requirements.txt` 12 | 13 | ## Setup and Configuration 14 | 15 | * Create a `.env` file in the project root directory with the keys just like shown in `.env.example` 16 | * Fill the values for each environment variable 17 | * For creating client IDs and secrets for each provider you can follow these guides: 18 | - Google: https://medium.com/itnext/fastapi-google-single-sign-on-sso-47454e2e2859 19 | - Facebook: https://medium.com/@christos.karvouniaris247/fastapi-facebook-single-sign-on-sso-b10865535029 20 | - X (Twitter): https://itnext.io/fastapi-x-twitter-single-sign-on-sso-5725671ad180 21 | - LinkedIn: https://itnext.io/fastapi-linkedin-single-sign-on-sso-b1a0118b02d7 22 | - GitHub: https://medium.com/@christos.karvouniaris247/fastapi-github-single-sign-on-sso-f6b942395649 23 | - GitLab: https://levelup.gitconnected.com/fastapi-gitlab-single-sign-on-sso-624fe442ec56 24 | - Microsoft: https://medium.com/@christos.karvouniaris247/fastapi-microsoft-single-sign-on-sso-d0a0ab248c36 25 | - Spotify: https://medium.com/@christos.karvouniaris247/fastapi-spotify-single-sign-on-sso-4f461b95344c 26 | 27 | ## Use the project 28 | * While in activated virtual environment, run with: `python3.11 main.py` 29 | 30 | ## To sign up a local user 31 | Use the swagger documentation at `localhost:9999/v1/documentation`, example: 32 | *  33 | 34 | ## Browse in the app 35 | * Visit the user interface in the browser at: `localhost:9999` 36 | * Use form to sign-in with local users 37 | * Use sign-in buttons to sign-in via a provider -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | * Python 3.9+ 6 | -------------------------------------------------------------------------------- /authentication.py: -------------------------------------------------------------------------------- 1 | from passlib.context import CryptContext 2 | from jose import JWTError, jwt 3 | from jose.constants import ALGORITHMS 4 | from fastapi.security import OAuth2PasswordBearer, APIKeyCookie 5 | from fastapi import Depends, HTTPException, status 6 | from sqlalchemy.orm import Session 7 | from db_models import User 8 | from database import get_db 9 | from pathlib import Path 10 | from dotenv import load_dotenv 11 | import os 12 | 13 | directory_path = Path(__file__).parent 14 | env_file_path = directory_path / '.env' 15 | 16 | load_dotenv() 17 | SECRET_KEY = os.getenv("SECRET_KEY", "default_secret_key") 18 | SESSION_COOKIE_NAME = os.getenv("SESSION_COOKIE_NAME", "default_session_cookie_name") 19 | 20 | 21 | COOKIE = APIKeyCookie(name=SESSION_COOKIE_NAME, auto_error=False) 22 | 23 | class BearAuthException(Exception): 24 | pass 25 | 26 | 27 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 28 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") 29 | 30 | 31 | def verify_password(plain_password, hashed_password): 32 | return pwd_context.verify(plain_password, hashed_password) 33 | 34 | 35 | def get_password_hash(password): 36 | return pwd_context.hash(password) 37 | 38 | 39 | def create_access_token(username: str, provider: str): 40 | to_encode = { 41 | "username": username, 42 | "provider": provider 43 | } 44 | encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHMS.HS256) 45 | return encoded_jwt 46 | 47 | 48 | def get_token_payload(session_token: str): 49 | try: 50 | payload = jwt.decode(session_token, SECRET_KEY, algorithms=[ALGORITHMS.HS256]) 51 | username: str = payload.get("username") 52 | provider: str = payload.get("provider") 53 | if username is None or provider is None: 54 | raise BearAuthException("Token could not be validated") 55 | return { 56 | "username": username, 57 | "provider": provider 58 | } 59 | except JWTError: 60 | raise BearAuthException("Token could not be validated") 61 | 62 | 63 | def authenticate_user(db: Session, username: str, password: str, provider: str): 64 | user = db.query(User).filter(User.username == username).filter(User.provider == provider).first() 65 | if not user: 66 | return False 67 | if not verify_password(password, user.password): 68 | return False 69 | return user 70 | 71 | 72 | def get_current_user(db: Session = Depends(get_db), session_token: str = Depends(COOKIE)): 73 | try: 74 | if not session_token: 75 | return None 76 | userdata = get_token_payload(session_token) 77 | username = userdata.get('username') 78 | provider = userdata.get('provider') 79 | except BearAuthException: 80 | raise HTTPException( 81 | status_code=status.HTTP_401_UNAUTHORIZED, 82 | detail="Could not validate bearer token", 83 | headers={"WWW-Authenticate": "Bearer"} 84 | ) 85 | user = db.query(User).filter(User.username == username).filter(User.provider == provider).first() 86 | if not user: 87 | return None 88 | return user 89 | -------------------------------------------------------------------------------- /database.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine 2 | from sqlalchemy.orm import sessionmaker, declarative_base 3 | from dotenv import load_dotenv 4 | import os 5 | 6 | load_dotenv() 7 | 8 | if os.environ.get('VERCEL'): 9 | DATABASE_URL = os.environ.get('DATABASE_URL') 10 | connect_args = {} 11 | else: 12 | DATABASE_URL = os.environ.get('DATABASE_URL') 13 | connect_args = {"check_same_thread": False} 14 | 15 | engine = create_engine(DATABASE_URL, connect_args=connect_args) 16 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 17 | 18 | Base = declarative_base() 19 | 20 | 21 | def get_db(): 22 | db = SessionLocal() 23 | try: 24 | yield db 25 | finally: 26 | db.close() 27 | -------------------------------------------------------------------------------- /database_crud/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisK824/fastapi-sso-example/d27a547cc8c7c047bcb6674969e61caa885e16f6/database_crud/__init__.py -------------------------------------------------------------------------------- /database_crud/users_db_crud.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | from db_models import User 3 | import schemas as schemas 4 | from sqlalchemy.exc import IntegrityError 5 | from authentication import get_password_hash 6 | from sqlalchemy import func, desc 7 | 8 | 9 | class DuplicateError(Exception): 10 | pass 11 | 12 | 13 | def add_user(db: Session, user: schemas.UserSignUp, provider: str = None): 14 | if not provider and not user.password: 15 | raise ValueError("A password should be provided for non SSO registers") 16 | elif provider and user.password: 17 | raise ValueError("A password should not be provided for SSO registers") 18 | 19 | if user.password: 20 | password = get_password_hash(user.password) 21 | else: 22 | password = None 23 | 24 | user = User( 25 | username=user.username, 26 | password=password, 27 | fullname=user.fullname, 28 | provider=provider 29 | ) 30 | try: 31 | db.add(user) 32 | db.commit() 33 | except IntegrityError: 34 | db.rollback() 35 | raise DuplicateError( 36 | f"Username {user.username} is already attached to a " 37 | "registered user for the provider '{provider}'." 38 | ) 39 | return user 40 | 41 | 42 | def get_user(db: Session, username: str, provider: str): 43 | user = db.query(User).filter(User.username == username).filter( 44 | User.provider == provider).first() 45 | return user 46 | 47 | 48 | def get_users_stats(db: Session): 49 | records = db.query(User).with_entities( 50 | User.provider.label("provider"), 51 | func.count(User.provider).label("count") 52 | ).group_by(User.provider).order_by(desc("count")).all() 53 | 54 | users_stats = [ 55 | schemas.UserStat( 56 | provider=record[0], 57 | count=record[1] 58 | ) 59 | for record in records 60 | ] 61 | return users_stats 62 | -------------------------------------------------------------------------------- /db_models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, DateTime, Integer, String, UniqueConstraint 2 | from sqlalchemy.sql import func 3 | from database import Base 4 | 5 | 6 | class User(Base): 7 | __tablename__ = "users" 8 | id = Column(Integer, primary_key=True, index=True) 9 | username = Column(String) 10 | password = Column(String, nullable=True) 11 | provider = Column(String, default="local", nullable=True) 12 | fullname = Column(String, nullable=True) 13 | register_date = Column(DateTime, default=func.now()) 14 | __table_args__ = (UniqueConstraint('username', 'provider', name='unique_username_per_provider'),) 15 | 16 | @property 17 | def as_dict(self): 18 | return {c.name: getattr(self, c.name) for c in self.__table__.columns} 19 | -------------------------------------------------------------------------------- /img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisK824/fastapi-sso-example/d27a547cc8c7c047bcb6674969e61caa885e16f6/img.png -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from importlib.metadata import version 3 | from fastapi import FastAPI, Request, Depends, HTTPException 4 | from contextlib import asynccontextmanager 5 | from db_models import Base 6 | from database import engine 7 | from database_crud import users_db_crud as db_crud 8 | from sqlalchemy.orm import Session 9 | from routers import ( 10 | auth, 11 | google_sso, 12 | spotify_sso, 13 | github_sso, 14 | gitlab_sso, 15 | facebook_sso, 16 | xtwitter_sso, 17 | linkedin_sso, 18 | microsoft_sso 19 | ) 20 | from starlette.middleware.cors import CORSMiddleware 21 | from starlette.middleware.sessions import SessionMiddleware 22 | from schemas import User 23 | from authentication import get_current_user 24 | from fastapi.responses import HTMLResponse 25 | from fastapi.templating import Jinja2Templates 26 | from fastapi.staticfiles import StaticFiles 27 | from pathlib import Path 28 | from database import get_db 29 | import os 30 | from dotenv import load_dotenv 31 | 32 | 33 | load_dotenv() 34 | parent_directory = Path(__file__).parent 35 | templates_path = parent_directory / "templates" 36 | templates = Jinja2Templates(directory=templates_path) 37 | 38 | description = """ 39 | Example API to demonstrate SSO login in fastAPI 40 | """ 41 | 42 | 43 | @asynccontextmanager 44 | async def lifespan(app: FastAPI): 45 | Base.metadata.create_all(bind=engine) 46 | yield 47 | 48 | 49 | app = FastAPI( 50 | title='SSO login example API', 51 | description=description, 52 | version="1.0.0", 53 | docs_url="/v1/documentation", 54 | redoc_url="/v1/redocs", 55 | lifespan=lifespan 56 | ) 57 | 58 | app.add_middleware(SessionMiddleware, secret_key="!secret") 59 | app.add_middleware( 60 | CORSMiddleware, 61 | allow_origins=['*'], 62 | allow_methods=['*'], 63 | allow_headers=["*"] 64 | ) 65 | 66 | app.mount("/static", StaticFiles(directory="static"), name="static") 67 | app.include_router(auth.router) 68 | app.include_router(google_sso.router) 69 | app.include_router(spotify_sso.router) 70 | app.include_router(github_sso.router) 71 | app.include_router(gitlab_sso.router) 72 | app.include_router(facebook_sso.router) 73 | app.include_router(xtwitter_sso.router) 74 | app.include_router(linkedin_sso.router) 75 | app.include_router(microsoft_sso.router) 76 | 77 | 78 | @app.get("/", response_class=HTMLResponse, summary="Home page") 79 | def home_page(request: Request, db: Session = Depends(get_db), user: User = Depends(get_current_user)): 80 | """ 81 | Returns all users. 82 | """ 83 | versions = { 84 | "fastapi_version": version('fastapi'), 85 | "fastapi_sso_version": version('fastapi_sso') 86 | } 87 | try: 88 | if user is not None: 89 | users_stats = db_crud.get_users_stats(db) 90 | response = templates.TemplateResponse("index.html", {"request": request, "user": user, "users_stats": users_stats, **versions}) 91 | else: 92 | response = templates.TemplateResponse("login.html", {"request": request, **versions}) 93 | return response 94 | except Exception as e: 95 | raise HTTPException( 96 | status_code=500, detail=f"An unexpected error occurred. Report this message to support: {e}") 97 | 98 | 99 | @app.get("/privacy_policy", response_class=HTMLResponse, summary="Privacy Policy") 100 | def privacy_policy(request: Request): 101 | """ 102 | Returns privacy policy page. 103 | """ 104 | try: 105 | response = templates.TemplateResponse( 106 | "privacy_policy.html", 107 | { 108 | "request": request, 109 | "host": os.getenv('HOST'), 110 | "contact_email": os.getenv('CONTACT_EMAIL') 111 | } 112 | ) 113 | return response 114 | except Exception as e: 115 | raise HTTPException( 116 | status_code=500, detail=f"An unexpected error occurred. Report this message to support: {e}") 117 | 118 | 119 | if __name__ == '__main__': 120 | uvicorn.run(app, host="0.0.0.0", port=9999) 121 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.115.12 2 | pydantic==2.11.5 3 | uvicorn==0.34.3 4 | sqlalchemy==2.0.41 5 | passlib==1.7.4 6 | python-jose==3.5.0 7 | email-validator==2.2.0 8 | bcrypt==4.3.0 9 | python-multipart==0.0.20 10 | jinja2==3.1.6 11 | fastapi-sso==0.18.0 12 | python-dotenv==1.1.0 13 | itsdangerous==2.2.0 14 | psycopg2-binary==2.9.10 -------------------------------------------------------------------------------- /routers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisK824/fastapi-sso-example/d27a547cc8c7c047bcb6674969e61caa885e16f6/routers/__init__.py -------------------------------------------------------------------------------- /routers/auth.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends, APIRouter, HTTPException, Form, status 2 | from fastapi.responses import RedirectResponse 3 | from sqlalchemy.orm import Session 4 | from authentication import create_access_token, authenticate_user, SESSION_COOKIE_NAME 5 | from database import get_db 6 | from database_crud import users_db_crud as db_crud 7 | from schemas import User, UserSignUp 8 | 9 | 10 | router = APIRouter(prefix="/v1") 11 | 12 | 13 | @router.post("/sign_up", response_model=User, summary="Register a user", tags=["Auth"]) 14 | def create_user(user_signup: UserSignUp, db: Session = Depends(get_db)): 15 | """ 16 | Registers a user. 17 | """ 18 | try: 19 | user_created = db_crud.add_user(db, user_signup) 20 | return user_created 21 | except db_crud.DuplicateError as e: 22 | raise HTTPException(status_code=403, detail=f"{e}") 23 | except ValueError as e: 24 | raise HTTPException(status_code=400, detail=f"{e}") 25 | except Exception as e: 26 | raise HTTPException( 27 | status_code=500, detail=f"An unexpected error occurred. Report this message to support: {e}") 28 | 29 | 30 | @router.post("/login", summary="Login as a user", tags=["Auth"]) 31 | def login(response: RedirectResponse, username: str = Form(...), password: str = Form(...), db: Session = Depends(get_db)): 32 | """ 33 | Logs in a user. 34 | """ 35 | user = authenticate_user(db=db, username=username, password=password, provider='local') 36 | if not user: 37 | raise HTTPException( 38 | status_code=401, detail="Invalid username or password.") 39 | try: 40 | access_token = create_access_token(username=user.username, provider='local') 41 | response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) 42 | response.set_cookie(SESSION_COOKIE_NAME, access_token) 43 | return response 44 | except Exception as e: 45 | raise HTTPException( 46 | status_code=500, detail=f"An unexpected error occurred. Report this message to support: {e}") 47 | 48 | 49 | @router.post("/logout", summary="Logout a user", tags=["Auth"]) 50 | def logout(): 51 | """ 52 | Logout a user. 53 | """ 54 | try: 55 | response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) 56 | response.delete_cookie(SESSION_COOKIE_NAME) 57 | return response 58 | except Exception as e: 59 | raise HTTPException( 60 | status_code=500, detail=f"An unexpected error occurred. Report this message to support: {e}") 61 | -------------------------------------------------------------------------------- /routers/facebook_sso.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException, status 2 | from fastapi.responses import RedirectResponse 3 | from database_crud import users_db_crud as db_crud 4 | from schemas import UserSignUp 5 | from sqlalchemy.orm import Session 6 | from database import get_db 7 | from fastapi_sso.sso.facebook import FacebookSSO 8 | from starlette.requests import Request 9 | from authentication import create_access_token, SESSION_COOKIE_NAME 10 | from dotenv import load_dotenv 11 | import os 12 | 13 | 14 | load_dotenv() 15 | FACEBOOK_CLIENT_ID = os.getenv("FACEBOOK_CLIENT_ID") 16 | FACEBOOK_CLIENT_SECRET = os.getenv("FACEBOOK_CLIENT_SECRET") 17 | 18 | 19 | facebook_sso = FacebookSSO( 20 | FACEBOOK_CLIENT_ID, 21 | FACEBOOK_CLIENT_SECRET, 22 | f"{os.getenv('HOST')}/v1/facebook/callback" 23 | ) 24 | 25 | router = APIRouter(prefix="/v1/facebook") 26 | 27 | 28 | @router.get("/login", tags=['Facebook SSO']) 29 | async def facebook_login(): 30 | async with facebook_sso: 31 | return await facebook_sso.get_login_redirect() 32 | 33 | 34 | @router.get("/callback", tags=['Facebook SSO']) 35 | async def facebook_callback(request: Request, db: Session = Depends(get_db)): 36 | """Process login response from Facebook and return user info""" 37 | 38 | try: 39 | async with facebook_sso: 40 | user = await facebook_sso.verify_and_process(request) 41 | username = user.email 42 | user_stored = db_crud.get_user(db, username, user.provider) 43 | if not user_stored: 44 | user_to_add = UserSignUp( 45 | username=username, 46 | fullname=user.display_name 47 | ) 48 | user_stored = db_crud.add_user(db, user_to_add, provider=user.provider) 49 | access_token = create_access_token(username=user_stored.username, provider=user.provider) 50 | response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) 51 | response.set_cookie(SESSION_COOKIE_NAME, access_token) 52 | return response 53 | except db_crud.DuplicateError as e: 54 | raise HTTPException(status_code=403, detail=f"{e}") 55 | except ValueError as e: 56 | raise HTTPException(status_code=400, detail=f"{e}") 57 | except Exception as e: 58 | raise HTTPException( 59 | status_code=500, detail=f"An unexpected error occurred. Report this message to support: {e}") -------------------------------------------------------------------------------- /routers/github_sso.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException, status 2 | from fastapi.responses import RedirectResponse 3 | from database_crud import users_db_crud as db_crud 4 | from schemas import UserSignUp 5 | from sqlalchemy.orm import Session 6 | from database import get_db 7 | from fastapi_sso.sso.github import GithubSSO 8 | from starlette.requests import Request 9 | from authentication import create_access_token, SESSION_COOKIE_NAME 10 | from dotenv import load_dotenv 11 | import os 12 | 13 | 14 | load_dotenv() 15 | GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID") 16 | GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET") 17 | 18 | 19 | github_sso = GithubSSO( 20 | GITHUB_CLIENT_ID, 21 | GITHUB_CLIENT_SECRET, 22 | f"{os.getenv('HOST')}/v1/github/callback" 23 | ) 24 | 25 | router = APIRouter(prefix="/v1/github") 26 | 27 | 28 | @router.get("/login", tags=['GitHub SSO']) 29 | async def github_login(): 30 | async with github_sso: 31 | return await github_sso.get_login_redirect() 32 | 33 | 34 | @router.get("/callback", tags=['GitHub SSO']) 35 | async def github_callback(request: Request, db: Session = Depends(get_db)): 36 | """Process login response from GitHub and return user info""" 37 | 38 | try: 39 | async with github_sso: 40 | user = await github_sso.verify_and_process(request) 41 | username = user.email if user.email else user.display_name 42 | user_stored = db_crud.get_user(db, username, user.provider) 43 | if not user_stored: 44 | user_to_add = UserSignUp( 45 | username=username, 46 | fullname=user.display_name 47 | ) 48 | user_stored = db_crud.add_user( 49 | db, 50 | user_to_add, 51 | provider=user.provider 52 | ) 53 | access_token = create_access_token( 54 | username=user_stored.username, 55 | provider=user.provider 56 | ) 57 | response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) 58 | response.set_cookie(SESSION_COOKIE_NAME, access_token) 59 | return response 60 | except db_crud.DuplicateError as e: 61 | raise HTTPException(status_code=403, detail=f"{e}") 62 | except ValueError as e: 63 | raise HTTPException(status_code=400, detail=f"{e}") 64 | except Exception as e: 65 | raise HTTPException( 66 | status_code=500, 67 | detail=f"An unexpected error occurred. Report this message to support: {e}" 68 | ) 69 | -------------------------------------------------------------------------------- /routers/gitlab_sso.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException, status 2 | from fastapi.responses import RedirectResponse 3 | from database_crud import users_db_crud as db_crud 4 | from schemas import UserSignUp 5 | from sqlalchemy.orm import Session 6 | from database import get_db 7 | from fastapi_sso.sso.gitlab import GitlabSSO 8 | from starlette.requests import Request 9 | from authentication import create_access_token, SESSION_COOKIE_NAME 10 | from dotenv import load_dotenv 11 | import os 12 | 13 | 14 | load_dotenv() 15 | GITLAB_CLIENT_ID = os.getenv("GITLAB_CLIENT_ID") 16 | GITLAB_CLIENT_SECRET = os.getenv("GITLAB_CLIENT_SECRET") 17 | 18 | 19 | gitlab_sso = GitlabSSO( 20 | GITLAB_CLIENT_ID, 21 | GITLAB_CLIENT_SECRET, 22 | f"{os.getenv('HOST')}/v1/gitlab/callback" 23 | ) 24 | 25 | router = APIRouter(prefix="/v1/gitlab") 26 | 27 | 28 | @router.get("/login", tags=['GitLab SSO']) 29 | async def gitlab_login(): 30 | async with gitlab_sso: 31 | return await gitlab_sso.get_login_redirect() 32 | 33 | 34 | @router.get("/callback", tags=['GitLab SSO']) 35 | async def gitlab_callback(request: Request, db: Session = Depends(get_db)): 36 | """Process login response from GitLab and return user info""" 37 | 38 | try: 39 | async with gitlab_sso: 40 | user = await gitlab_sso.verify_and_process(request) 41 | username = user.email if user.email else user.display_name 42 | user_stored = db_crud.get_user(db, username, user.provider) 43 | if not user_stored: 44 | user_to_add = UserSignUp( 45 | username=username, 46 | fullname=user.display_name 47 | ) 48 | user_stored = db_crud.add_user( 49 | db, 50 | user_to_add, 51 | provider=user.provider 52 | ) 53 | access_token = create_access_token( 54 | username=user_stored.username, 55 | provider=user.provider 56 | ) 57 | response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) 58 | response.set_cookie(SESSION_COOKIE_NAME, access_token) 59 | return response 60 | except db_crud.DuplicateError as e: 61 | raise HTTPException(status_code=403, detail=f"{e}") 62 | except ValueError as e: 63 | raise HTTPException(status_code=400, detail=f"{e}") 64 | except Exception as e: 65 | raise HTTPException( 66 | status_code=500, 67 | detail=f"An unexpected error occurred. Report this message to support: {e}" 68 | ) 69 | -------------------------------------------------------------------------------- /routers/google_sso.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException, status 2 | from fastapi.responses import RedirectResponse 3 | from database_crud import users_db_crud as db_crud 4 | from schemas import UserSignUp 5 | from sqlalchemy.orm import Session 6 | from database import get_db 7 | from fastapi_sso.sso.google import GoogleSSO 8 | from starlette.requests import Request 9 | from authentication import create_access_token, SESSION_COOKIE_NAME 10 | from dotenv import load_dotenv 11 | import os 12 | 13 | 14 | load_dotenv() 15 | GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") 16 | GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET") 17 | 18 | os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' 19 | 20 | google_sso = GoogleSSO( 21 | GOOGLE_CLIENT_ID, 22 | GOOGLE_CLIENT_SECRET, 23 | f"{os.getenv('HOST')}/v1/google/callback" 24 | ) 25 | 26 | router = APIRouter(prefix="/v1/google") 27 | 28 | 29 | @router.get("/login", tags=['Google SSO']) 30 | async def google_login(): 31 | async with google_sso: 32 | return await google_sso.get_login_redirect(params={"prompt": "consent", "access_type": "offline"}) 33 | 34 | 35 | @router.get("/callback", tags=['Google SSO']) 36 | async def google_callback(request: Request, db: Session = Depends(get_db)): 37 | """Process login response from Google and return user info""" 38 | 39 | try: 40 | async with google_sso: 41 | user = await google_sso.verify_and_process(request) 42 | user_stored = db_crud.get_user(db, user.email, provider=user.provider) 43 | if not user_stored: 44 | user_to_add = UserSignUp( 45 | username=user.email, 46 | fullname=user.display_name 47 | ) 48 | user_stored = db_crud.add_user(db, user_to_add, provider=user.provider) 49 | access_token = create_access_token(username=user_stored.username, provider=user.provider) 50 | response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) 51 | response.set_cookie(SESSION_COOKIE_NAME, access_token) 52 | return response 53 | except db_crud.DuplicateError as e: 54 | raise HTTPException(status_code=403, detail=f"{e}") 55 | except ValueError as e: 56 | raise HTTPException(status_code=400, detail=f"{e}") 57 | except Exception as e: 58 | raise HTTPException( 59 | status_code=500, detail=f"An unexpected error occurred. Report this message to support: {e}") -------------------------------------------------------------------------------- /routers/linkedin_sso.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException, status 2 | from fastapi.responses import RedirectResponse 3 | from database_crud import users_db_crud as db_crud 4 | from schemas import UserSignUp 5 | from sqlalchemy.orm import Session 6 | from database import get_db 7 | from fastapi_sso.sso.linkedin import LinkedInSSO 8 | from starlette.requests import Request 9 | from authentication import create_access_token, SESSION_COOKIE_NAME 10 | from dotenv import load_dotenv 11 | import os 12 | 13 | 14 | load_dotenv() 15 | LINKEDIN_CLIENT_ID = os.getenv("LINKEDIN_CLIENT_ID") 16 | LINKEDIN_CLIENT_SECRET = os.getenv("LINKEDIN_CLIENT_SECRET") 17 | 18 | 19 | linkedin_sso = LinkedInSSO( 20 | LINKEDIN_CLIENT_ID, 21 | LINKEDIN_CLIENT_SECRET, 22 | f"{os.getenv('HOST')}/v1/linkedin/callback" 23 | ) 24 | 25 | router = APIRouter(prefix="/v1/linkedin") 26 | 27 | 28 | @router.get("/login", tags=['LinkedIn SSO']) 29 | async def linkedin_login(): 30 | async with linkedin_sso: 31 | return await linkedin_sso.get_login_redirect() 32 | 33 | 34 | @router.get("/callback", tags=['LinkedIn SSO']) 35 | async def linkedin_callback(request: Request, db: Session = Depends(get_db)): 36 | """Process login response from LinkedIn and return user info""" 37 | 38 | try: 39 | async with linkedin_sso: 40 | user = await linkedin_sso.verify_and_process(request) 41 | username = user.email if user.email else user.display_name 42 | user_stored = db_crud.get_user(db, username, user.provider) 43 | if not user_stored: 44 | user_to_add = UserSignUp( 45 | username=username, 46 | fullname=user.display_name 47 | ) 48 | user_stored = db_crud.add_user( 49 | db, 50 | user_to_add, 51 | provider=user.provider 52 | ) 53 | access_token = create_access_token( 54 | username=user_stored.username, 55 | provider=user.provider 56 | ) 57 | response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) 58 | response.set_cookie(SESSION_COOKIE_NAME, access_token) 59 | return response 60 | except db_crud.DuplicateError as e: 61 | raise HTTPException(status_code=403, detail=f"{e}") 62 | except ValueError as e: 63 | raise HTTPException(status_code=400, detail=f"{e}") 64 | except Exception as e: 65 | raise HTTPException( 66 | status_code=500, 67 | detail=f"An unexpected error occurred. Report this message to support: {e}" 68 | ) 69 | -------------------------------------------------------------------------------- /routers/microsoft_sso.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException, status 2 | from fastapi.responses import RedirectResponse 3 | from database_crud import users_db_crud as db_crud 4 | from schemas import UserSignUp 5 | from sqlalchemy.orm import Session 6 | from database import get_db 7 | from fastapi_sso.sso.microsoft import MicrosoftSSO 8 | from starlette.requests import Request 9 | from authentication import create_access_token, SESSION_COOKIE_NAME 10 | from dotenv import load_dotenv 11 | import os 12 | 13 | 14 | load_dotenv() 15 | MICROSOFT_CLIENT_ID = os.getenv("MICROSOFT_CLIENT_ID") 16 | MICROSOFT_CLIENT_SECRET = os.getenv("MICROSOFT_CLIENT_SECRET") 17 | 18 | 19 | microsoft_sso = MicrosoftSSO( 20 | MICROSOFT_CLIENT_ID, 21 | MICROSOFT_CLIENT_SECRET, 22 | f"{os.getenv('HOST')}/v1/microsoft/callback" 23 | ) 24 | 25 | router = APIRouter(prefix="/v1/microsoft") 26 | 27 | 28 | @router.get("/login", tags=['Microsoft SSO']) 29 | async def microsoft_login(): 30 | async with microsoft_sso: 31 | return await microsoft_sso.get_login_redirect() 32 | 33 | 34 | @router.get("/callback", tags=['Microsoft SSO']) 35 | async def microsoft_callback(request: Request, db: Session = Depends(get_db)): 36 | """Process login response from Microsoft and return user info""" 37 | 38 | try: 39 | async with microsoft_sso: 40 | user = await microsoft_sso.verify_and_process(request) 41 | user_stored = db_crud.get_user(db, user.email, user.provider) 42 | if not user_stored: 43 | user_to_add = UserSignUp( 44 | username=user.email if user.email else user.display_name, 45 | fullname=user.display_name 46 | ) 47 | user_stored = db_crud.add_user(db, user_to_add, provider=user.provider) 48 | access_token = create_access_token(username=user_stored.username, provider=user.provider) 49 | response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) 50 | response.set_cookie(SESSION_COOKIE_NAME, access_token) 51 | return response 52 | except db_crud.DuplicateError as e: 53 | raise HTTPException(status_code=403, detail=f"{e}") 54 | except ValueError as e: 55 | raise HTTPException(status_code=400, detail=f"{e}") 56 | except Exception as e: 57 | raise HTTPException( 58 | status_code=500, detail=f"An unexpected error occurred. Report this message to support: {e}") -------------------------------------------------------------------------------- /routers/spotify_sso.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException, status 2 | from fastapi.responses import RedirectResponse 3 | from database_crud import users_db_crud as db_crud 4 | from schemas import UserSignUp 5 | from sqlalchemy.orm import Session 6 | from database import get_db 7 | from fastapi_sso.sso.spotify import SpotifySSO 8 | from starlette.requests import Request 9 | from authentication import create_access_token, SESSION_COOKIE_NAME 10 | from dotenv import load_dotenv 11 | import os 12 | 13 | 14 | load_dotenv() 15 | SPOTIFY_CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID") 16 | SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET") 17 | 18 | 19 | spotify_sso = SpotifySSO( 20 | SPOTIFY_CLIENT_ID, 21 | SPOTIFY_CLIENT_SECRET, 22 | f"{os.getenv('HOST')}/v1/spotify/callback" 23 | ) 24 | 25 | router = APIRouter(prefix="/v1/spotify") 26 | 27 | 28 | @router.get("/login", tags=['Spotify SSO']) 29 | async def spotify_login(): 30 | async with spotify_sso: 31 | return await spotify_sso.get_login_redirect() 32 | 33 | 34 | @router.get("/callback", tags=['Spotify SSO']) 35 | async def spotify_callback(request: Request, db: Session = Depends(get_db)): 36 | """Process login response from Spotify and return user info""" 37 | 38 | try: 39 | async with spotify_sso: 40 | user = await spotify_sso.verify_and_process(request) 41 | user_stored = db_crud.get_user(db, user.email, user.provider) 42 | if not user_stored: 43 | user_to_add = UserSignUp( 44 | username=user.email, 45 | fullname=user.display_name 46 | ) 47 | user_stored = db_crud.add_user(db, user_to_add, provider=user.provider) 48 | access_token = create_access_token(username=user_stored.username, provider=user.provider) 49 | response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) 50 | response.set_cookie(SESSION_COOKIE_NAME, access_token) 51 | return response 52 | except db_crud.DuplicateError as e: 53 | raise HTTPException(status_code=403, detail=f"{e}") 54 | except ValueError as e: 55 | raise HTTPException(status_code=400, detail=f"{e}") 56 | except Exception as e: 57 | raise HTTPException( 58 | status_code=500, detail=f"An unexpected error occurred. Report this message to support: {e}") -------------------------------------------------------------------------------- /routers/xtwitter_sso.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException, status 2 | from fastapi.responses import RedirectResponse 3 | from database_crud import users_db_crud as db_crud 4 | from schemas import UserSignUp 5 | from sqlalchemy.orm import Session 6 | from database import get_db 7 | from fastapi_sso.sso.twitter import TwitterSSO 8 | from starlette.requests import Request 9 | from authentication import create_access_token, SESSION_COOKIE_NAME 10 | from dotenv import load_dotenv 11 | import os 12 | 13 | 14 | load_dotenv() 15 | XTWITTER_CLIENT_ID = os.getenv("XTWITTER_CLIENT_ID") 16 | XTWITTER_CLIENT_SECRET = os.getenv("XTWITTER_CLIENT_SECRET") 17 | 18 | 19 | xtwitter_sso = TwitterSSO( 20 | XTWITTER_CLIENT_ID, 21 | XTWITTER_CLIENT_SECRET, 22 | f"{os.getenv('HOST')}/v1/xtwitter/callback" 23 | ) 24 | 25 | router = APIRouter(prefix="/v1/xtwitter") 26 | 27 | 28 | @router.get("/login", tags=['X(Twitter) SSO']) 29 | async def xtwitter_login(): 30 | async with xtwitter_sso: 31 | return await xtwitter_sso.get_login_redirect() 32 | 33 | 34 | @router.get("/callback", tags=['X(Twitter) SSO']) 35 | async def xtwitter_callback(request: Request, db: Session = Depends(get_db)): 36 | """Process login response from X(Twitter) and return user info""" 37 | 38 | try: 39 | async with xtwitter_sso: 40 | user = await xtwitter_sso.verify_and_process(request) 41 | username = user.email if user.email else user.display_name 42 | user_stored = db_crud.get_user(db, username, user.provider) 43 | if not user_stored: 44 | user_to_add = UserSignUp( 45 | username=username, 46 | fullname=user.display_name 47 | ) 48 | user_stored = db_crud.add_user( 49 | db, 50 | user_to_add, 51 | provider=user.provider 52 | ) 53 | access_token = create_access_token( 54 | username=user_stored.username, 55 | provider=user.provider 56 | ) 57 | response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) 58 | response.set_cookie(SESSION_COOKIE_NAME, access_token) 59 | return response 60 | except db_crud.DuplicateError as e: 61 | raise HTTPException(status_code=403, detail=f"{e}") 62 | except ValueError as e: 63 | raise HTTPException(status_code=400, detail=f"{e}") 64 | except Exception as e: 65 | raise HTTPException( 66 | status_code=500, 67 | detail=f"An unexpected error occurred. Report this message to support: {e}" 68 | ) 69 | -------------------------------------------------------------------------------- /schemas.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from datetime import datetime 3 | from typing import Optional 4 | 5 | 6 | class UserSignUp(BaseModel): 7 | username: str 8 | password: Optional[str] = None 9 | fullname: Optional[str] = None 10 | 11 | 12 | class User(BaseModel): 13 | username: str 14 | fullname: Optional[str] 15 | provider: Optional[str] 16 | register_date: Optional[datetime] 17 | 18 | class Config: 19 | from_attributes = True 20 | 21 | 22 | class Token(BaseModel): 23 | access_token: str 24 | token_type: str 25 | 26 | 27 | class UserStat(BaseModel): 28 | provider: str 29 | count: int 30 | -------------------------------------------------------------------------------- /static/css/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Geneva', Arial, Helvetica, sans-serif; 3 | text-align: center; 4 | font-size: large; 5 | } 6 | 7 | input[type=text], 8 | input[type=password] { 9 | width: 100%; 10 | padding: 12px 20px; 11 | margin: 8px 0; 12 | display: inline-block; 13 | } 14 | 15 | button { 16 | background-color: #27a9e1; 17 | color: white; 18 | padding: 15px; 19 | margin: 10px 10px; 20 | border: none; 21 | cursor: pointer; 22 | width: 100%; 23 | vertical-align: bottom; 24 | } 25 | 26 | button[disabled] { 27 | background-color: #ccc; 28 | cursor: not-allowed; 29 | } 30 | 31 | button:hover { 32 | opacity: 0.8; 33 | } 34 | 35 | .login-btn { 36 | border-radius: 3px; 37 | } 38 | 39 | .login-container { 40 | height: 80vh; 41 | display: flex; 42 | vertical-align: middle; 43 | justify-content: center; 44 | align-items: center; 45 | } 46 | 47 | .signup_or_sso { 48 | position: relative; 49 | text-align: center; 50 | } 51 | 52 | .signup-link { 53 | display: inline-block; 54 | margin-top: 10px; 55 | color: #27a9e1; 56 | text-decoration: none; 57 | align-content: center; 58 | } 59 | 60 | .signup-link:hover { 61 | text-decoration: underline; 62 | } 63 | 64 | #continue_with_sso { 65 | padding: 10px 0px 5px 0px; 66 | } 67 | 68 | .container { 69 | display: flex; 70 | flex-direction: column; 71 | min-height: 100vh; 72 | justify-content: space-between; 73 | } 74 | 75 | .header { 76 | display: flex; 77 | justify-content: space-between; 78 | align-items: center; 79 | padding: 10px; 80 | } 81 | 82 | .logout { 83 | display: flex; 84 | align-items: center; 85 | } 86 | 87 | .username { 88 | margin: 0; 89 | } 90 | 91 | .users-table { 92 | flex-grow: 1; 93 | overflow: auto; 94 | padding: 10px; 95 | } 96 | 97 | .footer { 98 | text-align: center; 99 | margin-top: auto; 100 | } 101 | 102 | .version-container { 103 | display: inline-block; 104 | bottom: 0; 105 | right: 0; 106 | padding: 10px; 107 | } 108 | 109 | .version-table th { 110 | text-align: left; 111 | padding: 5px 10px; 112 | } 113 | 114 | .version-table td { 115 | padding: 5px 10px; 116 | } 117 | 118 | @media only screen and (max-width: 990px) { 119 | .version-container { 120 | position: static; 121 | margin-bottom: 20px; 122 | } 123 | 124 | .login-container { 125 | display: block; 126 | align-items: center; 127 | margin: 10%; 128 | } 129 | 130 | .row.mt-3 { 131 | display: flex; 132 | flex-wrap: wrap; 133 | justify-content: center; 134 | } 135 | 136 | .row.mt-3 form { 137 | flex: 0 0 calc(50% - 20px); 138 | margin: 10px; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /static/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisK824/fastapi-sso-example/d27a547cc8c7c047bcb6674969e61caa885e16f6/static/images/favicon.png -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |{{user.username}}
18 | 21 |Provider | 29 |Users # | 30 |
---|---|
{{record.provider}} | 36 |{{record.count}} | 37 |
Don't have an account? Sign up
30 |Or continue with:
31 |Last Updated: 21/04/2024
22 | 23 |Thank you for visiting {{host}}. Your privacy is important to us, and we are committed to protecting your personal information. This Privacy Policy and Disclaimer outlines the types of personal information we collect and how we use, share, and protect it.
24 | 25 |{{host}} is a demonstration, testing, and development platform for Single Sign-On (SSO) authentication methods from various providers.
28 | 29 |We collect only the personal data necessary for SSO authentication, which may include:
32 |We use the collected personal data solely for the purpose of SSO authentication and to enhance your experience on our website. We do not use or share your personal information with third parties for marketing or advertising purposes.
41 | 42 |We may use aggregated, anonymized data for statistical analysis to understand how users interact with our website, such as:
45 |This statistical data does not reveal any individual's personal information and is used solely to improve our services and user experience.
51 | 52 |We implement appropriate security measures to protect your personal information from unauthorized access, alteration, disclosure, or destruction.
55 | 56 |We may update this Privacy Policy and Disclaimer from time to time. Any changes will be posted on this page with an updated "Last Updated" date. Your continued use of our website after any changes to this Privacy Policy constitutes your acceptance of the revised policy.
59 | 60 |If you have any questions or concerns about this Privacy Policy and Disclaimer or our data practices, please contact us at {{contact_email}}.
63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "public": false, 4 | "devCommand": "uvicorn main:app --host 0.0.0.0 --port 80", 5 | "builds": [ 6 | { 7 | "src": "main.py", 8 | "use": "@vercel/python" 9 | } 10 | ], 11 | "routes": [ 12 | { 13 | "src": "/(.*)", 14 | "dest": "main.py" 15 | } 16 | ] 17 | } --------------------------------------------------------------------------------