Contents

OAuth 2.0 - Tutorial 3 - Expose and Access a Web API - Resource Server

Previous tutorials

In the previous tutorials:

We learned:

  • How to create a “server-side” Python Web App
    • How to deploy this app to Azure
  • How to register the app with Entra ID
  • How to use the Authorization Code Flow to access the Microsoft Graph API.

Overview

In this tutorial:

  • We will learn how to build our own Web API (Resource Server),
  • which can be accessed from our previously created Python Web App.
  • The Web API (Resource Server) will have two different scopes (data.read and data.write) and will validate the token it receives.
  • We will run both apps locally: the Flask WebApp as the client and the FastAPI Web API as the resource server.

Within the OAuth flow, we focus on the Web API component:

/images/authflow-webapi-validates-token.png
Auth Flow - Web API Validates Token

The following image illustrates the Authorization Code Flow with the corresponding OAuth roles, based on section 4.1 of the OAuth 2.0 specification: RFC6749, now including an additional Resource Server (our FastAPI backend API):

/images/oauth-auth-code-grant-entraid-fastapi.png

Note: The lines illustrating steps (A), (B), and (C) are broken into two parts as they pass through the user-agent.

What we need:

  • An additional App Registration for the Web API (Resource Server) app, which will expose the API
  • Python FastAPI code for our Web API (run locally)
  • An updated Python Flask Web App (with links to our Web API)
  • Permissions for the Python Flask app registration to access the Web API

The following quickstart guide from Microsoft Docs can also be used as a reference.

Prerequisites

  • Tutorial 1 and optional 2 finished
  • Microsoft Azure Tenant
  • UV installed: UV Installation

Register an application for your Web API

This time, let’s start by registering your app in Entra ID:

/images/register-fastapi-webapi.png
Preview

Prepare FastAPI Web API Code

Now we will create a simple Web API with two endpoints and two scopes: one for “reading data” and one for “writing data”.

Open a console window, create and navigate to your FastAPI app folder:

cd fastapi_app

Create a new UV project and install the necessary dependencies:

uv init
uv add fastapi uvicorn[standard] pydantic pwdlib[argon2] python-multipart python-jose
# Activate the virtual environment with those dependencies installed:
source .venv/bin/activate

Then, add the following code to main.py:

# Type hints and annotation support
from typing import Annotated

# FastAPI and related imports
from fastapi import Depends, FastAPI, HTTPException, Security, status
from fastapi.security import OAuth2AuthorizationCodeBearer, SecurityScopes
# JWT decoding
from jose import jwt
# Data validation
from pydantic import BaseModel


# Entra ID config
TENANT_ID = "<ADD-YOUR-ENTRA-ID-TENANT-ID>" #TODO
OPENID_CONFIG_URL = f"https://login.microsoftonline.com/{TENANT_ID}/v2.0/.well-known/openid-configuration"
API_AUDIENCE = "<ADD-YOUR-API-AUDIENCE>" # First Expose Web API to get your API Audience #TODO
ALGORITHM = "RS256"


import requests

# Fetch JWKS URL from OpenID configuration at startup
# This gets the public keys used to verify JWTs
resp = requests.get(OPENID_CONFIG_URL)
resp.raise_for_status()
JWKS_URL = resp.json()["jwks_uri"]

# Get the JSON Web Key Set (JWKS) from Entra ID
def get_jwks():
    resp = requests.get(JWKS_URL)
    resp.raise_for_status()
    return resp.json()["keys"]

# Find the public key in JWKS that matches the JWT's key ID (kid)
def get_public_key(token):
    jwks = get_jwks()
    unverified_header = jwt.get_unverified_header(token)
    for key in jwks:
        if key["kid"] == unverified_header["kid"]:
            return key
    raise Exception("Public key not found.")

# Pydantic model for token data
class TokenData(BaseModel):
    username: str | None = None  # Username from token (not used currently)
    scopes: list[str] = []  # List of scopes from token

# OAuth2 scheme for FastAPI security
oauth2_scheme = OAuth2AuthorizationCodeBearer(
    authorizationUrl=f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/authorize",  # Entra ID auth URL
    tokenUrl=f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token",  # Entra ID token URL
    scopes={
        "data.read": "Read protected data.",
        "data.write": "Write protected data.",
    },
)

# Create FastAPI app
app = FastAPI()

# Dependency to get the current user from the JWT token
# Checks for required scopes and validates the token
async def get_current_user(
    security_scopes: SecurityScopes, token: Annotated[str, Depends(oauth2_scheme)]
):
    # Build the authenticate header value for WWW-Authenticate
    if security_scopes.scopes:
        authenticate_value = f'Bearer scope="{security_scopes.scope_str}"'
    else:
        authenticate_value = "Bearer"
    # Exception to raise if authentication fails (invalid, missing, or expired token)
    # This is required for proper OAuth2 error signaling, regardless of grant type.
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": authenticate_value},
    )
    try:
        # Get public key and decode JWT
        public_key = get_public_key(token)
        payload = jwt.decode(
            token,
            public_key,
            algorithms=[ALGORITHM],
            audience=API_AUDIENCE,
            options={"verify_exp": True, "verify_aud": True},
        )
        # Extract scopes from token payload
        scope: str = payload.get("scp", "") or payload.get("scope", "")
        token_scopes = scope.split(" ")
        token_data = TokenData(scopes=token_scopes)
    except Exception:
        # Token is invalid or cannot be decoded
        raise credentials_exception
    # Check if all required scopes are present in the token
    for scope in security_scopes.scopes:
        if scope not in token_data.scopes:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Not enough permissions",
                headers={"WWW-Authenticate": authenticate_value},
            )
    return token_data



# Protected endpoint: requires 'data.read' scope
@app.get("/data/read")
async def read_data(
    current_user: Annotated[
        TokenData, Security(get_current_user, scopes=["data.read"])
    ],
):
    """Return protected data if user has 'data.read' scope."""
    return {
        "data": "This is protected data you can read.",
        "current-user-scopes": current_user.scopes,
    }

# Protected endpoint: requires 'data.write' scope
@app.post("/data/write")
async def write_data(
    current_user: Annotated[
        TokenData, Security(get_current_user, scopes=["data.write"])
    ],
):
    """Allow writing protected data if user has 'data.write' scope."""
    return {"result": "You wrote protected data!", "current-user-scopes": current_user.scopes}

The OAuth implementation is based on the following FastAPI documentation: https://fastapi.tiangolo.com/advanced/security/oauth2-scopes/?h=scopes

Instead of using OAuth2PasswordBearer for a simple username/password flow, we use OAuth2AuthorizationCodeBearer to enable the Authorization Code Flow.

You must replace the placeholder values marked with #TODO with your own configuration.

Expose the API

Add a Scope to Your API

To expose your API, you need to define scopes in your app registration.

/images/myfastapi-webapi-add-scope1.png
Add Scope - Step 1
Let’s create two scopes data.read and data.write:
/images/MyFastAPI-WebAPI-add-scope2.png
Add Scope - Step 2
/images/myfastapi-webapi-add-scope3.png
Add Scope - Step 3

Now we have the backend FastAPI code ready, the Web API registered as an app, and the Web API exposed. Next, let’s update the Flask Web App code to add links that will call the backend API and use the new scopes.

Update Python Web App Flask code

Update the Flask Web App code from the previous tutorials. (Additionally, install Flask-Session to store session data in server-side storage.)

from flask import Flask, session, redirect, request, url_for
from flask_session import Session
from dotenv import load_dotenv
import msal
import uuid
import os
import requests

app = Flask(__name__)
app.secret_key = os.urandom(24)  # Needed for session
# Configure Flask-Session to use filesystem (server-side session)
app.config["SESSION_TYPE"] = "filesystem"
app.config["SESSION_PERMANENT"] = False
app.config["SESSION_USE_SIGNER"] = True
Session(app)
# Specify the path to the .env file
dotenv_path = ".env"  # Update this with the actual path

# Load the .env file
load_dotenv(dotenv_path=dotenv_path)

# Config
config = {
    "client_id": os.getenv("CLIENT_ID"),
    "authority": f"https://login.microsoftonline.com/{os.getenv('TENANT_ID')}",
    # Add FastAPI API scopes for read and write
    "scope": [
        "User.Read",
        #TODO - replace with your api scopes
        "api://c750c37c-6c67-4211-a9e2-6f035a897cfa/data.read", 
        "api://c750c37c-6c67-4211-a9e2-6f035a897cfa/data.write",
    ],
    "redirect_uri": os.getenv("REDIRECT_URI"),
    "client_secret": os.getenv("CLIENT_SECRET"),
}


def build_msal_app():
    return msal.ConfidentialClientApplication(
        client_id=config["client_id"],
        authority=config["authority"],
        client_credential=config["client_secret"],
    )

# On the index page, add simple links to our backend FastAPI endpoints
@app.route("/")
def index():
    if "user" in session:
        user = session["user"]
        return f"""
        <h2>Welcome {user['displayName']}</h2>
        <p>Email: {user.get('mail') or user.get('userPrincipalName')}</p>
    <a href="/logout">Logout</a><br>
    <a href="/api/read">Read Protected Data (FastAPI)</a><br>
    <a href="/api/write">Write Protected Data (FastAPI)</a>
        """
    return '<a href="/login">Login with Microsoft</a>'

@app.route("/api/read")
def api_read():
    api_access_token = session.get("api_access_token")
    if not api_access_token:
        return redirect(url_for("login"))
    # Call the FastAPI backend read endpoint
    api_url = "http://localhost:8000/data/read"
    resp = requests.get(api_url, headers={"Authorization": f"Bearer {api_access_token}"})
    if resp.ok:
        return f"<pre>{resp.json()}</pre><a href='/'>Back</a>"
    return f"Error calling API: {resp.status_code} {resp.text} <a href='/'>Back</a>", 500


@app.route("/api/write", methods=["GET", "POST"])
def api_write():
    api_access_token = session.get("api_access_token")
    if not api_access_token:
        return redirect(url_for("login"))
    # Call the FastAPI backend write endpoint
    api_url = "http://localhost:8000/data/write"
    resp = requests.post(api_url, headers={"Authorization": f"Bearer {api_access_token}"})
    if resp.ok:
        return f"<pre>{resp.json()}</pre><a href='/'>Back</a>"
    return f"Error calling API: {resp.status_code} {resp.text} <a href='/'>Back</a>", 500


@app.route("/login")
def login():
    msal_app = build_msal_app()
    flow = msal_app.initiate_auth_code_flow(
        scopes=config["scope"],
        redirect_uri=config["redirect_uri"],
        state=str(uuid.uuid4()),
    )
    session["auth_flow"] = flow
    return redirect(flow["auth_uri"])


@app.route("/callback")
def callback():
    msal_app = build_msal_app()
    try:
        result = msal_app.acquire_token_by_auth_code_flow(
            session.get("auth_flow", {}), request.args
        )
    except ValueError:
        return "Authentication failed", 400

    if "access_token" in result:
        session["access_token"] = result["access_token"]  # This is likely a Graph token

        # Call Microsoft Graph to get user info
        graph_resp = requests.get(
            "https://graph.microsoft.com/v1.0/me",
            headers={"Authorization": f"Bearer {result['access_token']}"},
        )
        if graph_resp.ok:
            session["user"] = graph_resp.json()
        else:
            return f"Graph API error: {graph_resp.text}", 500

        # Now acquire a token for the FastAPI API (audience = API)
        account = msal_app.get_accounts()[0] if msal_app.get_accounts() else None
        api_scopes = [
            #TODO - replace with your api scopes
            "api://c750c37c-6c67-4211-a9e2-6f035a897cfa/data.read",
            "api://c750c37c-6c67-4211-a9e2-6f035a897cfa/data.write",
        ]
        api_token_result = msal_app.acquire_token_silent(api_scopes, account=account)
        if not api_token_result:
            # If silent fails, use refresh token if available
            api_token_result = msal_app.acquire_token_by_authorization_code(
                request.args.get("code"),
                scopes=api_scopes,
                redirect_uri=config["redirect_uri"],
            )
        if "access_token" in api_token_result:
            session["api_access_token"] = api_token_result["access_token"]
        else:
            return f"API token error: {api_token_result.get('error_description')}", 400

        return redirect(url_for("index"))

    return f"Error: {result.get('error_description')}", 400


@app.route("/logout")
def logout():
    session.clear()
    return redirect(url_for("index"))


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5001, debug=True)

Add permissions to Web App

In Entra ID, ensure your Flask app registration has permissions to request those scopes (as “delegated permissions” to your API).

/images/request-api-permissions-fastapibackend-from-app.png
Request API Permissions for FastAPI Backend from App

/images/request-api-permissions-delegated-datasample.png
Request API Permissions - Delegated Data Sample
/images/myconfidentalserverapp-api-permissions.png
My Confidential Server App - API Permissions

Start FastAPI and Flask App locally

Start your FastAPI Web API app locally:

uvicorn main:app --host=0.0.0.0 --port=8000

Then start your Flask App locally:

flask --app main.py run --host=0.0.0.0 --port=5001

Then log in:

/images/login-localhost.png
Login Localhost
Accept the required permissions:
/images/consent-permissions-requested.png
Consent - Permissions Requested
/images/consent-premissions-requested-2.png
Consent - Permissions Requested 2
Now, a simple page appears where you can click on Read or Write protected data:
/images/sample-page-with-read-write-api-urls.png
Sample Page with Read/Write API URLs

After clicking on the “Read Protected Data (FastAPI)” link, the following page opens, showing the data you can read and the scopes assigned to the current user:

/images/sample-page-read-api.png
Sample Page - Read API Result

That’s it for now.