├── .gitattributes ├── LICENSE ├── README.md ├── basic └── main.py ├── first_party ├── main.py └── security.py ├── requirements.txt └── sessions ├── dependencies.py └── main.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Lukas Thaler 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 | # The OAuth2 authorization code flow using FastAPI 2 | This repository showcases two examples of how to implement the OAuth2 authorization code flow and one example of the OAuth2 implicit grant flow. 3 | 4 | The `basic` example contains the API routes needed to complete the OAuth2 authorization code flow. At the end, you'll be left with access and refresh tokens for the user and the scopes you requested. 5 | 6 | The `sessions` example expands on that with signed session cookies managed by `starlette`'s `SessionMiddleware`. These session cookies allow a client to authenticate using a cookie issued to them by your API service. Since the cookies are *signed*, you'll be able to verify if they have been tampered with (actually, `starlette` automatically does that for you, denying modified cookies a session and thus, authentication). This example then also showcases how to get the currently active user and how to restrict endpoints to logged-in users only. 7 | 8 | The `first_party` example implements the implicit grant flow. Please note that this flow is deprecated as of OAuth2.1. However, it is fine to implement this flow if you're going to be the only login authority, i.e. your users can only log in directly to your service, and you're not planning on using this as a third-party authorization provider for some other application. This example also showcases the use of API scopes to regulate access to endpoints. 9 | 10 | **NOTE**: The examples showcased here are built as concise and straight-to-the point as possible, foregoing return models (using `pydantic`) and other advanced FastAPI features to put the spotlight on the OAuth2 flow. I will highlight some more advanced concepts in the **Extending these examples** section. 11 | 12 | 13 | # Prerequisites 14 | * These examples were built and tested using Python 3.9.13, but they should also work for lower versions provided the libraries are available. 15 | * Install the dependencies listed in `requirements.txt`. The different examples each require a specific subset of the included libraries, see below for details. **All examples** need the following libraries to be installed: `fastapi` (for obvious reasons), `starlette` (this library will automatically be installed with `fastapi`, but I included it for clarity), `authlib` (to handle the OAuth2 flow), `httpx` and `itsdangerous` (required for `authlib ` to properly work), `uvicorn` (to serve the app). Additionally, the following libraries are necessary: 16 | * `basic` example: no further libraries need to be installed 17 | * `sessions` example: `aiohttp` (asynchronous HTTP requests) 18 | * `first_party` example: `python-jose` (JWT (Json Web Token) handling to make access tokens work), `python-multipart` (required to receive user input from a web form) 19 | 20 | I chose to include running the app in the respective main files for ease of demonstration, but you'll probably want to run the app from the command line using some variation of `$ uvicorn main:app` in a productive environment. 21 | * Your OAuth2 flow for the `basic` and `sessions` examples will be rejected unless you have authorized the redirect URI you request the flow to be directed to after obtaining the authorization code from the user. To whitelist your endpoint, head to `https://discord.com/developers/applications/{YOUR APPLICATION ID}/oauth2/general` (if you don't have your application id at hand, go to `https://discord.com/developers/applications` and select your application, then click on OAuth2 in the left sidebar menu). Once there, under **Redirects**, hit the "Add another" button and enter `127.0.0.1:5000/auth` (if you have modified host, port or the endpoint name, adapt accordingly). 22 | 23 | 24 | # Running these examples 25 | 26 | ### basic example 27 | To run the basic example, you need to update the `CLIENT_ID` (line 17), `CLIENT_SECRET` (line 18) and `SESSION_SECRET` (line 19) variables with values for your app. If you haven't already stored them somewhere safe, you can retrieve them from your discord developers page. After that, run `main.py`. You will see some log output like the below: 28 | ``` 29 | INFO: Started server process [XXX] 30 | INFO: Waiting for application startup. 31 | INFO: Application startup complete. 32 | INFO: Uvicorn running on http://127.0.0.1:5000 (Press CTRL+C to quit) 33 | ``` 34 | This means that the API is up and running. Now, navigate your web browser to http://127.0.0.1:5000/login. You will be redirected to a Discord authentication page. Log in if needed and then confirm your app's request to access your data. Upon confirming the request, you will be redirected once more and you'll see a json object containing your token similar to the one below: 35 | ``` 36 | { 37 | "access_token": "ACCESS TOKEN REDACTED", 38 | "expires_in": 604800, 39 | "refresh_token": "REFRESH TOKEN REDACTED", 40 | "scope": "identify", 41 | "token_type": "Bearer", 42 | "expires_at": 1651756835 43 | } 44 | ``` 45 | 46 | ### sessions example 47 | To run the sessions example, you need to update the `CLIENT_ID`, `CLIENT_SECRET` and `SESSION_SECRET` (lines 22-24) variables with values for your app. After that, start the API as detailed in the basic example section. Don't head to http://127.0.0.1:5000/login straight away, however. First, visit http://127.0.0.1:5000/privileged. You'll be greeted with a "401 UNAUTHORIZED" error message because you need to be logged in to access this endpoint. Now, follow the same login process as the basic example. Instead of being displayed a token on success, you'll be redirected to `/users/me` and receive a JSON dump of your Discord information, including ID, name, discriminator and more. Also, if you inspect the cookies for your localhost, you will see that a session cookie has been set. This cookie contains an encoded version of the token from the basic example and is used by the `/users/me` endpoint to verify your identity and request your information from Discord. Finally, head to http://127.0.0.1:5000/privileged once more. Instead of the error, you will now see a message saying "Congratulations, you are logged in using Discord!". 48 | 49 | 50 | ### first party example 51 | This example can be run as-is, no code modifications are required. Start the API by running `main.py`. To start off, head to http://127.0.0.1:5000/endpoint1 and http://127.0.0.1:5000/endpoint3. The first endpoint will greet you with a 401 Not Authorized error, the second will return a 200 response. If you have the ability to make HTTP requests (i.e. using a GUI like Postman or by using a command-line tool such as `curl`), please continue reading below. Otherwise, continue on to the "using the FastAPI swagger docs" section. 52 | 53 | ##### using HTTP requests 54 | Your next destination is http://127.0.0.1:5000/login. Provide any value for username and password, they are not validated in this example. For now, only check the first scope (`some.scope`) and leave the other two unchecked. Hit "Submit" and you'll be forwarded to http://127.0.0.1:5000/token and provided with an access token like the one below (if you're curious about the content of the token, you can decode it using a site like https://jwt.io): 55 | ``` 56 | { 57 | "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3N1ZXIiOiJteWFwcCIsInVzZXJuYW1lIjoiam9obmRvZSIsInNjb3BlcyI6InNvbWUuc2NvcGUiLCJleHBpcmVzX2F0IjoxNjcxNzI3NTY5Ljc3Mzc4Mn0.A9H--COsYLKJyp0Bf-lGQMEUganmyVYTDxjnYQOA68M", 58 | "token_type": "bearer" 59 | } 60 | ``` 61 | Copy the access token. You now have access to endpoint 1, but not to endpoint 2. To verify this, hit both endpoints using an authorization header (`Authorization: Bearer TOKEN`, where `TOKEN` is the access_token string you received). The first will respond with "Hello from endpoint 1" and the second will hit you with a 403 Forbidden error message. That's it. You have your own implicit grant OAuth2 flow. If you want to try out different scope settings, just return to the login page and generate a new access token. 62 | 63 | ##### using the FastAPI swagger docs 64 | Head to http://127.0.0.1:5000/docs and hit the green "Authorize" button in the top right of the page. Fill the `username` and `password` fields with arbitrary values (they are not validated) and enable `some.scope` by clicking the checkbox left of it. Leave everything else unchanged and click "Authorize" and then "Close". Next, go to `/endpoint1`, expand the section and hit "Try it out", then hit "Execute". You will receive a "Hello from endpoint 1" message. If you repeat the same process for `/endpoint2`, you will be greeted with a 403 Forbidden error message. That's it. You have your own implicit grant OAuth2 flow. If you want to try out different scope settings, just return to the login page and generate a new access token. 65 | 66 | 67 | # Extending these examples 68 | 69 | ### Using different authentication providers 70 | The same ideas presented here can easily be applied to different remote identity providers, e.g. Google or Facebook, even in a single application. This can be done by registering another remote app in your code like so (change the scope according to what you need, I just grabbed a random one off of Google's OAuth2 tutorial website): 71 | ```python 72 | oauth.register( 73 | 'google', 74 | authorize_url='https://accounts.google.com/o/oauth2/auth', 75 | access_token_url='https://oauth2.googleapis.com/token', 76 | scope='https://www.googleapis.com/auth/drive.metadata.readonly', 77 | client_id=GOOGLE_CLIENT_ID, 78 | client_secret=GOOGLE_CLIENT_SECRET 79 | ) 80 | ``` 81 | 82 | The Google OAuth2 client defined in this manner would then be called with `oauth.google`, the rest of the flow doesn't need to change by much, though it is encouraged to build dedicated endpoints (`/login/discord`, `/login/google` and `/auth/discord`, `/auth/google`) for each identity provider (the main `/login` endpoint could list a choice of login options in this scenario). The token structure may differ from remote application to remote application, however. Make sure to account for that if you plan on using multiple identity providers. In that case, the `/auth` endpoint will have to do some user management, anyway (e.g. store the user, assign them a unique ID for your service and then store that in a user info cookie of some sort - see the comments in the auth endpoint of `sessions/main.py` on how to set custom cookies). 83 | 84 | ### Building your own permission system on top of the session infrastructure 85 | By adding some user management to your `/auth` endpoint (i.e. storing the user ID in a database of some sort), you can build a custom permission system. This entails storing and managing a set of permissions along each user ID and building dependencies to ensure the user has the permissions needed to access a specific endpoint similar to `is_logged_in` in the `dependencies.py` file in the `sessions` example. Below, I'll give a brief, incomplete example to illustrate one way custom permission handling could be done: 86 | ```python 87 | from fastapi import Depends, FastAPI, HTTPException, status 88 | from starlette.requests import Request 89 | 90 | # custom components 91 | from dependencies import get_token 92 | 93 | 94 | app = FastAPI() 95 | 96 | async def get_user_id(request: Request): 97 | # ACTION ITEM: get the user ID from the request by means of extracting it from the "userinfo" 98 | # cookie (if implemented, see the comment in the auth endpoint of `sessions\main.py` 99 | # for details) or by querying the identity provider (Discord, in our example) 100 | import random 101 | user_id = random.choice([1234567890, None]) 102 | 103 | if not user_id: 104 | raise HTTPException( 105 | status_code=status.HTTP_401_UNAUTHORIZED, 106 | detail='Not logged in' 107 | ) 108 | 109 | return user_id 110 | 111 | 112 | class PermissionChecker: 113 | def __init__(self, permission): 114 | self.permission = permission 115 | 116 | def __call__(self, user_id=Depends(get_user_id)): 117 | # ACTION ITEM: query your database to see if the currently logged-in user 118 | # has self.permission 119 | import random 120 | has_permission = random.choice([True, False]) 121 | 122 | if not has_permission: 123 | raise HTTPException( 124 | status_code=status.HTTP_403_FORBIDDEN, 125 | detail='Insufficient permissions' 126 | ) 127 | 128 | permission1 = 'Admin' # this can be anything: strings, numeric IDs - design as you like 129 | has_admin_permission = PermissionChecker(permission1) 130 | 131 | permission2 = 'Something' 132 | has_permission_two = PermissionChecker(permission2) 133 | 134 | @app.get('/admin', dependencies=[Depends(has_admin_permission)]) 135 | async def admin_only(): 136 | return 'You are an admin!' 137 | 138 | 139 | @app.get('/privileged', dependencies=[Depends(has_permission_two)]) 140 | async def is_privileged(): 141 | return 'You are truly privileged!' 142 | ``` -------------------------------------------------------------------------------- /basic/main.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | from authlib.integrations.starlette_client import OAuth, OAuthError 4 | from fastapi import FastAPI, HTTPException, status 5 | from starlette.config import Config 6 | from starlette.middleware.sessions import SessionMiddleware 7 | from starlette.requests import Request 8 | 9 | 10 | # configuration parameters 11 | HOST = '127.0.0.1' 12 | PORT = 5000 13 | SCOPE = 'identify' # if you need more scopes, add them to the string (separated with whitespaces) 14 | 15 | # in a productive app, DO NOT leave any of the following in your code 16 | # ACTION ITEM: replace these placeholders with your own values 17 | CLIENT_ID = 'YOUR DISCORD CLIENT ID HERE' 18 | CLIENT_SECRET = 'YOUR DISCORD CLIENT SECRET HERE' 19 | SESSION_SECRET = 'REPLACE WITH A PROPER SECRET OF YOUR CHOICE' 20 | 21 | 22 | # initialize the API 23 | app = FastAPI() 24 | 25 | 26 | # add session middleware (this is used internally by starlette to execute the authorization flow) 27 | app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET, 28 | max_age=60 * 60 * 24 * 7) # one week, in seconds 29 | 30 | 31 | # configure OAuth client 32 | config = Config(environ={}) # you could also read the client ID and secret from a .env file 33 | oauth = OAuth(config) 34 | oauth.register( # this allows us to call oauth.discord later on 35 | 'discord', 36 | authorize_url='https://discord.com/api/oauth2/authorize', 37 | access_token_url='https://discord.com/api/oauth2/token', 38 | scope=SCOPE, 39 | client_id=CLIENT_ID, 40 | client_secret=CLIENT_SECRET 41 | ) 42 | 43 | 44 | # define the endpoints for the OAuth2 flow 45 | @app.get('/login') 46 | async def get_authorization_code(request: Request): 47 | """OAuth2 flow, step 1: have the user log into Discord to obtain an authorization code grant 48 | """ 49 | 50 | redirect_uri = request.url_for('auth') 51 | return await oauth.discord.authorize_redirect(request, redirect_uri) 52 | 53 | 54 | @app.get('/auth') 55 | async def auth(request: Request): 56 | """OAuth2 flow, step 2: exchange the authorization code for access token 57 | """ 58 | 59 | # exchange auth code for token 60 | try: 61 | token = await oauth.discord.authorize_access_token(request) 62 | except OAuthError as error: 63 | raise HTTPException( 64 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 65 | detail=error.error 66 | ) 67 | 68 | # you now have a Discord token. Do whatever you need with it 69 | return token 70 | 71 | 72 | # run the API 73 | if __name__ == '__main__': 74 | uvicorn.run('main:app', host=HOST, port=PORT) 75 | -------------------------------------------------------------------------------- /first_party/main.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | from fastapi import Depends, FastAPI, HTTPException, Security, status 4 | from starlette.responses import HTMLResponse 5 | 6 | # custom components 7 | from security import APP_NAME, create_access_token, ensure_permissions, OAuth2Form, SCOPES, validate_login 8 | 9 | 10 | # configuration parameters 11 | HOST = '127.0.0.1' 12 | PORT = 5000 13 | 14 | 15 | # initialize the API 16 | app = FastAPI() 17 | 18 | 19 | # define the endpoints for the OAuth2 flow 20 | 21 | @app.get('/login') 22 | async def login_page(): 23 | """OAuth2 implicit flow, step 1.1: have the user log in and confirm a set of scopes 24 | """ 25 | 26 | # build and send a basic login page 27 | html_header = ''' 28 |
29 | 30 |
31 | 32 |
33 | ''' 34 | 35 | if SCOPES: 36 | scope_template = ''' 37 | 38 |
39 | ''' 40 | html_scopes = ''.join(scope_template.format(scope=scope) for scope in SCOPES) 41 | else: 42 | html_scopes = '' 43 | 44 | html_footer = ''' 45 | 46 |
47 | ''' 48 | 49 | return HTMLResponse(html_header + html_scopes + html_footer) 50 | 51 | 52 | @app.post('/token') 53 | async def login_for_token(form_data: OAuth2Form = Depends()): 54 | """OAuth2 password bearer flow: exchange username/password for access token 55 | """ 56 | 57 | # validate username/password 58 | if not validate_login(form_data.username, form_data.password): 59 | raise HTTPException( 60 | status_code=status.HTTP_401_UNAUTHORIZED, 61 | detail='Invalid username or password' 62 | ) 63 | 64 | # generate access token 65 | # NOTE: in a productive environment, you have to validate the requested scopes against the user's permissions 66 | # otherwise, attackers may be able to get more permissions than they are supposed to have 67 | token_data = {'issuer': APP_NAME, 'username': form_data.username, 'scopes': ' '.join(form_data.scopes)} 68 | access_token = create_access_token(token_data) 69 | 70 | return {'access_token': access_token, 'token_type': 'bearer'} 71 | 72 | 73 | @app.get('/endpoint1', dependencies=[Security(ensure_permissions, scopes=['some.scope', 'third.scope'])]) 74 | def endpoint1(): 75 | return 'Hello from endpoint 1' 76 | 77 | 78 | @app.get('/endpoint2', dependencies=[Security(ensure_permissions, scopes=['other.scope', 'third.scope'])]) 79 | def endpoint2(): 80 | return 'Hello from endpoint 2' 81 | 82 | 83 | @app.get('/endpoint3') 84 | def endpoint3(): 85 | return 'Hello from the unprotected endpoint 3' 86 | 87 | 88 | # run the API 89 | if __name__ == '__main__': 90 | uvicorn.run('main:app', host=HOST, port=PORT) 91 | -------------------------------------------------------------------------------- /first_party/security.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from fastapi import Depends, HTTPException, status 3 | from fastapi.param_functions import Form 4 | from fastapi.security import OAuth2PasswordBearer, SecurityScopes 5 | 6 | from jose import jwt 7 | from typing import Optional 8 | 9 | 10 | APP_NAME = 'myapp' 11 | 12 | # in a productive app, DO NOT leave this in your code 13 | # ACTION ITEM: replace this with a proper secret you come up with 14 | # this secret is used to sign the access tokens, allowing jose to verify that they have not been tampered with 15 | TOKEN_SECRET = 'YOUR TOKEN SECRET HERE' 16 | 17 | # ACTION ITEM: replace these scopes with your own scopes. If you don't need any, leave the dict empty 18 | SCOPES = { 19 | 'some.scope': 'Can access endpoint1', 20 | 'other.scope': 'Can access endpoint2', 21 | 'third.scope': 'Can access both endpoint1 and endpoint2' 22 | } 23 | 24 | # used to en-/decode the JWT tokens. Only modify if you know what you're doing 25 | ALGORITHM = 'HS256' 26 | 27 | 28 | # define the oauth scheme 29 | oauth2_scheme = OAuth2PasswordBearer( 30 | tokenUrl="token", 31 | scopes=SCOPES, 32 | ) 33 | 34 | 35 | class OAuth2Form: 36 | """container class to capture the form inputs from the login page 37 | """ 38 | 39 | def __init__( 40 | self, 41 | username: str = Form(), 42 | password: str = Form(), 43 | scope: list[str] = Form(default=[]) 44 | ): 45 | self.username = username 46 | self.password = password 47 | self.scopes = scope 48 | 49 | 50 | def create_access_token(data: dict, expiry_seconds: Optional[int] = 60 * 60 * 24 * 7 # one week, in seconds 51 | ) -> str: 52 | """create an access token from a given dict of data 53 | Parameters 54 | ---------- 55 | data: dictionary 56 | the data to include in the token 57 | expiry_seconds: timedelta or None 58 | how long the token should be valid for. Defaults to one week 59 | 60 | Returns 61 | ------- 62 | string 63 | the JWT-encoded access token 64 | """ 65 | 66 | to_encode = data.copy() 67 | 68 | # add expiry date 69 | expiry_date = datetime.datetime.utcnow() + datetime.timedelta(seconds=expiry_seconds) 70 | to_encode.update({'expires_at': expiry_date.timestamp()}) 71 | 72 | # encode and return 73 | encoded_jwt = jwt.encode(to_encode, TOKEN_SECRET, algorithm=ALGORITHM) 74 | return encoded_jwt 75 | 76 | 77 | def validate_token(token: dict) -> bool: 78 | """ensure the token is valid (i.e. issued by us and not expired) 79 | """ 80 | 81 | return token.get('issuer') == APP_NAME and token.get('expires_at', 0) > datetime.datetime.utcnow().timestamp() 82 | 83 | 84 | async def ensure_permissions(security_scopes: SecurityScopes, token: str = Depends(oauth2_scheme)): 85 | """ensure the provided token has enough permissions to access the requested resource 86 | """ 87 | 88 | decoded_token = jwt.decode(token, TOKEN_SECRET, algorithms=[ALGORITHM]) 89 | 90 | # check if the token is valid 91 | if not validate_token(decoded_token): 92 | raise HTTPException( 93 | status_code=status.HTTP_401_UNAUTHORIZED, 94 | detail='Could not validate credentials' 95 | ) 96 | 97 | # check if the provided token has any of the necessary scopes 98 | if not has_any_scope(security_scopes.scopes, decoded_token): 99 | raise HTTPException( 100 | status_code=status.HTTP_403_FORBIDDEN, 101 | detail='Insufficient permissions' 102 | ) 103 | 104 | 105 | def has_any_scope(scopes: list[str], token_data: dict) -> bool: 106 | """check if a token has any of the required scopes. 107 | Similarly, you can build `has_all_scopes` or arbitrary other checks to extend or replace this check 108 | """ 109 | 110 | if not scopes: # no scopes required, always go through 111 | return True 112 | return any(scope in scopes for scope in token_data.get('scopes', '').split()) 113 | 114 | 115 | def validate_login(username: str, password: str) -> bool: 116 | """validate username/password against the credentials you have stored 117 | IMPORTANT NOTE: never, ever store credentials in plain text, always use some form of encryption 118 | one way of doing that is `passlib`'s CryptContext 119 | 120 | Parameters 121 | ---------- 122 | username: string 123 | password: string 124 | 125 | Returns 126 | ------- 127 | bool 128 | whether or not the user login was successfully validated 129 | """ 130 | 131 | # ACTION ITEM: replace this with proper logic to validate the provided credentials 132 | return True 133 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.8.3 2 | authlib==1.2.0 3 | fastapi==0.88.0 4 | httpx==0.23.1 5 | itsdangerous==2.1.2 6 | python-jose==3.3.0 7 | python-multipart==0.0.5 8 | starlette==00.22.0 9 | uvicorn==0.20.0 -------------------------------------------------------------------------------- /sessions/dependencies.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException, status 2 | from starlette.requests import Request 3 | 4 | 5 | def get_token(request: Request): 6 | """a dependency to extract the token from the request's session cookie 7 | """ 8 | 9 | session = request.session 10 | if not session: 11 | raise HTTPException( 12 | status_code=status.HTTP_401_UNAUTHORIZED, 13 | detail='Not logged in' 14 | ) 15 | return session['user'] 16 | 17 | 18 | async def is_logged_in(request: Request): 19 | """a dependency to ensure a user is logged in via a session cookie 20 | """ 21 | 22 | session = request.session 23 | if not session: 24 | raise HTTPException( 25 | status_code=status.HTTP_401_UNAUTHORIZED, 26 | detail='Not logged in' 27 | ) 28 | -------------------------------------------------------------------------------- /sessions/main.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import uvicorn 3 | 4 | from authlib.integrations.starlette_client import OAuth, OAuthError 5 | from fastapi import Depends, FastAPI, HTTPException, status 6 | from starlette.config import Config 7 | from starlette.middleware.sessions import SessionMiddleware 8 | from starlette.requests import Request 9 | from starlette.responses import RedirectResponse 10 | 11 | # custom components 12 | from dependencies import get_token, is_logged_in 13 | 14 | 15 | # configuration parameters 16 | HOST = '127.0.0.1' 17 | PORT = 5000 18 | DISCORD_API_PATH = 'https://discord.com/api/v9' 19 | 20 | # in a productive app, DO NOT leave any of the following in your code 21 | # ACTION ITEM: replace these placeholders with your own values 22 | CLIENT_ID = 'YOUR CLIENT ID HERE' 23 | CLIENT_SECRET = 'YOUR CLIENT SECRET HERE' 24 | SESSION_SECRET = 'REPLACE WITH A PROPER SECRET OF YOUR CHOICE' 25 | 26 | 27 | # initialize the API 28 | app = FastAPI() 29 | 30 | 31 | # add session middleware 32 | app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET, 33 | max_age=60 * 60 * 24 * 7) # one week, in seconds 34 | 35 | 36 | # configure OAuth client 37 | config = Config(environ={}) # you could also read the client ID and secret from a .env file 38 | oauth = OAuth(config) 39 | oauth.register( # this allows us to call oauth.discord later on 40 | 'discord', 41 | authorize_url='https://discord.com/api/oauth2/authorize', 42 | access_token_url='https://discord.com/api/oauth2/token', 43 | scope='identify', 44 | client_id=CLIENT_ID, 45 | client_secret=CLIENT_SECRET 46 | ) 47 | 48 | 49 | # define the endpoints for the OAuth2 flow 50 | @app.get('/login') 51 | async def get_authorization_code(request: Request): 52 | """OAuth2 flow, step 1: have the user log into Discord to obtain an authorization code grant 53 | """ 54 | 55 | redirect_uri = request.url_for('auth') 56 | return await oauth.discord.authorize_redirect(request, redirect_uri) 57 | 58 | 59 | @app.get('/auth') 60 | async def auth(request: Request): 61 | """OAuth2 flow, step 2: exchange the authorization code for access token 62 | """ 63 | 64 | # exchange auth code for token 65 | try: 66 | token = await oauth.discord.authorize_access_token(request) 67 | except OAuthError as error: 68 | raise HTTPException( 69 | status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 70 | detail=error.error 71 | ) 72 | 73 | # set the session cookie 74 | request.session['user'] = dict(token) 75 | 76 | # build the response 77 | response = RedirectResponse(url='/users/me') 78 | 79 | # you could also fetch additional user info here (see get_current_user) and set a 80 | # user info cookie like so (requires python-jose installed and "from jose import jwt"): 81 | # user_info = await get_current_user(token) 82 | # response.set_cookie('userinfo', jwt.encode(payload, SESSION_SECRET, algorithm='HS256'), 83 | # max_age=60 * 60 * 24 * 7) 84 | # this way, you'll always have user id, name etc. available from request.cookies['userinfo'] 85 | # without needing to go and fetch it every time (you'll need to jwt.decode the cookie first) 86 | # Of course, the user info may be outdated this way, but you'll mainly need the static user id 87 | 88 | # redirect the user to the profile endpoint 89 | return response 90 | 91 | 92 | # this endpoint is explicitly login-protected by requiring the token from the session cookie 93 | @app.get('/users/me') 94 | async def get_current_user(token=Depends(get_token)): 95 | """get the currently logged-in user based on their session cookie 96 | """ 97 | 98 | # use the access token to fetch the user 99 | headers = {'Authorization': f'Bearer {token.get("access_token")}'} 100 | async with aiohttp.ClientSession() as sess: 101 | async with sess.get(DISCORD_API_PATH + '/users/@me', headers=headers) as response: 102 | # catch any http errors 103 | if response.status != status.HTTP_200_OK: 104 | response.raise_for_status() 105 | 106 | payload = await response.json() 107 | return payload 108 | 109 | 110 | # this endpoint is implicitly login-protected via a dependency checking for a session cookie 111 | @app.get('/privileged', dependencies=[Depends(is_logged_in)]) 112 | async def only_for_logged_in_users(): 113 | return 'Congratulations, you are logged in using Discord!' 114 | 115 | 116 | # run the API 117 | if __name__ == '__main__': 118 | uvicorn.run('main:app', host=HOST, port=PORT) 119 | --------------------------------------------------------------------------------