pythonazure-web-app-serviceazure-webappsazure-authenticationazure-python-sdk

Attempting to use the Azure Web App 'Identity provider' feature with a web app that uses the Python Azure SDK (azure.identity)


I'm attempting to build a web app for end users to manage their VMs in Azure (giving them access/visibility to VMs based on their existing permissions). I'd like to take advantage of the identity provider built into the Azure We App resource (aka 'Easy Auth' feature). This involves the Azure web app resource itself proxying authentication and passes user details to the app code via headers.

From the docs there doesn't appear to be a way to hand off that information to the azure.identity module and generate a credential object. How can I use that information to imitate the credential object so I can still use the SDK?

I can write individual queries against the API via requests.get with the information retrieved from the headers, but that defeats the purpose of using the SDK.

Anyone have some insight they are willing to share? Do I have to abandon either the built in identity provider, or the SDK?

Some examples of what I've tested (code and errors are displayed in the context of a streamlit app)

As per the docs, I have set up an app registration with perms and configured the web app resource's Settings > Authentication like so:

I am using Microsoft (Entra ID) as my identity provider, with the app registration.

import streamlit as st

from azure.identity import DefaultAzureCredential
from azure.mgmt.resource import ResourceManagementClient

if "authenticated" not in st.session_state or not st.session_state.authenticated:
    # first auth with cached credential (deployed web app)
    st.session_state.authenticated = False
    st.session_state.credential = DefaultAzureCredential()
            # if credential.supported():
            #     st.text('supported')
    test_client = ResourceManagementClient(st.session_state.credential, subscription_id="72e6300d-ba3d-4094-aede-60df2d005b2e")
    test_group_name = test_client.resource_groups.get(resource_group_name='testgroup')
    print(test_group_name)
    st.session_state.authenticated = True

Authentication occurs (through Easy Auth--through the managed identity provider configured in the web app resources, which happens before the web app's code is interacted with in any way). The user is then directed to the web app, with the following error as a result: enter image description here

The Token Store setting of the web app's authentication pane makes the tokens available via headers and refreshable (see Token Store) but these headers are not the same thing as the Shared Token Cache that azure.identity's SharedTokenCacheCredential interacts with (azure.identity doesn't have any means of interacting with such headers out of the box).

My thought was if the azure.identity module is able to be used, it would be through populating the Shared Token Cache from the header information and allowing the SharedTokenCacheCredential object to take things from there perhaps? But that involves creating a secured in-memory object.

Another angle I looked into was AuthorizationCodeCredential, which is allows 'redeeming an authorization code previously obtained', but this occurs at a different step of the Oauth process--the tokens generated and passed as headers are the result of the Oauth process already having moved past that step.


Solution

  • OnBehalfOfCredential is the solution!

    There is a rather helpful diagram on the On-Behalf-Of flow hosted on Microsoft Learn: Microsoft's diagram demonstrating On Behalf Of flow

    Notably, when using Easy Auth (or the MSAL package, for that matter) to authenticate the user, it is acting as the 'Application' in the above diagram, even though the only thing it is doing is authentication. The Python SDK is acting as Web API A in the diagram, which may feel counterintuitive when that is where the bulk of your app code lives. Azure's REST API is Web API B in the diagram.

    Because the process is not simple, the following will be a summary and not go into detail. If there are question about any of the steps, let me know. This would make a great blog post. . .

    Parts and Pieces

    1. Azure Web App with runtime stack of Python 3.8 or later

    2. Key Vault (to hold secrets for your web app)

    3. Python application code which incorporates the Azure SDK for Python (I was also using streamlit, but it should work with other frameworks too).

    4. SDK App registration for the application (for use by body of code that is using the Azure SDK for Python). This is a SEPARATE app registration from the one used for authentication.

      • Authentication > Access tokens enabled
      • Authentication > Web Redirect URIs - this can be any path on your web app, so long as it matches the path used in the MSAL code below (if using MSAL)
      • Certificates & secrets > generate a client secret, and store it in the key vault for code use (via key vault reference)
    5. Auth App registration for Authentication code (for use by either MSAL or Easy Auth (Settings > Authentication > Add identity provider in the Web App resource)

      • Authentication > Redirect URIs (make sure to also set http://localhost:####, where #### is whatever port you use when running your local dev server)
      • API permissions > Add a permission > My APIs > select the SDK app registration > Delegated permissions > Add permissions > select 'user_impersonation'

    Example implementation in code

    Variables (in a web app usually passed through Environment Variables, sometimes automatically available, but just representing them here as vars)

    import streamlit as st
    import os
    
    from azure.identity import OnBehalfOfCredential
    from msal_streamlit_authentication import msal_authentication
    from streamlit.web.server.websocket_headers import _get_websocket_headers
    from azure.mgmt.resource import ResourceManagementClient
    
    #! MSAL ONLY: remove if using Easy Auth 
    # check if authenticated
    if "authenticated" not in st.session_state or not st.session_state.authenticated:
        st.session_state.authenticated = False
    
        with st.sidebar:
            login_token = msal_authentication(
                auth={
                    "clientId": auth_app_reg_oid,
                    "authority": f"https://login.microsoftonline.com/{tenant_id}",
                    "redirectUri": "/.auth/login/entraid/callback",
                    "postLogoutRedirectUri": "/"
                }, # Corresponds to the 'auth' configuration for an MSAL Instance
                cache={
                    "cacheLocation": "sessionStorage",
                    "storeAuthStateInCookie": False
                }, # Corresponds to the 'cache' configuration for an MSAL Instance
                login_request={
                    "scopes": [f"{sdk_app_reg_oid}/user_impersonation"]
                }, # Optional
                login_button_text="Login", # Optional, defaults to "Login"
                logout_button_text="Logout", # Optional, defaults to "Logout"
                key=1 # Optional if only a single instance is needed
            )
            access_token = login_token["accessToken"]
    
    #! Easy Auth ONLY: remove if using MSAL
    #? code for Easy Auth, the following gets the token
    # headers = _get_websocket_headers()
    # # next check if easy auth is enabled and was used
    # access_token = headers.get("X-Ms-Token-Aad-Access-Token")
    
    #! From here the code is the same for both Easy Auth and MSAL
    # if not authenticated, halt processing until authentication occurs
    if not access_token:
        st.write("Authenticate to access protected content")
        st.stop()
    
    # if authenticated, start the OBO flow
    else:
        if access_token is not None:
            st.session_state.credential = OnBehalfOfCredential(
                tenant_id=tenant_id,
                client_id=sdk_app_reg_id,
                client_secret=sdk_app_reg_secret,
                user_assertion=access_token,
            )
            with st.sidebar:
                st.write('authenticated with OBO flow')
        else:
            with st.sidebar:
                st.write('missing access token')
    
        st.session_state.authenticated = True
    
        # test the credential (doesn't show errors until used with a client)
        test_client = ResourceManagementClient(
                st.session_state.credential,
                subscription_id=test_subscription_id
            )
        test_group_name = test_client.resource_groups.get(resource_group_name=test_rg_name)
        print(test_group_name)
    

    Some other resources I found helpful in getting to a working deployment: