AzureAD / microsoft-authentication-library-for-python

Microsoft Authentication Library (MSAL) for Python makes it easy to authenticate to Microsoft Entra ID. General docs are available here https://learn.microsoft.com/entra/msal/python/ Stable APIs are documented here https://msal-python.readthedocs.io. Questions can be asked on www.stackoverflow.com with tag "msal" + "python".
https://stackoverflow.com/questions/tagged/azure-ad-msal+python
Other
754 stars 191 forks source link

Question: Intended use of MSAL for Auth Code Flow for OpenID Connect (OIDC) with ID Tokens (FastAPI ConfidentialClient example) #711

Closed madsruben closed 2 weeks ago

madsruben commented 2 weeks ago

Hello. First of all, thank you for supporting developers in integrating with the Microsoft ecosystem.

We're looking to validate some assumptions on how to use msal.

We have an SPA with FastAPI as the backend, and would like to offer M365 users to log in to the platform using their existing credentials. There are a lot of code examples around, but we weren't really able to find a clear guide on how you are supposed to use the library.

We want to use OpenID Connect to get user information from the Identity Provider (Entra) for their first-time registration and later sign-in.

Our understanding is that using the Implicit Grant Flow to retrieve ID Tokens has been deprecated, and as our FastAPI backend represents a ConfidentialClient, we are using the Authorization Code Flow.

from fastapi import FastAPI, Request, HTTPException
import msal
from fastapi.responses import RedirectResponse

oauthconf = {
    'entra_org_id': 'xyz',
    'entra_client_id': 'app_id',
    'entra_client_secret': 'app_secret',
}

app = FastAPI(
    title='FastAPI-msal',
)

msal_app = msal.ConfidentialClientApplication(
    # authority=f"https://login.microsoftonline.com/{oauthconf.get('entra_org_id')}",
    authority=f"https://login.microsoftonline.com/organizations", # allow users from any M365 organization 
    client_id=oauthconf.get('entra_client_id'),
    client_credential=oauthconf.get('entra_client_secret'), # credential could also have been a certificate.
)

flow = None
# Initial endpoint, where a new or existing user would be sent to initate a new auth flow
@app.get('/api/oauthlogin')
async def oauthLogin(request: Request):
    # Initate a new auth code flow and redirect the user to the identity provider for consent.

    redirect_uri = 'http://localhost:8080/api/oauthtoken'
    # Where the user will be redirected back to later, after completing the Identity Provider login and consent steps.
    # Has to be registered in the App Registration in Entra.

    global flow
    # The `flow` is required in the following step, when the user is redirected back to us from the identity 
    # provider after providing consent, for which they will arrive at the redirect_uri on /api/oauthtoken.
    # 
    # We use a global var here for simplicity, but the flow is unique per user request and contains the nonce
    # and PKCE/code_challenge, so we need to retrieve the correct flow for the correct user session.

    flow = msal_app.initiate_auth_code_flow(
        # for openid connect, we would specify the scopes 'openid' and 'profile'. Since those are the defaults, 
        # it would be nice if it was more obvious that we can provide an empty list in order to initiate an oidc flow. 
        # If we provide them manually, an exception is raised.
        scopes=[], 
        redirect_uri=redirect_uri,
        # already includes code_verifier['pkce'] for PKCE.
    )

    # We initially tried building the auth request url to redirect the user with the following;
    # however, this function does not add PKCE and the redirect fails. It would work for Implicit Grant.
    # resp_url = msal_app.get_authorization_request_url(
    #     scopes=[], # ['openid']
    #     # nonce=nonce,
    #     state=flow.get('state'),
    # )

    # Apparently the auth request url has already been generated for us and is available in the flow.
    resp_url = flow.get('auth_uri')

    # Redirect the User's Web Browser and wait for them to come back to /api/oauthtoken
    return RedirectResponse(resp_url, status_code=302)

At this point, we're waiting for the user to come back, to the following endpoint:

# Handle a user that comes back after having logged in and consented with Entra.
@app.get('/api/oauthtoken')
def oauthToken(request: Request, code: str, state: str, session_state: str):
    # not async because of sync msal library, will be handled in FastAPI's threadpool.

    result = None
    try:
        auth_resp = {
            'code': code,
            'state': state,
            'session_state': session_state,
        },

        # see how to retrieve the flow state at a later point. as we have multiple FastAPI backends in parallell, 
        # we probably have to serialize the flow or key flow settings and store it in the db for later retrieval based on the nonce.
        global flow 

        # If we had used Implicit Grant to retrieve an ID Token, the auth_resp would already have included all the user
        # information at this point, and we would have to validate the token signature by retrieving Entra's 
        # /.well-known/openid-configuration, and refer jwks.json in it (their list of signing keys), find the 
        # correct key and validate the token signature.
        # 
        # However, since we're using authorization_code_flow, we use the following function to complete the flow.
        # It will take the code from the user, and make another HTTP request to Entra using this code to
        # generate an authorization_code and provide us with the user's profile (claims). As Entra's server is validating
        # the code and will not give us anything if the code is invalid, there is no need for us to validate the code ourselves.
        # 
        # Additional profile information from OptionalClaims, if configured, will be included in the returned token.
        # (see https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims?tabs=appui#optional-claims-example
        # and https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference#v20-specific-optional-claims-set)

        result = msal_app.acquire_token_by_auth_code_flow(
            auth_code_flow=flow,
            auth_response=auth_resp,
        )

        if "error" in result:
            raise HTTPException(status_code=401, detail='Invalid authentication token')

        # Retrieve the claims from the decoded and verified token
        # (includes keys for optional ID token claims that have to be configured in the App Manifest in Entra).
        if claims := result.get('id_token_claims'):
            fullname = claims.get('name')
            preferred_username = claims.get('preferred_username')
            oid = claims.get('oid')
            upn = claims.get('upn')

            # Code to create user if not exists
            # Code to persist access for the logged in user:
            # - redirect the user to obtain an OAuth access token for our system
            # - act as another OAuth provider and grant the user an OAuth access token
            # - generate our own jwt access token for the user, or set a session cooke, et.al
            # Code to redirect user to initially requested page or homepage.

    except Exception as err:
        raise HTTPException(status_code=401, detail='Invalid authentication token')

    return {"status": 1, "message": "success"}

Would you be able to please confirm whether our assumptions are correct and that this is the intended way to use the library?

Thank you.

rayluo commented 2 weeks ago

Your direction is correct, initiate_auth_code_flow() and acquire_token_by_auth_code_flow() are the way to go, IF you are going to build the auth-for-web directly on top of MSAL. Just keep in mind that MSAL is a lower-than-web-level library, so, you will have lots of web-specific issues to take care of. If you are not aware of, we have a Flask web app sample and a Django web app sample. They are not built for FastAPI, I know, but you can probably draw some inspiration from their implementation on how they (do not need to) handle those issues, via the help of their dependency, a mid-tier library optimized for web and built on top of MSAL.