├── .gitignore ├── Dockerfile ├── Makefile ├── README.md ├── app ├── __init__.py ├── main.py └── ms_utils.py ├── app_config.py ├── docker-compose-production.yml ├── requirements.txt └── templates ├── auth_error.html ├── display.html ├── index.html └── login.html /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | .idea 3 | octopus-data-gcp.json 4 | .env 5 | app/__pycache__ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM tiangolo/uvicorn-gunicorn-fastapi:python3.7 2 | 3 | COPY ./app /app 4 | 5 | COPY ./requirements.txt /app/requirements.txt 6 | 7 | RUN pip install -r requirements.txt -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | uvicorn: 2 | uvicorn app.main:app --reload --port 9000 3 | 4 | docker: 5 | docker build -t fast_api_boilerplate . && docker run -d -v app:/app -p 9000:80 fast_api_boilerplate -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MS Graph API authentication example with Fast API 2 | 3 | ## What it is & does 4 | 5 | This is a simple python service/webapp, using [FastAPI](https://fastapi.tiangolo.com/) with server side rendering, that uses the Microsoft [MSAL](https://github.com/AzureAD/microsoft-authentication-library-for-python) library for SSO auth with Azure. 6 | The app handles performing the redirect and handshake for SSO, fetching the JWT(s), and allowing authorized http requests to the MS GraphAPI on behalf 7 | the given user. 8 | 9 | ## Why does this exist? 10 | 11 | The quickstart guide for python and Azure SSO is based on Flask. There are some interesting caveats to using the MSAL library with FastAPI instead of Flask. 12 | Thus I thought it useful to setup a simple & functional FastAPI app that works properly. 13 | 14 | > Note that I think FastAPI is really awesome! This demo shows off some use of its concurrency (async/await), as well as a simple & elegant mem-cache 15 | > as a session mechanism. The FastAPI [cache](https://github.com/long2ice/fastapi-cache) package is easy to use, configure, and supports redis 16 | > (with very little code change) - so setting up a distributed, and shared cache pool has never been easier. 17 | 18 | ## requirements 19 | - install gnu `make` 20 | - the MSAL python package - installation is documented in the article link below. 21 | - follow setup guide here for Azure [here](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-v2-python-webapp) 22 | 23 | 24 | ## Setup 25 | For simplicity I just used a virtualenv, and changes shouldn't need to be made but may be 26 | for caching with docker - would be easy to setup redis here though and reconfigure this to 27 | use redis shared cache. 28 | 29 | ### Using virtual environment: 30 | 31 | Assuming python 3 - I used 3.7 for building and have python version 3 aliased to `python3` 32 | Thus, if `python` is not version 3 or greater, best to use brew and install newer version and 33 | replace `python` below with `python3` 34 | 35 | Create Env 36 | ```bash 37 | $ python -m venv venv 38 | ``` 39 | 40 | Activate venv 41 | ```bash 42 | $ . venv/bin/activate 43 | ``` 44 | 45 | Install requirements 46 | ```bash 47 | $ pip install -r requirements.txt 48 | ``` 49 | 50 | Run the app 51 | 52 | ```bash 53 | $ make uvicorn 54 | ``` 55 | 56 | Open your browser and point to `localhost:9000` 57 | 58 | ### Using docker: 59 | `make docker` 60 | 61 | The api runs on port 9000 - ensure you use localhost and configure your redirects properly based on the 62 | setup guide. 63 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AndrewJHart/Azure-FastAPI-Auth-Example/a2dee717c7676d2b11ed8692741f2683926aa7af/app/__init__.py -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Depends, Request 2 | from fastapi.responses import RedirectResponse, HTMLResponse 3 | from fastapi_cache import caches, close_caches 4 | from fastapi_cache.backends.memory import CACHE_KEY, InMemoryCacheBackend 5 | from fastapi.templating import Jinja2Templates 6 | from app.ms_utils import load_cache, save_cache, build_msal_app, build_auth_code_flow, get_token_from_cache 7 | import app_config 8 | import requests 9 | 10 | # instantiate app 11 | app = FastAPI() 12 | 13 | # configure template engine 14 | templates = Jinja2Templates(directory='templates') 15 | 16 | # default ttl for mem cache 17 | default_ttl = 10*60*60 18 | 19 | 20 | def mem_cache(): 21 | """ 22 | sets default cache key for group 23 | """ 24 | return caches.get(CACHE_KEY) 25 | 26 | 27 | @app.on_event('startup') 28 | async def on_startup() -> None: 29 | """ 30 | simple event listener to configure & init local mem-cache 31 | 32 | :note: Redis would be perfect for this and this supports Redis or mem-cache 33 | :return: None 34 | """ 35 | mem = InMemoryCacheBackend() 36 | caches.set(CACHE_KEY, mem) 37 | 38 | 39 | @app.get('/') 40 | async def index( 41 | request: Request, 42 | session_cache: InMemoryCacheBackend = Depends(mem_cache) 43 | ): 44 | # check cache for user 45 | user = await session_cache.get('user') 46 | 47 | if user: 48 | # user authenticated, render index with proper context 49 | return templates.TemplateResponse( 50 | 'index.html', 51 | {'request': request, 'user': user, 'endpoint': True} 52 | ) 53 | 54 | # otherwise, no user/token so redirect to login page 55 | return RedirectResponse(app.url_path_for('login')) 56 | 57 | 58 | @app.get('/login', response_class=HTMLResponse) 59 | async def login( 60 | request: Request, 61 | session_cache: InMemoryCacheBackend = Depends(mem_cache) 62 | ): 63 | # Technically we could use empty list [] as scopes to do just sign in, 64 | # here we choose to also collect end user consent upfront 65 | flow = build_auth_code_flow(app, scopes=app_config.SCOPE) 66 | 67 | await session_cache.set('flow', flow, ttl=default_ttl) # cache the flow for 10 hours or until app restarts 68 | 69 | cached_flow = await session_cache.get('flow') 70 | 71 | return templates.TemplateResponse( 72 | 'login.html', 73 | {'request': request, 'auth_url': cached_flow['auth_uri']} 74 | ) 75 | 76 | 77 | @app.get('/oauth') # Its absolute URL must match your app's redirect_uri set in AAD 78 | async def authorized( 79 | request: Request, 80 | session_cache: InMemoryCacheBackend = Depends(mem_cache) 81 | ): 82 | try: 83 | cache = await load_cache(session_cache) # pass our mem-cache as a session 84 | cached_flow = await session_cache.get('flow') 85 | 86 | # build the app config, pass cache store for success & pass the cached flow generated earlier 87 | # as well convert the query params to a dict (from the 3 way handshake that returns the code, etc...) 88 | result = build_msal_app( 89 | cache=cache 90 | ).acquire_token_by_auth_code_flow( 91 | cached_flow, 92 | dict(request.query_params) 93 | ) 94 | 95 | if 'error' in result: 96 | return templates.TemplateResponse( 97 | 'auth_error.html', 98 | {'request': request, 'result': result} 99 | ) 100 | 101 | # auth was successful, set the user cache info & use this chance 102 | # to grab any other data you would need if you wanted to wrap 103 | # this in your own JSON Web Token 104 | await session_cache.set('user', result.get('id_token_claims'), ttl=default_ttl) 105 | await save_cache(cache, session_cache) 106 | except ValueError: # Usually caused by CSRF 107 | pass # ugh, ignore this. I don't like this Rich... Microsoft 108 | 109 | return RedirectResponse(app.url_path_for('index')) # redirect back to index / 110 | 111 | 112 | @app.get('/logout') 113 | def logout( 114 | session_cache: InMemoryCacheBackend = Depends(mem_cache) 115 | ): 116 | session_cache.flush() # flush out the mem-cache 117 | 118 | # construct the url to hit MS & logout user then redirect back to index page 119 | logout_redirect = '{authority}/oauth2/v2.0/logout?post_logout_redirect_uri={host}{index_page}'.format( 120 | authority=app_config.AUTHORITY, 121 | host=app_config.HOST_URL, 122 | index_page=app.url_path_for('index') 123 | ) 124 | 125 | return RedirectResponse(logout_redirect) 126 | 127 | 128 | @app.get('/graphcall') 129 | async def graphcall( 130 | request: Request, 131 | session_cache: InMemoryCacheBackend = Depends(mem_cache) 132 | ): 133 | # attempt to fetch the token from our mem-cache 134 | token = await get_token_from_cache(session_cache, app_config.SCOPE) 135 | 136 | # uncomment to print the full token info 137 | # print(token) 138 | 139 | if not token: 140 | return RedirectResponse(app.url_path_for('login')) 141 | 142 | # use requests to relay api call from backend service to another service 143 | graph_data = requests.get( 144 | app_config.ENDPOINT, 145 | headers={'Authorization': 'Bearer ' + token['access_token']}, 146 | ).json() 147 | 148 | return templates.TemplateResponse('display.html', {'request': request, 'result': graph_data}) 149 | -------------------------------------------------------------------------------- /app/ms_utils.py: -------------------------------------------------------------------------------- 1 | import app_config 2 | import msal 3 | 4 | # default ttl for mem cache 5 | default_ttl = 10*60*60 6 | 7 | 8 | async def load_cache(session_cache): 9 | cache = msal.SerializableTokenCache() 10 | 11 | token_cache = await session_cache.get('token_cache') 12 | 13 | if token_cache: 14 | cache.deserialize(token_cache) 15 | 16 | return cache 17 | 18 | 19 | async def save_cache(cache, session_cache): 20 | if cache.has_state_changed: 21 | await session_cache.set('token_cache', cache.serialize(), ttl=default_ttl) 22 | 23 | 24 | def build_msal_app(cache=None, authority=None): 25 | return msal.ConfidentialClientApplication( 26 | app_config.CLIENT_ID, 27 | authority=authority or app_config.AUTHORITY, 28 | client_credential=app_config.CLIENT_SECRET, 29 | token_cache=cache 30 | ) 31 | 32 | 33 | def build_auth_code_flow(app, authority=None, scopes=None): 34 | """ 35 | Must build full url here - suggest using config for varying environments but 36 | fastApi does not use the full url for url_path_for 37 | 38 | :note: There is a better way using the app to get base url - this is a hack in app_config (HOST_URL) 39 | 40 | :param app: instance of the Fast API app 41 | :param authority: 42 | :param scopes: 43 | :return: 44 | """ 45 | redirect_url = str.format( 46 | '{host_url}{authorized}', 47 | host_url=app_config.HOST_URL, 48 | authorized=app.url_path_for('authorized') 49 | ) 50 | 51 | return build_msal_app(authority=authority).initiate_auth_code_flow( 52 | scopes or [], 53 | redirect_uri=redirect_url 54 | ) 55 | 56 | 57 | async def get_token_from_cache(session_cache, scope=None): 58 | # This web app maintains one cache per session 59 | cache = await load_cache(session_cache) 60 | cca = build_msal_app(cache=cache) 61 | accounts = cca.get_accounts() 62 | 63 | # uncomment to print all accounts 64 | # print('accounts are: ', accounts) 65 | 66 | if accounts: # So all account(s) belong to the current signed-in user 67 | result = cca.acquire_token_silent(scope, account=accounts[0]) 68 | await save_cache(cache, session_cache) 69 | return result 70 | -------------------------------------------------------------------------------- /app_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | CLIENT_ID = "" # Application (client) ID of app registration 4 | 5 | CLIENT_SECRET = "" # Placeholder - for use ONLY during testing. 6 | # In a production app, we recommend you use a more secure method of storing your secret, 7 | # like Azure Key Vault. Or, use an environment variable as described in Flask's documentation: 8 | # https://flask.palletsprojects.com/en/1.1.x/config/#configuring-from-environment-variables 9 | # CLIENT_SECRET = os.getenv("CLIENT_SECRET") 10 | # if not CLIENT_SECRET: 11 | # raise ValueError("Need to define CLIENT_SECRET environment variable") 12 | 13 | AUTHORITY = "https://login.microsoftonline.com/07a204b0-4ada-44d7-8ebd-9d168f38a08c" # For multi-tenant app 14 | # AUTHORITY = "https://login.microsoftonline.com/Enter_the_Tenant_Name_Here" 15 | 16 | HOST_URL = "http://localhost:9000" 17 | REDIRECT_PATH = "/oauth" # Used for forming an absolute URL to your redirect URI. 18 | # The absolute URL must match the redirect URI you set 19 | # in the app's registration in the Azure portal. 20 | 21 | # You can find more Microsoft Graph API endpoints from Graph Explorer 22 | # https://developer.microsoft.com/en-us/graph/graph-explorer 23 | ENDPOINT = 'https://graph.microsoft.com/v1.0/users' # This resource requires no admin consent 24 | 25 | # You can find the proper permission names from this document 26 | # https://docs.microsoft.com/en-us/graph/permissions-reference 27 | SCOPE = ["User.ReadBasic.All"] 28 | 29 | SESSION_TYPE = "filesystem" # Specifies the token cache should be stored in server-side session 30 | -------------------------------------------------------------------------------- /docker-compose-production.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | web: 5 | image: fast_api_boilerplate_production:latest 6 | build: 7 | context: . 8 | dockerfile: Dockerfile.production 9 | container_name: fast_api_boilerplate_production 10 | # expose: 11 | # - 80 12 | env_file: 13 | - .env 14 | command: bash -c "uvicorn app.main:app --host 0.0.0.0 --port 80" 15 | ports: 16 | - "80:80" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiofiles==0.5.0 2 | aioredis==1.3.1 3 | aniso8601==7.0.0 4 | async-exit-stack==1.0.1 5 | async-generator==1.10 6 | async-timeout==4.0.2 7 | cachelib==0.4.1 8 | certifi==2021.10.8 9 | cffi==1.15.0 10 | charset-normalizer==2.0.9 11 | click==7.1.2 12 | cryptography==36.0.1 13 | dnspython==2.1.0 14 | email-validator==1.1.3 15 | fastapi==0.61.1 16 | fastapi-cache==0.1.0 17 | fastapi-sessions==0.3.2 18 | graphene==2.1.9 19 | graphql-core==2.3.2 20 | graphql-relay==2.0.1 21 | h11==0.9.0 22 | hiredis==2.0.0 23 | httptools==0.1.1 24 | idna==3.3 25 | itsdangerous==1.1.0 26 | Jinja2==2.11.3 27 | MarkupSafe==2.0.1 28 | msal==1.16.0 29 | orjson==3.6.5 30 | promise==2.3 31 | pycparser==2.21 32 | pydantic==1.6.1 33 | PyJWT==2.3.0 34 | python-multipart==0.0.5 35 | PyYAML==5.4.1 36 | requests==2.26.0 37 | Rx==1.6.1 38 | six==1.16.0 39 | starlette==0.13.6 40 | typing-extensions==4.0.1 41 | ujson==3.2.0 42 | urllib3==1.26.7 43 | uvicorn==0.11.8 44 | uvloop==0.14.0 45 | websockets==8.1 46 | -------------------------------------------------------------------------------- /templates/auth_error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% if config.get("B2C_RESET_PASSWORD_AUTHORITY") and "AADB2C90118" in result.get("error_description") %} 7 | 8 | 9 | {% endif %} 10 | 11 | 12 |

Login Failure

13 |
14 |
{{ result.get("error") }}
15 |
{{ result.get("error_description") }}
16 |
17 |
18 | Homepage 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /templates/display.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Back 8 |

Graph API Call Result

9 |
{{ result |tojson(indent=4) }}
10 | 11 | 12 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Microsoft Identity Python Web App

8 |

Welcome {{ user.get("name") }}!

9 | 10 | {% if endpoint %} 11 |
  • Call Microsoft Graph API
  • 12 | {% endif %} 13 | 14 |
  • Logout
  • 15 |
    16 |
    Powered by MSAL Python
    17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

    Microsoft Identity Python Web App

    8 | 9 |
  • Sign In
  • 10 | 11 |
    12 | 13 | 14 | 15 | 16 | --------------------------------------------------------------------------------