Skip to content

Instantly share code, notes, and snippets.

@liliankasem
Created March 16, 2026 22:48
Show Gist options
  • Select an option

  • Save liliankasem/4f9c6ac12865640e34ef155bd36496c1 to your computer and use it in GitHub Desktop.

Select an option

Save liliankasem/4f9c6ac12865640e34ef155bd36496c1 to your computer and use it in GitHub Desktop.

On-Behalf-Of (OBO) Flow with Azure Functions and Easy Auth

This guide explains how to configure the On-Behalf-Of (OBO) flow to call Microsoft Graph on behalf of authenticated users from an Azure Functions MCP server.

Overview

The OBO flow allows your Azure Function to:

  1. Accept an authenticated user's token via Easy Auth
  2. Exchange that token for a new token scoped to Microsoft Graph
  3. Call Graph APIs on behalf of the user
┌─────────┐     ┌───────────────┐     ┌─────────────────────┐     ┌───────────────┐
│  User   │────▶│  MCP Client   │────▶│  Azure Function     │────▶│ Microsoft     │
│         │     │  (VS Code)    │     │  + Easy Auth        │     │ Graph API     │
└─────────┘     └───────────────┘     └─────────────────────┘     └───────────────┘
                       │                       │
                       │ Bearer Token          │ OBO Token Exchange
                       │ (user assertion)      │ (via managed identity)
                       └───────────────────────┘

Prerequisites

  • Azure subscription
  • Azure CLI
  • Azure Developer CLI (azd)
  • .NET 10 SDK

Setup Steps

1. Deploy the Azure Function

azd up

This creates:

  • Azure Function App
  • User-assigned Managed Identity
  • App registration (via Easy Auth)

2. Configure App Registration

2.1 Add API Permissions

  1. Go to Azure PortalApp registrations[Your App]
  2. Click API permissionsAdd a permission
  3. Select Microsoft GraphDelegated permissions
  4. Add User.Read
  5. (Optional) Click Grant admin consent if available

2.2 Expose an API

  1. Go to Expose an API
  2. Set Application ID URI to api://{client-id} (if not already set)
  3. Add a scope:
    • Scope name: access_as_user
    • Who can consent: Admins and users
    • Admin consent display name: Access as user
    • Admin consent description: Allows the app to access resources on behalf of the signed-in user

2.3 Configure Authentication

  1. Go to Authentication
  2. Add a Web platform redirect URI:
    https://{your-function-app}.azurewebsites.net/.auth/login/aad/callback
    
  3. Under Implicit grant and hybrid flows, check:
    • ID tokens
  4. Click Save

3. Create Federated Credential for Managed Identity

This allows the managed identity to authenticate to Entra ID as the app registration.

# Get the managed identity object ID
MI_OBJECT_ID=$(az identity show \
  --resource-group {rg-name} \
  --name {mi-name} \
  --query principalId -o tsv)

# Create federated credential
az ad app federated-credential create \
  --id {app-client-id} \
  --parameters '{
    "name": "managed-identity-federation",
    "issuer": "https://login.microsoftonline.com/{tenant-id}/v2.0",
    "subject": "'$MI_OBJECT_ID'",
    "audiences": ["api://AzureADTokenExchange"],
    "description": "Federated credential for managed identity OBO flow"
  }'

4. Configure Easy Auth

4.1 Enable Easy Auth

az webapp auth config-version upgrade \
  --resource-group {rg-name} \
  --name {function-app-name}

az webapp auth microsoft update \
  --resource-group {rg-name} \
  --name {function-app-name} \
  --client-id {app-client-id} \
  --issuer "https://login.microsoftonline.com/{tenant-id}/v2.0" \
  --allowed-audiences "api://{app-client-id}" "{app-client-id}" \
  --yes

4.2 Configure Login Parameters for Graph Scope

This is critical - Easy Auth must request Graph scopes during login so the user consents to them.

# Get subscription ID
SUBSCRIPTION_ID=$(az account show --query id -o tsv)

# Get current auth settings
az rest --method GET \
  --uri "https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/resourceGroups/{rg-name}/providers/Microsoft.Web/sites/{function-app-name}/config/authsettingsV2?api-version=2023-12-01" \
  > /tmp/auth.json

# Add loginParameters with Graph scopes
cat /tmp/auth.json | jq '.properties.identityProviders.azureActiveDirectory.login += {"loginParameters": ["scope=openid profile email https://graph.microsoft.com/User.Read"]}' > /tmp/auth_updated.json

# Apply updated settings
az rest --method PUT \
  --uri "https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/resourceGroups/{rg-name}/providers/Microsoft.Web/sites/{function-app-name}/config/authsettingsV2?api-version=2023-12-01" \
  --body @/tmp/auth_updated.json

5. Configure Function App Settings

The following environment variables are needed:

Setting Source Description
AZURE_TENANT_ID Bicep output Your Entra ID tenant ID
WEBSITE_AUTH_CLIENT_ID Auto-injected by Easy Auth App registration client ID
OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID Bicep User-assigned managed identity client ID

Note: WEBSITE_AUTH_CLIENT_ID is automatically set by Easy Auth when authentication is enabled - no manual configuration needed. AZURE_TENANT_ID is already output by main.bicep. OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID is already configured in api.bicep.

6. Pre-authorize MCP Clients (Optional)

To avoid consent prompts for MCP clients like VS Code:

  1. Go to App registrations[Your App]Expose an API
  2. Under Authorized client applications, click Add a client application
  3. Add VS Code's client ID: 04b07795-8ddb-461a-bbee-02f9e1bf7b46
  4. Select your access_as_user scope

Code Implementation

HelloTool.cs

public class HelloTool(ILogger<HelloTool> logger, IHostEnvironment hostEnvironment)
{
    private static readonly string[] GraphScopes = ["https://graph.microsoft.com/.default"];

    [Function(nameof(HelloTool))]
    public async Task<string> Run(
        [McpToolTrigger(nameof(HelloTool), "Responds to the user with a hello message.")] 
        ToolInvocationContext context)
    {
        TokenCredential credential;

        if (hostEnvironment.IsDevelopment())
        {
            // Use local developer credentials
            credential = new ChainedTokenCredential(
                new AzureCliCredential(),
                new VisualStudioCodeCredential());
        }
        else
        {
            // Use OBO flow in production
            credential = BuildOnBehalfOfCredential(context);
        }

        using var graphClient = new GraphServiceClient(credential, GraphScopes);
        var me = await graphClient.Me.GetAsync();
        
        return $"Hello, {me?.DisplayName} ({me?.Mail})!";
    }

    private static TokenCredential BuildOnBehalfOfCredential(ToolInvocationContext context)
    {
        if (!context.TryGetHttpTransport(out var transport))
            throw new InvalidOperationException("No HTTP transport available.");

        // Get user token from Easy Auth or Authorization header
        var userToken = GetUserToken(transport!);

        // Get configuration from environment variables
        // AZURE_TENANT_ID - output by bicep
        // WEBSITE_AUTH_CLIENT_ID - auto-injected by Easy Auth
        string tenantId = Environment.GetEnvironmentVariable("AZURE_TENANT_ID") 
            ?? throw new InvalidOperationException("AZURE_TENANT_ID is not set.");
        string clientId = Environment.GetEnvironmentVariable("WEBSITE_AUTH_CLIENT_ID")
            ?? throw new InvalidOperationException("WEBSITE_AUTH_CLIENT_ID is not set.");

        // Build client assertion callback using managed identity federation
        var clientAssertionCallback = BuildClientAssertionCallback();

        return new OnBehalfOfCredential(tenantId, clientId, clientAssertionCallback, userToken);
    }

    private static string GetUserToken(HttpTransport transport)
    {
        // Try Easy Auth token store first
        if (transport.Headers.TryGetValue("X-MS-TOKEN-AAD-ACCESS-TOKEN", out var easyAuthToken) 
            && !string.IsNullOrEmpty(easyAuthToken))
            return easyAuthToken;

        // Fallback to Authorization header (for MCP clients sending Bearer tokens directly)
        if (transport.Headers.TryGetValue("Authorization", out var authHeader) 
            && authHeader.StartsWith("Bearer "))
            return authHeader["Bearer ".Length..];

        throw new InvalidOperationException("No access token found.");
    }

    private static Func<CancellationToken, Task<string>> BuildClientAssertionCallback()
    {
        // OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID - set by bicep
        string miClientId = Environment.GetEnvironmentVariable("OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID")
            ?? throw new InvalidOperationException("OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID is not set.");

        var managedIdentity = new ManagedIdentityCredential(miClientId);
        string audience = Environment.GetEnvironmentVariable("TokenExchangeAudience") 
            ?? "api://AzureADTokenExchange";

        return async (cancellationToken) =>
        {
            var token = await managedIdentity.GetTokenAsync(
                new TokenRequestContext([$"{audience}/.default"]),
                cancellationToken);
            return token.Token;
        };
    }
}

Required NuGet Packages

<PackageReference Include="Azure.Identity" Version="1.13.2" />
<PackageReference Include="Microsoft.Graph" Version="5.94.0" />

Testing

Trigger User Consent

After configuring everything, trigger consent by visiting:

https://{your-function-app}.azurewebsites.net/.auth/login/aad?prompt=consent

Get a Token for Testing

# After login, get your token
curl -s https://{your-function-app}.azurewebsites.net/.auth/me | jq '.access_token'

Test via MCP Client

Configure .vscode/mcp.json:

{
  "servers": {
    "my-mcp-server": {
      "type": "http",
      "url": "https://{your-function-app}.azurewebsites.net/runtime/webhooks/mcp",
      "headers": {
        "Authorization": "Bearer {your-token}"
      }
    }
  }
}

Troubleshooting

AADSTS65001: User consent required

Cause: The user hasn't consented to Graph permissions.

Fix:

  1. Ensure loginParameters includes scope=... https://graph.microsoft.com/User.Read
  2. Have the user re-authenticate with ?prompt=consent
  3. Or grant admin consent in Azure Portal

AADSTS700054: response_type 'id_token' is not enabled

Cause: ID token issuance is not enabled for the app.

Fix: Go to App registration → Authentication → Enable ID tokens

AADSTS500113: No reply address is registered

Cause: The redirect URI is missing.

Fix: Add https://{function-app}/.auth/login/aad/callback to redirect URIs

No access token found

Cause: Easy Auth is not enabled or configured correctly.

Fix: Verify Easy Auth is enabled and authentication is required

Managed identity token error

Cause: Federated credential not configured correctly.

Fix:

  1. Verify federated credential exists on the app registration
  2. Check the subject matches the managed identity object ID
  3. Ensure the managed identity is assigned to the Function App

Security Considerations

  1. Token validation: Easy Auth validates incoming tokens. Additional validation may be needed for sensitive operations.

  2. Scope limitations: Only request the minimum Graph scopes needed.

  3. Token caching: Consider implementing token caching to reduce OBO exchanges.

  4. Audit logging: Log authentication events for security monitoring.

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment