├── .env.sample ├── .gitignore ├── LICENSE ├── README.md ├── app ├── __init__.py ├── api │ ├── __init__.py │ ├── dependencies │ │ ├── __init__.py │ │ ├── auth.py │ │ └── db.py │ └── routes │ │ ├── __init__.py │ │ └── api.py ├── core │ ├── __init__.py │ └── config.py ├── db │ ├── __init__.py │ ├── exceptions.py │ └── repositories │ │ ├── TodoRepository.py │ │ └── __init__.py ├── main.py ├── models │ ├── __init__.py │ ├── domain │ │ ├── TodoItem.py │ │ ├── User.py │ │ └── __init__.py │ └── schemas │ │ ├── TodoItem.py │ │ └── __init__.py └── services │ ├── AzureADAuthorization.py │ └── __init__.py ├── requirements.txt └── tests ├── __init__.py ├── conftest.py └── test_api └── __init__.py /.env.sample: -------------------------------------------------------------------------------- 1 | API_CLIENT_ID= 2 | API_CLIENT_SECRET= 3 | SWAGGER_UI_CLIENT_ID= 4 | AAD_TENANT_ID= 5 | -------------------------------------------------------------------------------- /.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 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 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 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .idea/ 132 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Tess Ferrandez 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 | # Quickstart: Protect FastAPI web API with the Microsoft identity platform 2 | 3 | In this quickstart, you download a Python FastAPI web API code sample, and review the way it restricts resource access to authorized accounts only. The sample supports authorization of personal Microsoft accounts and accounts in any Azure Active Directory (Azure AD) organization. 4 | 5 | ## Prerequisites 6 | 7 | - Azure account with an active subscription. [Create an account for free](https://azure.microsoft.com/en-us/free/?WT.mc_id=A261C142F). 8 | - [Azure Active Directory tenant](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-create-new-tenant) 9 | - Python 3.8 or higher 10 | - [Visual Studio Code](https://code.visualstudio.com/) (or equivalent) 11 | 12 | ## Step 1: Clone or download the sample 13 | 14 | Clone the sample from your shell or command line: 15 | 16 | ```cmd 17 | git clone https://github.com/TessFerrandez/fastapi_with_aad_auth.git 18 | ``` 19 | 20 | ## Step 2: Register the web API Azure AD Applications 21 | 22 | Register your web API in **App Registrations** in the Azure portal. 23 | 24 | 1. Sign in to the Azure Portal 25 | 1. If you have access to multiple tenants, use the **Directory + subscription** filter in the top menu to select the tenant in which you want to register an application. 26 | 1. Find and select **Azure Active Directory**. 27 | 1. Under **Manage**, select **App registrations > New registration**. 28 | 1. Enter a **Name** for your application, for example `TODO-API`. Users of your app might see this name and you can change it later. 29 | 1. For **Supported account types**, select **Accounts in any organizational directory**. 30 | 1. Select **Register** to create the application. 31 | 1. On the **Overview** page, look for the **Application (client) ID** value, and then record it for later use. You'll need it to configure the API (that is, `API_CLIENT_ID` in the *.env* file). 32 | 1. Under **Manage**, select **Expose an API > Add a scope**. Accept the proposed Application ID URI (api://{clientId}) by selecting **Save and continue**, and then enter the following information: 33 | - For **Scope name**, enter `access_as_user` 34 | - For **Who can consent**, ensure that the **Admins and users** option is selected. 35 | - In the **Admin consent display name** box, enter `Access TODO API as a user`. 36 | - In the **Admin consent description** box, enter `Access TODO API as a user`. 37 | - In the **User consent display name** box, enter `Access TODO API as a user`. 38 | - In the **User consent description** box, enter `Access TODO API as a user`. 39 | - For **State**, keep **Enabled**. 40 | 1. Select **Add scope**. 41 | 42 | Add roles to the API App registration 43 | 44 | 1. Under **Manage**, select **App Roles** and **Create app role** 45 | - For **Display name**, enter `Admin` 46 | - For **Allowed member types**, select **Users/Groups** 47 | - For **Value**, enter `Admin` 48 | - For **Description**, enter `Administrator` 49 | - Ensure that the checkbox for **Do you want to enable this app role?** is checked 50 | 1. Repeat the same steps to create a `User` app role 51 | 52 | Add a secret to the API App registration 53 | 54 | 1. Under **Manage**, select **Certificates & secrets** and **New client secret** 55 | - For **Description**, enter `API Client Secret` 56 | - For **Expires**, leave it at 6 months 57 | 1. Select **Add** 58 | 1. On the **Certificates & Secrets** page, save the secret **Value**. You'll need it to configure the API (that is `API_CLIENT_SECRET` in the *.env* file.). 59 | 60 | > NOTE: You will not be able to access this value later so it is important that you save it. If you missed saving it, you can remove it and create a new secret. 61 | 62 | Create an App registration for Swagger 63 | 64 | 1. Back at **App registrations**, select **New registration** 65 | - For **Name**, enter `TODO-API-SWAGGER` 66 | - For **Supported account types**, select **Accounts in any organizational directory**. 67 | - For **Redirect URI**, select **Single-page application (SPA)** and enter `http://localhost:8000/oauth2-redirect` 68 | 1. Select **Register** to create the application. 69 | 1. On the **Overview** page, look for the **Application (client) ID** value, and then record it for later use. You'll need it to configure the API (that is, `SWAGGER_UI_CLIENT_ID` in the *.env* file). 70 | 71 | ## Step 3: Assign users to roles 72 | 73 | 1. In Azure Portal, find and select **Azure Active Directory** 74 | 1. Under **Manage**, select **Enterprise applications**, and select the `TODO-API` application 75 | 1. Select **Assign users and groups** and then **Add user/group** 76 | 1. Under **Users**, select your own user, and select **Select** to make your choice. 77 | 1. Under **Select a role**, select either `Admin` or `User` and select **Select** to make your choice. Depending on which you choose, you will have access to different API endpoints 78 | 1. Select **Assign** to finish assigning the role to the user. 79 | 80 | ## Step 4: Configure the API 81 | 82 | Configure environment variables 83 | 84 | 1. Copy the `.env.sample` file and rename to `.env` 85 | 1. Set the `API_CLIENT_ID`, `API_CLIENT_SECRET` and `SWAGGER_UI_CLIENT_ID` to the values you gathered above 86 | 1. Set the `AAD_TENANT_ID` to your Azure Tenant ID 87 | 88 | Install required libraries 89 | 90 | 1. Open a command prompt and navigate to the directory where you cloned the repository 91 | 1. Create a new virtual environment to install your python libraries 92 | 93 | ```cmd 94 | python -m venv .venv 95 | ``` 96 | 97 | 1. Activate your virtual environment 98 | 99 | ```cmd 100 | .\.venv\Scripts\activate # Windows 101 | source .venv/Scripts/activate # Linux 102 | ``` 103 | 104 | 1. Upgrade pip to the latest version 105 | 106 | ```cmd 107 | python -m pip install --upgrade pip 108 | ``` 109 | 110 | 1. Install the required libraries `pip install -r requirements.txt` 111 | 112 | ```cmd 113 | pip install -r requirements.txt 114 | ``` 115 | 116 | Open Visual Studio Code and set the interpreter 117 | 118 | 1. In the terminal window, in the project directory, launch visual studio code 119 | 120 | ```cmd 121 | code . 122 | ``` 123 | 124 | 1. Visual Studio Code may recognize that there is a virtual environment and ask you if you want to activate it. If this does not happen, use **View->Command Palette->Python:Select Interpreter** and select the `.venv:venv` interpreter (in rare cases you may need to manually select the `.\.venv\Scripts\python.exe` if Visual Studio Code does not recommend it). 125 | 1. Close down any open terminals and start a new one from **Terminal->New Terminal**. This ensures that any commands you run will be using the new interpreter. 126 | 127 | ## Step 5: Run the API locally 128 | 129 | Run the API from Visual Studio Code 130 | 131 | 1. Open the file `app/main.py` 132 | 1. Press **F5** to run under a debugger (or **CTRL+F5** to run without a debugger) 133 | 1. Under **Debug Configuration** select **Python File** 134 | 135 | This will serve the api locally on your machine. 136 | 137 | > NOTE: The output suggests for you to browse to [http://localhost:8000](http://localhost:8000) - if you browse there you will see {"detail": "Not Found"}, this is normal as we don't have a default endpoint for our API. 138 | 139 | 1. Browse to [http://localhost:8000/health](http://localhost:8000/health) to reach the health endpoint. If all is working correctly, you should be greeted with "OK" 140 | 1. Browse to [http://localhost:8000/docs](http://localhost:8000/docs) to see the Swagger UI and the available endpoints. 141 | 1. Try an endpoint - for example **[GET]/todoitems->Try it out->Execute**. This should result in a `401:Unauthorized` 142 | 143 | Log in to use the API 144 | 145 | 1. Log in using the **Authorize** button at the top right of the page. 146 | - **client_id:** should be pre-filled, leave it as is 147 | - **client_secret:** should be empty, leave it as is 148 | - **scopes:** select the `Access API as user` scope 149 | - Select **Authorize** to log in 150 | 1. Follow the prompts to log in with your account. 151 | 1. In the **Permissions requested** dialog box, check the box to **Consent on behalf of your organization** and select **Accept** - you will only need to consent once for the API. 152 | 1. In the **Available authorizations** dialog box, select **Close** 153 | 154 | Access the **[POST]/todoitems** 155 | 156 | 1. Select the **[POST]/todoitems** endpoint 157 | 1. Select **Try it out**. You can change the request body, and give it another name than "Walk the dog" if you want 158 | 1. Select **Execute** 159 | 1. Verify that you receive a **201** result, and the resulting json for the created item. 160 | 161 | ## How the sample works 162 | 163 | ### The API 164 | 165 | You can find the code for the available routes in `/app/api/routes/api.py` 166 | 167 | | Endpoint | Request Method | Description | Authentication | Auth method | 168 | | -- | -- | -- | -- | -- | 169 | | /health | GET | Get health status | No authentication | 170 | | /todoitems | GET | Get my todo items | User | Depends(get_user) | 171 | | /todoitems | POST | Create todo item | User | Depends(get_user) | 172 | | /todoitems | DELETE | Delete all todo items | Admin | Depends(get_admin_user) | 173 | | /todoitems/{id} | GET | Get todo item | User (owner of item or admin) | Depends(get_todo_item_by_id_from_path) | 174 | | /todoitems/{id} | DELETE | Delete todo item | User (owner of item or admin) | Depends(get_todo_item_by_id_from_path) | 175 | 176 | ### Auth code and dependencies 177 | 178 | FastAPI has a powerful [**Dependency Injection**](https://fastapi.tiangolo.com/tutorial/dependencies/) system, that allows us to enforce security, authentication, role requirements etc. 179 | 180 | In our case, we have created a simple dependency function in `/app/api/dependencies/auth.py` to ensure that the user is logged in for the `GET /todoitems` endpoint for example. 181 | 182 | ```python 183 | def get_user(user: User = Depends(authorize)) -> User: 184 | return user 185 | ``` 186 | 187 | This, in turn, depends on **authorize**, defined in `app/services/AzureADAuthorization.py`. **authorize** is an instance of the **AzureADAuthorization**, that when called (through the `__call__` method) validates and decodes the authentication token against the Azure AD App and required scopes, and further generates a **User** instance based on the contents of the token. 188 | 189 | If the token is invalid, or can't be processed, the **AzureADAuthorization** class returns a **401 UNAUTHORIZED** HTTP status. 190 | 191 | Because the **AzureADAuthorization** class derives from **OAuth2AuthorizationCodeBearer**, FastAPI (and Swagger) understands that the endpoint requires authentication, and displays the padlock in the Swagger UI. 192 | 193 | ### Protecting endpoints 194 | 195 | There are multiple ways to protect the endpoints, and the various endpoints implemented in this sample, show some of these varieties. 196 | 197 | #### Require user to be authenticated 198 | 199 | By passing in `user = Depends(get_user)` as a parameter to our endpoint function, we require the user to be authenticated and also get the user info, so that we can filter the todo items that belong to the user. 200 | 201 | ```python 202 | @router.get('/todoitems', status_code=status.HTTP_200_OK, name="Get My Todos [Admin or Owner of todo]") 203 | async def get_my_todos(user: User = Depends(get_user)) -> TodoItemsInList: 204 | items: List[TodoItem] = todo_repository.get_items_for_user(user) 205 | return TodoItemsInList(items=items) 206 | ``` 207 | 208 | #### Require the user to have the admin role 209 | 210 | We can create more specialized dependency functions, that both validates that the user is authenticated, and validates that the user has the correct role. 211 | 212 | ```python 213 | def get_admin_user(user: User = Depends(authorize)) -> User: 214 | if 'Admin' in user.roles: 215 | return user 216 | raise ForbiddenAccess('Admin privileges required') 217 | ``` 218 | 219 | We can then use the **get_admin_user** dependency function exactly as the **get_user** function. 220 | 221 | The example below shows this usage with a slight modification. If you don't need to use the returned **user** for further processing, you can simply add the dependency to the router decorator. 222 | 223 | ```python 224 | @router.delete('/todoitems', status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_admin_user)], name="Delete all Todos [Admin]") 225 | async def delete_all_todo_items() -> None: 226 | todo_repository.delete_all_items() 227 | ``` 228 | 229 | ##### Create a dependency that retrieves a todo item if the user can access it 230 | 231 | We can also create more intricate dependencies, that don't only validate authorization, but also validate access to items. 232 | 233 | ```python 234 | def get_todo_item_by_id_from_path(id: int = Path(...), user: User = Depends(get_user)): 235 | try: 236 | todo: TodoItem = todo_repository.get_item(id) 237 | except EntityNotFound: 238 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Item does not exist') 239 | 240 | if 'Admin' in user.roles or todo.owner_id == user.id: 241 | return todo 242 | raise HTTPException(status.HTTP_403_FORBIDDEN, detail='User is not allowed to access the item') 243 | ``` 244 | 245 | Because **get_user** indirectly depends on **OAuth2AuthorizationCodeBearer**, and **get_todo_item_by_id_from_path** depends on **get_user**, FastAPI and the Swagger UI will still understand that authorization is required. 246 | 247 | ```python 248 | @router.get('/todoitems/{id}', status_code=status.HTTP_200_OK, name="Get Todo by Id [Admin or Owner of todo]") 249 | async def get_todo_by_id(id: int, todo: TodoItem = Depends(get_todo_item_by_id_from_path)) -> TodoItemInResponse: 250 | return TodoItemInResponse(item=todo) 251 | ``` 252 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TessFerrandez/fastapi_with_aad_auth/2533c12a5196347e556e1c8580fc5da22eaab3ed/app/__init__.py -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TessFerrandez/fastapi_with_aad_auth/2533c12a5196347e556e1c8580fc5da22eaab3ed/app/api/__init__.py -------------------------------------------------------------------------------- /app/api/dependencies/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TessFerrandez/fastapi_with_aad_auth/2533c12a5196347e556e1c8580fc5da22eaab3ed/app/api/dependencies/__init__.py -------------------------------------------------------------------------------- /app/api/dependencies/auth.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi import Depends, HTTPException, status 4 | 5 | from models.domain.User import User 6 | from services.AzureADAuthorization import authorize 7 | 8 | 9 | class ForbiddenAccess(HTTPException): 10 | def __init__(self, detail: Any = None) -> None: 11 | super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail, headers={"WWW-Authenticate": "Bearer"}) 12 | 13 | 14 | def get_user(user: User = Depends(authorize)) -> User: 15 | return user 16 | 17 | 18 | def get_admin_user(user: User = Depends(authorize)) -> User: 19 | if 'Admin' in user.roles: 20 | return user 21 | raise ForbiddenAccess('Admin privileges required') 22 | -------------------------------------------------------------------------------- /app/api/dependencies/db.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends, Path 2 | from fastapi import HTTPException, status 3 | 4 | from api.dependencies.auth import get_user 5 | from db.exceptions import EntityNotFound 6 | from db.repositories.TodoRepository import todo_repository 7 | from models.domain.User import User 8 | from models.domain.TodoItem import TodoItem 9 | 10 | 11 | def get_todo_item_by_id_from_path(id: int = Path(...), user: User = Depends(get_user)): 12 | try: 13 | todo: TodoItem = todo_repository.get_item(id) 14 | except EntityNotFound: 15 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Item does not exist') 16 | 17 | if 'Admin' in user.roles or todo.owner_id == user.id: 18 | return todo 19 | raise HTTPException(status.HTTP_403_FORBIDDEN, detail='User is not allowed to access the item') 20 | -------------------------------------------------------------------------------- /app/api/routes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TessFerrandez/fastapi_with_aad_auth/2533c12a5196347e556e1c8580fc5da22eaab3ed/app/api/routes/__init__.py -------------------------------------------------------------------------------- /app/api/routes/api.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi import APIRouter, Body, Depends, status 4 | 5 | from api.dependencies.db import get_todo_item_by_id_from_path 6 | from api.dependencies.auth import get_user, get_admin_user 7 | from db.repositories.TodoRepository import todo_repository 8 | from models.domain.TodoItem import TodoItem 9 | from models.domain.User import User 10 | from models.schemas.TodoItem import TodoItemInCreate, TodoItemInResponse, TodoItemsInList 11 | 12 | 13 | router = APIRouter() 14 | 15 | 16 | # no authentication needed 17 | @router.get('/health', status_code=status.HTTP_200_OK, name="Get Health Status [NO AUTH REQUIRED]") 18 | async def get_health_status(): 19 | return 'OK' 20 | 21 | 22 | # requires user to be authenticated, only returns items for this user, or all for admin 23 | # that the user is authenticated is verified by Depends(get_user) 24 | @router.get('/todoitems', status_code=status.HTTP_200_OK, name="Get My Todos [Admin or Owner of todo]") 25 | async def get_my_todos(user: User = Depends(get_user)) -> TodoItemsInList: 26 | items: List[TodoItem] = todo_repository.get_items_for_user(user) 27 | return TodoItemsInList(items=items) 28 | 29 | 30 | # requires user to be authenticated 31 | # that the user is authenticated is verified by Depends(get_user) 32 | @router.post('/todoitems', status_code=status.HTTP_201_CREATED, name="Create Todo [User]") 33 | async def create_todo(todo_create: TodoItemInCreate = Body(..., embed=True, alias="item"), user: User = Depends(get_user)) -> TodoItemInResponse: 34 | todo: TodoItem = todo_repository.create_item(todo_create, user) 35 | return TodoItemInResponse(item=todo) 36 | 37 | 38 | # requires user to be admin 39 | # note how we don't need to know who the user is, only that they are admin, so we can add this to the decorator 40 | @router.delete('/todoitems', status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(get_admin_user)], name="Delete all Todos [Admin]") 41 | async def delete_all_todo_items() -> None: 42 | todo_repository.delete_all_items() 43 | 44 | 45 | # requires user to be authenticated and owner of item (or admin) 46 | # This is verified in the get_todo_item_by_id_from_path dependency 47 | @router.get('/todoitems/{id}', status_code=status.HTTP_200_OK, name="Get Todo by Id [Admin or Owner of todo]") 48 | async def get_todo_by_id(id: int, todo: TodoItem = Depends(get_todo_item_by_id_from_path)) -> TodoItemInResponse: 49 | return TodoItemInResponse(item=todo) 50 | 51 | 52 | # requires user to be authenticated and owner of item (or admin) 53 | # This is verified in the get_todo_item_by_id_from_path dependency 54 | @router.delete('/todoitems/{id}', status_code=status.HTTP_204_NO_CONTENT, name="Delete Todo [Admin or Owner of todo]") 55 | async def delete_todo(id: int, todo: TodoItem = Depends(get_todo_item_by_id_from_path)) -> None: 56 | todo_repository.delete_item(todo.id) 57 | -------------------------------------------------------------------------------- /app/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TessFerrandez/fastapi_with_aad_auth/2533c12a5196347e556e1c8580fc5da22eaab3ed/app/core/__init__.py -------------------------------------------------------------------------------- /app/core/config.py: -------------------------------------------------------------------------------- 1 | from starlette.config import Config 2 | 3 | 4 | config = Config(".env") 5 | 6 | API_PREFIX: str = "/api" 7 | VERSION: str = "0.1.0" 8 | PROJECT_NAME: str = "FastAPI with AAD Authentication" 9 | DEBUG: bool = config("DEBUG", cast=bool, default=False) 10 | 11 | # Authentication 12 | API_CLIENT_ID: str = config("API_CLIENT_ID", default="") 13 | API_CLIENT_SECRET: str = config("API_CLIENT_SECRET", default="") 14 | SWAGGER_UI_CLIENT_ID: str = config("SWAGGER_UI_CLIENT_ID", default="") 15 | AAD_TENANT_ID: str = config("AAD_TENANT_ID", default="") 16 | 17 | AAD_INSTANCE: str = config("AAD_INSTANCE", default="https://login.microsoftonline.com") 18 | API_AUDIENCE: str = config("API_AUDIENCE", default=f"api://{API_CLIENT_ID}") 19 | -------------------------------------------------------------------------------- /app/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TessFerrandez/fastapi_with_aad_auth/2533c12a5196347e556e1c8580fc5da22eaab3ed/app/db/__init__.py -------------------------------------------------------------------------------- /app/db/exceptions.py: -------------------------------------------------------------------------------- 1 | class EntityNotFound(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /app/db/repositories/TodoRepository.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | 3 | from db.exceptions import EntityNotFound 4 | from models.domain.TodoItem import TodoItem 5 | from models.domain.User import User 6 | from models.schemas.TodoItem import TodoItemInCreate 7 | 8 | 9 | 10 | class TodoRepository(): 11 | def __init__(self) -> None: 12 | self.max_index: int = 0 13 | self.fake_db: Dict[int, TodoItem] = {} 14 | 15 | def create_item(self, todo_create: TodoItemInCreate, user: User) -> TodoItem: 16 | self.max_index += 1 17 | todo = TodoItem(id=self.max_index, name=todo_create.name, owner_id=user.id, is_complete=False) 18 | self.fake_db[self.max_index] = todo 19 | return todo 20 | 21 | def delete_all_items(self) -> None: 22 | self.fake_db.clear() 23 | self.max_index = 0 24 | 25 | def delete_item(self, id: int) -> None: 26 | if id not in self.fake_db: 27 | raise EntityNotFound 28 | del self.fake_db[id] 29 | 30 | def get_items_for_user(self, user: User) -> List[TodoItem]: 31 | if 'Admin' in user.roles: 32 | return [todo for todo in self.fake_db.values()] 33 | else: 34 | return [todo for todo in self.fake_db.values() if todo.owner_id == user.id] 35 | 36 | def get_item(self, id: int) -> TodoItem: 37 | if id not in self.fake_db: 38 | raise EntityNotFound 39 | return self.fake_db[id] 40 | 41 | 42 | todo_repository = TodoRepository() 43 | -------------------------------------------------------------------------------- /app/db/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TessFerrandez/fastapi_with_aad_auth/2533c12a5196347e556e1c8580fc5da22eaab3ed/app/db/repositories/__init__.py -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from fastapi import FastAPI 3 | 4 | from api.routes.api import router as api_router 5 | from core import config 6 | 7 | 8 | def get_application() -> FastAPI: 9 | application = FastAPI( 10 | title=config.PROJECT_NAME, 11 | debug=config.DEBUG, 12 | version=config.VERSION, 13 | swagger_ui_oauth2_redirect_url='/oauth2-redirect', 14 | swagger_ui_init_oauth={ 15 | "usePkceWithAuthorizationCodeGrant": True, 16 | "clientId": config.SWAGGER_UI_CLIENT_ID, 17 | "scopes": [f'api://{config.API_CLIENT_ID}/access_as_user'] 18 | } 19 | ) 20 | application.include_router(api_router) 21 | return application 22 | 23 | 24 | app = get_application() 25 | 26 | 27 | if __name__ == "__main__": 28 | uvicorn.run("main:app", host="localhost", port=8000, reload=True) 29 | -------------------------------------------------------------------------------- /app/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TessFerrandez/fastapi_with_aad_auth/2533c12a5196347e556e1c8580fc5da22eaab3ed/app/models/__init__.py -------------------------------------------------------------------------------- /app/models/domain/TodoItem.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class TodoItem(BaseModel): 5 | id: int 6 | owner_id: str 7 | name: str 8 | is_complete: bool 9 | -------------------------------------------------------------------------------- /app/models/domain/User.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class User(BaseModel): 7 | id: str 8 | name: str 9 | preferred_username: str 10 | roles: List[str] 11 | -------------------------------------------------------------------------------- /app/models/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TessFerrandez/fastapi_with_aad_auth/2533c12a5196347e556e1c8580fc5da22eaab3ed/app/models/domain/__init__.py -------------------------------------------------------------------------------- /app/models/schemas/TodoItem.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pydantic import BaseModel 4 | 5 | from models.domain.TodoItem import TodoItem 6 | 7 | 8 | class TodoItemInCreate(BaseModel): 9 | name: str 10 | 11 | class Config: 12 | schema_extra = { 13 | "example": { 14 | "name": "Walk the dog" 15 | } 16 | } 17 | 18 | 19 | class TodoItemInResponse(BaseModel): 20 | item: TodoItem 21 | 22 | class Config: 23 | schema_extra = { 24 | "example": { 25 | "item": { 26 | "id": 1, 27 | "name": "Walk the dog", 28 | "is_completed": False, 29 | "owner_id": "933ad738-7265-4b5f-9eae-a1a62928772e" 30 | } 31 | } 32 | } 33 | 34 | 35 | class TodoItemsInList(BaseModel): 36 | items: List[TodoItem] 37 | 38 | class Config: 39 | schema_extra = { 40 | "example": { 41 | "items": [ 42 | { 43 | "id": 1, 44 | "name": "Walk the dog", 45 | "is_completed": False, 46 | "owner_id": "933ad738-7265-4b5f-9eae-a1a62928772e" 47 | }, 48 | { 49 | "id": 2, 50 | "name": "Feed the cat", 51 | "is_completed": False, 52 | "owner_id": "933ad738-7265-4b5f-9eae-a1a62928772e" 53 | }, 54 | ] 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/models/schemas/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TessFerrandez/fastapi_with_aad_auth/2533c12a5196347e556e1c8580fc5da22eaab3ed/app/models/schemas/__init__.py -------------------------------------------------------------------------------- /app/services/AzureADAuthorization.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | import requests 4 | import rsa 5 | from typing import Any, Dict, Mapping, Optional, Union 6 | 7 | from fastapi import HTTPException, Request, status 8 | from fastapi.security import OAuth2AuthorizationCodeBearer 9 | from jose import jwt 10 | from jose.exceptions import ExpiredSignatureError, JWTClaimsError, JWTError 11 | 12 | from core import config 13 | from models.domain.User import User 14 | 15 | 16 | log = logging.getLogger() 17 | 18 | 19 | class InvalidAuthorization(HTTPException): 20 | def __init__(self, detail: Any = None) -> None: 21 | super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail, headers={"WWW-Authenticate": "Bearer"}) 22 | 23 | 24 | class AzureADAuthorization(OAuth2AuthorizationCodeBearer): 25 | # cached AAD jwt keys 26 | aad_jwt_keys_cache: dict = {} 27 | 28 | def __init__(self, aad_instance: str = config.AAD_INSTANCE, aad_tenant: str = config.AAD_TENANT_ID, auto_error: bool = True): 29 | self.scopes = ['access_as_user'] 30 | self.base_auth_url: str = f"{aad_instance}/{aad_tenant}" 31 | super(AzureADAuthorization, self).__init__( 32 | authorizationUrl=f"{self.base_auth_url}/oauth2/v2.0/authorize", 33 | tokenUrl=f"{self.base_auth_url}/oauth2/v2.0/token", 34 | refreshUrl=f"{self.base_auth_url}/oauth2/v2.0/token", 35 | scheme_name="oauth2", 36 | scopes={ 37 | f'api://{config.API_CLIENT_ID}/access_as_user': 'Access API as user', 38 | }, 39 | auto_error=auto_error 40 | ) 41 | 42 | async def __call__(self, request: Request) -> User: 43 | token: str = await super(AzureADAuthorization, self).__call__(request) or '' 44 | self._validate_token_scopes(token) 45 | decoded_token = self._decode_token(token) 46 | return self._get_user_from_token(decoded_token) 47 | 48 | @staticmethod 49 | def _get_user_from_token(decoded_token: Mapping) -> User: 50 | try: 51 | user_id = decoded_token['oid'] 52 | except Exception as e: 53 | logging.debug(e) 54 | raise InvalidAuthorization(detail='Unable to extract user details from token') 55 | 56 | return User( 57 | id=user_id, 58 | name=decoded_token.get('name', ''), 59 | preferred_username=decoded_token.get('preferred_username', ''), 60 | roles=decoded_token.get('roles', []) 61 | ) 62 | 63 | @staticmethod 64 | def _get_validation_options() -> Dict[str, bool]: 65 | return { 66 | 'require_aud': True, 67 | 'require_exp': True, 68 | 'require_iss': True, 69 | 'require_iat': True, 70 | 'require_nbf': True, 71 | 'require_sub': True, 72 | 'verify_aud': True, 73 | 'verify_exp': True, 74 | 'verify_iat': True, 75 | 'verify_iss': True, 76 | 'verify_nbf': True, 77 | 'verify_sub': True, 78 | } 79 | 80 | def _validate_token_scopes(self, token: str): 81 | """ 82 | Validate that the requested scopes are in the tokens claims 83 | """ 84 | try: 85 | claims = jwt.get_unverified_claims(token) or {} 86 | except Exception as e: 87 | log.debug(f'Malformed token: {token}, {e}') 88 | raise InvalidAuthorization('Malformed token received') 89 | 90 | try: 91 | token_scopes = claims.get('scp', '').split(' ') 92 | except: 93 | log.debug(f'Malformed scopes') 94 | raise InvalidAuthorization('Malformed scopes') 95 | 96 | for scope in self.scopes: 97 | if scope not in token_scopes: 98 | raise InvalidAuthorization('Missing a required scope') 99 | 100 | @staticmethod 101 | def _get_key_id(token: str) -> Optional[str]: 102 | headers = jwt.get_unverified_header(token) 103 | return headers['kid'] if headers and 'kid' in headers else None 104 | 105 | @staticmethod 106 | def _ensure_b64padding(key: str) -> str: 107 | """ 108 | The base64 encoded keys are not always correctly padded, so pad with the right number of = 109 | """ 110 | key = key.encode('utf-8') 111 | missing_padding = len(key) % 4 112 | for _ in range(missing_padding): 113 | key = key + b'=' 114 | return key 115 | 116 | def _cache_aad_keys(self) -> None: 117 | """ 118 | Cache all AAD JWT keys - so we don't have to make a web call each auth request 119 | """ 120 | response = requests.get(f"{self.base_auth_url}/v2.0/.well-known/openid-configuration") 121 | aad_metadata = response.json() if response.ok else None 122 | jwks_uri = aad_metadata['jwks_uri'] if aad_metadata and 'jwks_uri' in aad_metadata else None 123 | if jwks_uri: 124 | response = requests.get(jwks_uri) 125 | keys = response.json() if response.ok else None 126 | if keys and 'keys' in keys: 127 | for key in keys['keys']: 128 | n = int.from_bytes(base64.urlsafe_b64decode(self._ensure_b64padding(key['n'])), "big") 129 | e = int.from_bytes(base64.urlsafe_b64decode(self._ensure_b64padding(key['e'])), "big") 130 | pub_key = rsa.PublicKey(n, e) 131 | # Cache the PEM formatted public key. 132 | AzureADAuthorization.aad_jwt_keys_cache[key['kid']] = pub_key.save_pkcs1() 133 | 134 | def _get_token_key(self, key_id: str) -> str: 135 | if key_id not in AzureADAuthorization.aad_jwt_keys_cache: 136 | self._cache_aad_keys() 137 | return AzureADAuthorization.aad_jwt_keys_cache[key_id] 138 | 139 | def _decode_token(self, token: str) -> Mapping: 140 | key_id = self._get_key_id(token) 141 | if not key_id: 142 | raise InvalidAuthorization('The token does not contain kid') 143 | 144 | key = self._get_token_key(key_id) 145 | try: 146 | options = self._get_validation_options() 147 | return jwt.decode(token=token, key=key, algorithms=['RS256'], audience=config.API_AUDIENCE, options=options) 148 | except JWTClaimsError as e: 149 | logging.debug(f'The token has some invalid claims: {e}') 150 | raise InvalidAuthorization('The token has some invalid claims') 151 | except ExpiredSignatureError as e: 152 | logging.debug(f'The token signature has expired: {e}') 153 | raise InvalidAuthorization('The token signature has expired') 154 | except JWTError as e: 155 | logging.debug(f'Invalid token: {e}') 156 | raise InvalidAuthorization('The token is invalid') 157 | except Exception as e: 158 | logging.debug(f'Unexpected error: {e}') 159 | raise InvalidAuthorization('Unable to decode token') 160 | 161 | 162 | authorize = AzureADAuthorization() 163 | -------------------------------------------------------------------------------- /app/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TessFerrandez/fastapi_with_aad_auth/2533c12a5196347e556e1c8580fc5da22eaab3ed/app/services/__init__.py -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi[all]==0.68.0 2 | python-jose[cryptography]==3.3.0 3 | uvicorn==0.13.4 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TessFerrandez/fastapi_with_aad_auth/2533c12a5196347e556e1c8580fc5da22eaab3ed/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TessFerrandez/fastapi_with_aad_auth/2533c12a5196347e556e1c8580fc5da22eaab3ed/tests/conftest.py -------------------------------------------------------------------------------- /tests/test_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TessFerrandez/fastapi_with_aad_auth/2533c12a5196347e556e1c8580fc5da22eaab3ed/tests/test_api/__init__.py --------------------------------------------------------------------------------