Showing posts with label Azure OpenAI. Show all posts
Showing posts with label Azure OpenAI. Show all posts

Saturday, 7 March 2026

Integrating AI Agents in SharePoint with Azure Functions (.NET 8), Azure OpenAI, and React (TypeScript) using Managed Identity

Integrating AI Agents in SharePoint

AI Agents in SharePoint often stall on two fronts: secure Graph access and reliable orchestration. Teams hardcode keys, expose function keys in SPAs, and skip schema validation—leading to outages and security reviews. This solution shows how to integrate AI Agents in SharePoint using Azure Functions (.NET 8), Azure OpenAI, and React with strict TypeScript, Zod schemas, and Managed Identity—no secrets in code, no function keys.

Prerequisites

- Azure subscription with permissions to assign roles - .NET 8 SDK - Node.js 20+ - Azure Functions Core Tools v4 - Azure OpenAI resource (standard or global) with a deployed Chat Completions model (e.g., gpt-4o or gpt-35-turbo) - Microsoft Entra ID app registration for the SPA (React)

The Solution (Step-by-Step)

1) Architecture Overview

- React SPA authenticates with Microsoft Entra ID (MSAL) and calls an Azure Function over HTTPS. - Azure Function uses Managed Identity for two things: - Call Azure OpenAI without API keys. - Call Microsoft Graph for SharePoint operations (e.g., list items, search content). - Optional: Retrieve non-secret configuration from Azure Key Vault using Managed Identity.

2) Azure Roles and Permissions

- Function App (system-assigned managed identity): - On Azure OpenAI resource: Cognitive Services OpenAI User role. - On Azure Key Vault: Key Vault Secrets User role (only if you store configuration there). - On Microsoft Graph: Application permissions as needed, e.g., Sites.Read.All (read) or Sites.ReadWrite.All (write). Grant admin consent in Enterprise Applications for the Function’s managed identity service principal. - SPA app registration: - Expose an API for your Function App or configure Easy Auth with audience (Application ID URI). The SPA will request tokens for this audience.

3) Azure Function (.NET 8, Isolated Worker) with Managed Identity

// File: Program.cs // Purpose: Configure DI, Managed Identity credentials, HTTP trigger, and CORS. using System.Net; using Azure; using Azure.AI.OpenAI; using Azure.Core; using Azure.Identity; // DefaultAzureCredential using Azure.Security.KeyVault.Secrets; // Optional: Key Vault using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Middleware; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; var host = new HostBuilder() .ConfigureFunctionsWorkerDefaults() .ConfigureServices(services => { // Use DefaultAzureCredential to support Managed Identity in Azure and dev flows locally. services.AddSingleton(_ => new DefaultAzureCredential()); // OpenAI client uses Managed Identity; endpoint comes from configuration (App Setting: AZURE_OPENAI_ENDPOINT). services.AddSingleton(sp => { var cred = sp.GetRequiredService(); var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT not configured."); return new OpenAIClient(new Uri(endpoint), cred); }); // Optional Key Vault to fetch non-secret config at runtime. services.AddSingleton(sp => { var cred = sp.GetRequiredService(); var kvUri = Environment.GetEnvironmentVariable("KEY_VAULT_URI"); return kvUri is null ? null! : new SecretClient(new Uri(kvUri), cred); }); // Graph client factory using Managed Identity. You can use Graph SDK or raw HTTP with token. services.AddHttpClient("graph"); services.AddSingleton(); services.AddSingleton(); }) .Build(); await host.RunAsync(); // File: Services.cs // Purpose: Token acquisition for Graph and agent orchestration including OpenAI and SharePoint. using System.Net.Http.Headers; using System.Text; using System.Text.Json; using Azure.AI.OpenAI; using Azure.Identity; using Azure.Core; public interface IGraphTokenProvider { // Acquire an application token for Microsoft Graph using Managed Identity. Task GetTokenAsync(CancellationToken ct); } public sealed class GraphTokenProvider(TokenCredential credential) : IGraphTokenProvider { private readonly TokenCredential _credential = credential; private static readonly string[] Scopes = ["https://graph.microsoft.com/.default"]; // Application permissions public async Task GetTokenAsync(CancellationToken ct) { var token = await _credential.GetTokenAsync(new TokenRequestContext(Scopes), ct); return token.Token; } } public interface IAgentService { // Orchestrate: summarize SharePoint content then respond via Azure OpenAI. Task HandleAsync(AgentRequest request, CancellationToken ct); } public sealed class AgentService(OpenAIClient openAi, IHttpClientFactory httpFactory, IGraphTokenProvider tokenProvider, ILogger logger) : IAgentService { private readonly OpenAIClient _openAi = openAi; private readonly IHttpClientFactory _httpFactory = httpFactory; private readonly IGraphTokenProvider _tokenProvider = tokenProvider; private readonly ILogger _logger = logger; public async Task HandleAsync(AgentRequest request, CancellationToken ct) { // 1) Fetch SharePoint content via Microsoft Graph based on request.SiteId and query. var graphClient = _httpFactory.CreateClient("graph"); var token = await _tokenProvider.GetTokenAsync(ct); graphClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); // Example: Search site for files matching the query. Adjust endpoints to your needs. var searchPayload = new { requests = new[] { new { entityTypes = new[] { "driveItem" }, query = new { queryString = request.Query }, from = 0, size = 5, fields = new[] { "name", "path", "title" } } } }; using var content = new StringContent(JsonSerializer.Serialize(searchPayload), Encoding.UTF8, "application/json"); using var resp = await graphClient.PostAsync("https://graph.microsoft.com/v1.0/search/query", content, ct); if (!resp.IsSuccessStatusCode) { _logger.LogWarning("Graph search failed: {Status}", resp.StatusCode); } var graphJson = await resp.Content.ReadAsStringAsync(ct); // 2) Ask Azure OpenAI to synthesize an answer citing found items. // Use a deterministic, concise system prompt to guide the agent. var systemPrompt = "You are a SharePoint-aware assistant. Summarize results and provide next steps. Be concise."; var chat = new ChatCompletionsOptions { Messages = { new ChatRequestSystemMessage(systemPrompt), new ChatRequestUserMessage($"User query: {request.Query}\nGraph results JSON: {graphJson}") }, Temperature = 0.2f, MaxTokens = 600 }; var deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_CHAT_DEPLOYMENT") ?? throw new InvalidOperationException("AZURE_OPENAI_CHAT_DEPLOYMENT not configured."); var result = await _openAi.GetChatCompletionsAsync(deployment, chat, ct); var message = result.Value.Choices[0].Message.Content?.Trim() ?? "No response."; return new AgentResponse(message); } } public sealed record AgentRequest(string SiteId, string Query); public sealed record AgentResponse(string Message); // File: Function.cs // Purpose: HTTP endpoint secured by App Service Authentication. No function keys. Validates input and returns agent response. using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; public sealed class AgentFunction(IAgentService agent, ILogger logger) { private readonly IAgentService _agent = agent; private readonly ILogger _logger = logger; [Function("agent-run")] public async Task Run([HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "agent/run")] HttpRequestData req, FunctionContext ctx) { // Authentication is enforced by App Service Authentication (Easy Auth) at the platform layer. // Validate payload strictly. try { var request = await JsonSerializer.DeserializeAsync(req.Body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }); if (request is null || string.IsNullOrWhiteSpace(request.Query) || string.IsNullOrWhiteSpace(request.SiteId)) { var bad = req.CreateResponse(HttpStatusCode.BadRequest); await bad.WriteStringAsync("Invalid payload: 'siteId' and 'query' are required."); return bad; } var result = await _agent.HandleAsync(request, ctx.CancellationToken); var ok = req.CreateResponse(HttpStatusCode.OK); await ok.WriteStringAsync(JsonSerializer.Serialize(result)); return ok; } catch (Exception ex) { _logger.LogError(ex, "Agent invocation failed"); var res = req.CreateResponse(HttpStatusCode.InternalServerError); await res.WriteStringAsync("Agent failed. Check logs."); return res; } } }

4) React (TypeScript, strict) with MSAL and Zod

// File: src/agentSchema.ts // Purpose: Runtime validation for request/response payloads. import { z } from "zod"; export const AgentRequestSchema = z.object({ siteId: z.string().min(1), query: z.string().min(1) }); export type AgentRequest = z.infer; export const AgentResponseSchema = z.object({ message: z.string() }); export type AgentResponse = z.infer; // File: src/msal.ts // Purpose: Initialize MSAL for SPA login; acquire tokens for the Function App audience. import { PublicClientApplication, type Configuration } from "@azure/msal-browser"; const config: Configuration = { auth: { clientId: import.meta.env.VITE_AAD_CLIENT_ID, // SPA app registration authority: `https://login.microsoftonline.com/${import.meta.env.VITE_AAD_TENANT_ID}`, redirectUri: import.meta.env.VITE_REDIRECT_URI }, cache: { cacheLocation: "sessionStorage" } }; export const msal = new PublicClientApplication(config); // File: src/api.ts // Purpose: Call the Function endpoint with a bearer token; no function keys in URL. import { msal } from "./msal"; import { AgentRequestSchema, AgentResponseSchema, type AgentRequest, type AgentResponse } from "./agentSchema"; const FUNCTION_SCOPE = import.meta.env.VITE_FUNCTION_AUDIENCE; // e.g., api://YOUR-FUNCTION-APP-APPID/.default const FUNCTION_BASE = import.meta.env.VITE_FUNCTION_BASE; // e.g., https://your-func.azurewebsites.net async function acquireToken(): Promise { const accounts = msal.getAllAccounts(); const account = accounts[0] ?? (await msal.loginPopup({ scopes: [FUNCTION_SCOPE] })).account; const result = await msal.acquireTokenSilent({ account, scopes: [FUNCTION_SCOPE] }); return result.accessToken; } export async function runAgent(input: AgentRequest): Promise { const parsed = AgentRequestSchema.parse(input); // Validate before sending const token = await acquireToken(); // MSAL gets an access token for the Function audience const res = await fetch(`${FUNCTION_BASE}/api/agent/run`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify(parsed) }); if (!res.ok) { throw new Error(`Agent failed: ${res.status}`); } const json = await res.json(); return AgentResponseSchema.parse(json); // Validate response shape } // File: src/App.tsx // Purpose: Simple UI to query the agent against SharePoint content. import { useState } from "react"; import { runAgent } from "./api"; export default function App() { const [siteId, setSiteId] = useState(""); const [query, setQuery] = useState(""); const [result, setResult] = useState(""); const [busy, setBusy] = useState(false); async function onSubmit(e: React.FormEvent) { e.preventDefault(); setBusy(true); try { const res = await runAgent({ siteId, query }); setResult(res.message); } catch (err) { setResult((err as Error).message); } finally { setBusy(false); } } return (

SharePoint AI Agent

setSiteId(e.target.value)} />
setQuery(e.target.value)} />
{result}
); }

5) Configuration Notes

- App Settings for Function: - AZURE_OPENAI_ENDPOINT = https://your-openai-resource.openai.azure.com - AZURE_OPENAI_CHAT_DEPLOYMENT = your-deployment-name - Optional KEY_VAULT_URI = https://your-kv.vault.azure.net - CORS: - For local: local.settings.json Host:CORS = https://localhost:5173 - In Azure: enable CORS on the Function App for your SPA origins. - Authentication (Function App): - Enable App Service Authentication (Login with Microsoft) and set Allowed Token Audiences to your Function App Application ID URI. - Do not use function keys. Rely on Azure AD tokens.

Best Practices & Security

- Recommendation: Use DefaultAzureCredential everywhere. It enables Managed Identity in Azure and dev auth locally. - Recommendation: Grant least privilege on Graph (e.g., Sites.Read.All). Avoid tenant-wide write unless necessary. - Recommendation: Store only non-secret configuration in Key Vault when needed; prefer Managed Identity over storing API keys. - Recommendation: Validate all inputs/outputs with Zod (SPA) and model binding or explicit checks (Function) to reduce runtime errors. - Recommendation: Configure CORS explicitly per environment. Avoid wildcard origins in production. - Recommendation: Monitor with Application Insights. Track dependency calls (Graph, OpenAI) and add custom dimensions for correlation IDs. - Recommendation: Implement retry with exponential backoff for Graph and OpenAI calls. Consider Polly for transient faults. - Recommendation: If hosting the SPA on Azure Static Web Apps, use its built-in authentication and route protected calls to the Function with Easy Auth.

Summary

- Securely integrate AI Agents in SharePoint by fronting Graph and Azure OpenAI with an Azure Function using Managed Identity—no keys, no secrets in code. - Enforce strict schemas with Zod in React and input validation in .NET to reduce errors and improve reliability. - Harden production with proper RBAC, CORS, Application Insights, and transient fault handling.

Sunday, 18 January 2026

Integrate Power Apps with AI using Azure Functions (.NET 8) and Azure OpenAI with Managed Identity

Integrate Power Apps with AI by fronting Azure OpenAI behind a secure .NET 8 isolated Azure Function, authenticated via Entra ID and deployed with Azure Bicep. Why this matters: you keep your Azure OpenAI keyless via Managed Identity, enforce RBAC, and provide a stable HTTPS endpoint for Power Apps using a custom connector.

The Problem

Developers need to call AI securely from Power Apps without exposing keys, while meeting enterprise requirements for RBAC, observability, and least-privilege. Manual wiring through the portal and ad hoc security checks lead to drift and risk.

Prerequisites

  • Azure CLI 2.60+
  • .NET 8 SDK
  • Azure Developer CLI (optional) or Bicep CLI
  • Owner or User Access Administrator on the target subscription
  • Power Apps environment access for creating a custom connector

The Solution (Step-by-Step)

1) Deploy Azure resources with Bicep (Managed Identity, Function App, Azure OpenAI, RBAC, Easy Auth)

This Bicep template creates a Function App with system-assigned managed identity, Azure OpenAI with a model deployment, App Service Authentication (Easy Auth) enforced, and assigns the Cognitive Services OpenAI User role to the identity.

// main.bicep
targetScope = 'resourceGroup'

@description('Name prefix for resources')
param namePrefix string

@description('Location for resources')
param location string = resourceGroup().location

@description('Azure OpenAI model name, e.g., gpt-4o-mini')
param aoaiModelName string = 'gpt-4o-mini'

@description('Azure OpenAI model version for your region (see Azure docs for supported versions).')
param aoaiModelVersion string = '2024-07-18' // Tip: Validate the correct version via az cognitiveservices account list-models

var funcName = '${namePrefix}-func'
var aoaiName = '${namePrefix}-aoai'
var hostingPlanName = '${namePrefix}-plan'
var appInsightsName = '${namePrefix}-ai'
var storageName = toLower(replace('${namePrefix}st${uniqueString(resourceGroup().id)}', '-', ''))

resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: storageName
  location: location
  sku: {
    name: 'Standard_LRS'
  }
  kind: 'StorageV2'
}

resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
  name: appInsightsName
  location: location
  kind: 'web'
  properties: {
    Application_Type: 'web'
  }
}

resource plan 'Microsoft.Web/serverfarms@2023-12-01' = {
  name: hostingPlanName
  location: location
  sku: {
    name: 'Y1' // Consumption for Functions
    tier: 'Dynamic'
  }
}

resource func 'Microsoft.Web/sites@2023-12-01' = {
  name: funcName
  location: location
  kind: 'functionapp'
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    serverFarmId: plan.id
    siteConfig: {
      appSettings: [
        { name: 'AzureWebJobsStorage', value: storage.listKeys().keys[0].value }
        { name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: appInsights.properties.ConnectionString }
        { name: 'FUNCTIONS_EXTENSION_VERSION', value: '~4' }
        { name: 'FUNCTIONS_WORKER_RUNTIME', value: 'dotnet-isolated' }
        // Enable Easy Auth enforcement via config below (authSettingsV2)
      ]
      http20Enabled: true
      minimumTlsVersion: '1.2'
      ftpsState: 'Disabled'
      cors: {
        allowedOrigins: [
          // Add your Power Apps domain if needed; CORS is typically managed by the custom connector
        ]
      }
    }
    httpsOnly: true
  }
}

// Enable App Service Authentication (Easy Auth) with Entra ID and enforce authentication.
resource auth 'Microsoft.Web/sites/config@2023-12-01' = {
  name: '${func.name}/authsettingsV2'
  properties: {
    globalValidation: {
      requireAuthentication: true
      unauthenticatedClientAction: 'Return401'
    }
    identityProviders: {
      azureActiveDirectory: {
        enabled: true
        registration: {
          // When omitted, system can use an implicit provider. For enterprise, specify a registered app:
          // Provide clientId and clientSecretSettingName if using a dedicated app registration.
        }
        validation: {
          // Audience (App ID URI or application ID). Power Apps custom connector should request this audience.
          // Replace with your API application ID URI when using a dedicated AAD app registration.
          allowedAudiences: [
            'api://<your-app-id>'
          ]
        }
        login: {
          disableWWWAuthenticate: false
        }
      }
    }
    platform: {
      enabled: true
      runtimeVersion: '~1'
    }
    login: {
      tokenStore: {
        enabled: true
      }
    }
  }
}

// Azure OpenAI account
resource aoai 'Microsoft.CognitiveServices/accounts@2023-05-01' = {
  name: aoaiName
  location: location
  kind: 'OpenAI'
  sku: {
    name: 'S0'
  }
  properties: {
    customSubDomainName: toLower(aoaiName)
    publicNetworkAccess: 'Enabled'
  }
}

// Azure OpenAI deployment - ensure model/version are valid for your region
resource aoaiDeployment 'Microsoft.CognitiveServices/accounts/deployments@2023-05-01' = {
  name: 'chat-${aoaiModelName}'
  parent: aoai
  properties: {
    model: {
      format: 'OpenAI'
      name: aoaiModelName
      version: aoaiModelVersion
    }
    capacities: [
      {
        capacity: 1
        capacityType: 'Standard'
      }
    ]
  }
}

// Assign RBAC: Cognitive Services OpenAI User role to the Function's managed identity
// Built-in role: Cognitive Services OpenAI User (Role ID: 5e0bd9bd-7ac1-4c9e-8289-1b01f135d4a8)
resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(aoai.id, func.identity.principalId, 'CognitiveServicesOpenAIUserRole')
  scope: aoai
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7ac1-4c9e-8289-1b01f135d4a8')
    principalId: func.identity.principalId
    principalType: 'ServicePrincipal'
  }
}

output functionAppName string = func.name
output openAiEndpoint string = 'https://${aoai.name}.openai.azure.com/'
output openAiDeployment string = aoaiDeployment.name

Tip: To find the correct model version for your region, run: az cognitiveservices account list-models --name <aoaiName> --resource-group <rg> --output table.

2) Implement a secure .NET 8 isolated Azure Function with DI and Managed Identity

The function validates Entra ID via Easy Auth headers, performs input validation, calls Azure OpenAI using DefaultAzureCredential, and returns a minimal response. AuthorizationLevel is Anonymous because App Service Authentication is enforcing auth at the edge; the code still verifies identity defensively.

// Program.cs
using System.Text.Json;
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Middleware;
using Microsoft.Azure.Functions.Worker.Extensions.OpenApi.Extensions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var host = new HostBuilder()
    .ConfigureFunctionsWebApplication(builder =>
    {
        // Add global middleware if needed (e.g., correlation, exception handling)
        builder.UseMiddleware<GlobalExceptionMiddleware>();
    })
    .ConfigureServices((ctx, services) =>
    {
        var config = ctx.Configuration;

        // Bind custom options
        services.Configure<OpenAIOptions>(config.GetSection(OpenAIOptions.SectionName));

        // Register OpenAI client using Managed Identity (no keys)
        services.AddSingleton((sp) =>
        {
            var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<OpenAIOptions>>().Value;
            // Use DefaultAzureCredential to leverage Managed Identity in Azure
            var credential = new DefaultAzureCredential();
            return new OpenAIClient(new Uri(options.Endpoint), credential);
        });

        services.AddSingleton<IPromptService, PromptService>();
        services.AddApplicationInsightsTelemetryWorkerService();
    })
    .ConfigureAppConfiguration(config =>
    {
        config.AddEnvironmentVariables();
    })
    .ConfigureLogging(logging =>
    {
        logging.AddConsole();
    })
    .Build();

await host.RunAsync();

file sealed class GlobalExceptionMiddleware : IFunctionsWorkerMiddleware
{
    public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
    {
        try
        {
            await next(context);
        }
        catch (Exception ex)
        {
            var logger = context.GetLogger<GlobalExceptionMiddleware>();
            logger.LogError(ex, "Unhandled exception");

            // Avoid leaking internals; return generic error with correlation ID
            var invocationId = context.InvocationId;
            var http = await context.GetHttpRequestDataAsync();
            if (http is not null)
            {
                var response = http.CreateResponse(System.Net.HttpStatusCode.InternalServerError);
                await response.WriteStringAsync($"Request failed. CorrelationId: {invocationId}");
                context.GetInvocationResult().Value = response;
            }
        }
    }
}

file sealed class OpenAIOptions
{
    public const string SectionName = "OpenAI";
    public string Endpoint { get; init; } = string.Empty; // e.g., https://<aoaiName>.openai.azure.com/
    public string Deployment { get; init; } = string.Empty; // e.g., chat-gpt-4o-mini
}

// Service abstraction to keep function logic clean
file interface IPromptService
{
    Task<string> CreateChatResponseAsync(string userInput, CancellationToken ct);
}

file sealed class PromptService(OpenAIClient client, Microsoft.Extensions.Options.IOptions<OpenAIOptions> opts, ILogger<PromptService> logger) : IPromptService
{
    private readonly OpenAIClient _client = client;
    private readonly OpenAIOptions _opts = opts.Value;
    private readonly ILogger<PromptService> _logger = logger;

    public async Task<string> CreateChatResponseAsync(string userInput, CancellationToken ct)
    {
        // Basic input trimming and minimal length check
        var prompt = (userInput ?? string.Empty).Trim();
        if (prompt.Length < 3)
        {
            return "Input too short.";
        }

        // Create chat completion with minimal system prompt
        var req = new ChatCompletionsOptions()
        {
            DeploymentName = _opts.Deployment,
            Temperature = 0.2f,
            MaxTokens = 256
        };
        req.Messages.Add(new ChatRequestSystemMessage("You are a concise assistant.")); // Guardrail
        req.Messages.Add(new ChatRequestUserMessage(prompt));

        _logger.LogInformation("Calling Azure OpenAI deployment {Deployment}", _opts.Deployment);

        var resp = await _client.GetChatCompletionsAsync(req, ct);
        var msg = resp.Value.Choices.Count > 0 ? resp.Value.Choices[0].Message.Content[0].Text : "No response.";
        return msg ?? string.Empty;
    }
}
// HttpFunction.cs
using System.Net;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Api;

public sealed class ChatRequest
{
    // Strongly typed DTO for request validation
    public string Prompt { get; init; } = string.Empty;
}

public sealed class ChatFunction(IPromptService promptService, ILogger<ChatFunction> logger) 
{
    // Simple helper to read Easy Auth principal header safely
    private static ClaimsPrincipal? TryGetPrincipal(HttpRequestData req)
    {
        // Easy Auth sets x-ms-client-principal as Base64 JSON
        if (!req.Headers.TryGetValues("x-ms-client-principal", out var values)) return null;
        var b64 = values.FirstOrDefault();
        if (string.IsNullOrWhiteSpace(b64)) return null;

        try
        {
            var json = Encoding.UTF8.GetString(Convert.FromBase64String(b64));
            using var doc = JsonDocument.Parse(json);
            var claims = new List<Claim>();
            if (doc.RootElement.TryGetProperty("claims", out var arr))
            {
                foreach (var c in arr.EnumerateArray())
                {
                    var typ = c.GetProperty("typ").GetString() ?? string.Empty;
                    var val = c.GetProperty("val").GetString() ?? string.Empty;
                    claims.Add(new Claim(typ, val));
                }
            }
            return new ClaimsPrincipal(new ClaimsIdentity(claims, "EasyAuth"));
        }
        catch
        {
            return null;
        }
    }

    [Function("chat")]
    public async Task<HttpResponseData> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "chat")] HttpRequestData req,
        FunctionContext context)
    {
        var principal = TryGetPrincipal(req);
        if (principal is null)
        {
            // Defense in depth: App Service auth should already block, but we double-check.
            var unauth = req.CreateResponse(HttpStatusCode.Unauthorized);
            await unauth.WriteStringAsync("Authentication required.");
            return unauth;
        }

        // Basic content-type check and bound read limit
        if (!req.Headers.TryGetValues("Content-Type", out var ct) || !ct.First().Contains("application/json", StringComparison.OrdinalIgnoreCase))
        {
            var bad = req.CreateResponse(HttpStatusCode.BadRequest);
            await bad.WriteStringAsync("Content-Type must be application/json.");
            return bad;
        }

        // Safe body read
        using var reader = new StreamReader(req.Body);
        var body = await reader.ReadToEndAsync();
        ChatRequest? input;
        try
        {
            input = JsonSerializer.Deserialize<ChatRequest>(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
        }
        catch
        {
            var bad = req.CreateResponse(HttpStatusCode.BadRequest);
            await bad.WriteStringAsync("Invalid JSON.");
            return bad;
        }

        if (input is null || string.IsNullOrWhiteSpace(input.Prompt))
        {
            var bad = req.CreateResponse(HttpStatusCode.BadRequest);
            await bad.WriteStringAsync("Prompt is required.");
            return bad;
        }

        var loggerScope = new Dictionary<string, object> { ["UserObjectId"] = principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value ?? "unknown" };
        using (logger.BeginScope(loggerScope))
        {
            try
            {
                var result = await promptService.CreateChatResponseAsync(input.Prompt, context.CancellationToken);
                var ok = req.CreateResponse(HttpStatusCode.OK);
                await ok.WriteAsJsonAsync(new { response = result });
                return ok;
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Error generating chat response");
                var err = req.CreateResponse(HttpStatusCode.BadGateway);
                await err.WriteStringAsync("AI request failed. Try again later.");
                return err;
            }
        }
    }
}
// appsettings.json (for local debug)
{
  "OpenAI": {
    "Endpoint": "https://<your-aoai-name>.openai.azure.com/",
    "Deployment": "chat-gpt-4o-mini"
  }
}

Tip: When running locally, ensure you are logged in with Azure CLI (az login) so DefaultAzureCredential can acquire a token. Assign yourself Cognitive Services OpenAI User on the AOAI resource for testing.

3) Enforce Azure AD (Easy Auth) - configuration example

The Bicep above enables Easy Auth and returns 401 for unauthenticated calls. If you maintain settings as JSON, you can also use an ARM style configuration:

// Example snippet (appsettings for site config via ARM/Bicep-style) ensures:
// - requireAuthentication: true
// - allowedAudiences must match the audience used by your client (Power Apps custom connector)
{
  "properties": {
    "authSettingsV2": {
      "platform": { "enabled": true },
      "globalValidation": {
        "requireAuthentication": true,
        "unauthenticatedClientAction": "Return401"
      },
      "identityProviders": {
        "azureActiveDirectory": {
          "enabled": true,
          "validation": {
            "allowedAudiences": [ "api://<your-app-id>" ]
          }
        }
      }
    }
  }
}

Tip: For most enterprise setups, register a dedicated App Registration for the Function API and reference its Application ID URI in allowedAudiences. Keep consent and scopes explicit.

4) Create a Power Apps Custom Connector

  1. In Power Apps, open Solutions and create a new Custom Connector.
  2. Set the Host to your Function URL (e.g., https://<func-name>.azurewebsites.net).
  3. Define the POST /chat operation that accepts a JSON body { "prompt": "..." } and returns { "response": "..." }.
  4. Security: Choose OAuth 2.0 (Azure Active Directory). Set Audience to match allowedAudiences (api://<your-app-id>). Supply Tenant ID and Client ID as provided by your admin.
  5. Test the connector. You should receive HTTP 200 with the AI response. Unauthorized calls return 401.

5) Add client-side validation in Power Apps (Power Fx)

Before invoking the connector, validate inputs in the app to fail fast and reduce server load.

// Example Power Fx for a button's OnSelect
If(
    IsBlank(Trim(txtPrompt.Text)) || Len(Trim(txtPrompt.Text)) < 3,
    Notify("Please enter at least 3 characters.", NotificationType.Error),
    Set(
        aiResult,
        YourConnector.chat(
            {
                prompt: Trim(txtPrompt.Text)
            }
        ).response
    )
);

// Display result in a Label: Text = aiResult

Tip: Add input length limits and debouncing for multi-keystroke actions. For read-only UI against data, prefer server-side pagination and AsNoTracking() on the API layer where EF Core is involved.

Best Practices & Security

Identity and RBAC

  • Use Managed Identity for the Function App; never store secrets. The sample uses DefaultAzureCredential.
  • Assign the Cognitive Services OpenAI User role to the Function’s identity on the Azure OpenAI resource. This allows API calls without exposing keys.
  • Enforce Azure AD with Easy Auth at the platform level and verify identity in code for defense in depth.

Least Privilege and Network

  • Restrict access with the minimum RBAC scope necessary. Avoid granting Contributor on the subscription.
  • Enable HTTPS only and TLS 1.2+. Disable FTP/FTPS where possible.
  • Consider Private Endpoints for Azure OpenAI and Functions behind an Application Gateway/WAF for enterprise networks.

Validation, Error Handling, and Reliability

  • Validate input on both client (Power Apps) and server (Function). The code enforces content-type and schema shape.
  • Handle exceptions using middleware and return generic messages with correlation IDs to avoid leaking internals.
  • Implement circuit breakers and retries for downstream calls with transient fault policies if you add HTTP dependencies.

Observability

  • Use Application Insights. Log prompt sizes and latency, not raw sensitive content.
  • Sample KQL to investigate errors:
// Requests by operation
requests
| where url endswith "/chat"
| summarize count() by resultCode

// Exceptions with correlation
exceptions
| where operation_Name == "chat"
| project timestamp, type, problemId, outerMessage, operation_Id

// Latency p95
requests
| where url endswith "/chat"
| summarize p95(duration) by bin(timestamp, 1h)

Cost and Abuse Controls

  • Apply rate limiting at the API Management layer if exposing broadly.
  • Set MaxTokens and Temperature conservatively; monitor usage with budgets and alerts.
  • Implement content filters as needed using Azure AI Content Safety.

Summary

  • Power Apps integrates cleanly with AI by calling a secure .NET 8 Azure Function that uses Managed Identity to access Azure OpenAI.
  • Security is enforced with Azure AD (Easy Auth) and RBAC, specifically the Cognitive Services OpenAI User role.
  • IaC with Bicep, defensive coding, and Application Insights deliver repeatable, production-grade operations.