diff --git a/docs/calling-downstream-apis/README.md b/docs/calling-downstream-apis/README.md index ce57e4c27..330bda1c5 100644 --- a/docs/calling-downstream-apis/README.md +++ b/docs/calling-downstream-apis/README.md @@ -1,22 +1,423 @@ -# Calling Downstream APIs +# Calling Downstream APIs with Microsoft.Identity.Web + +This guide helps you choose and implement the right approach for calling downstream APIs (Microsoft Graph, Azure services, or custom APIs) from your ASP.NET Core, OWIN or .NET applications using Microsoft.Identity.Web. + +## 🎯 Choosing the Right Approach + +Use this decision tree to select the best method for your scenario: + +| API Type / Scenario | Decision / Criteria | Recommended Client/Class | +|--------------------------------------|---------------------------------------|---------------------------------------------------------| +| Microsoft Graph | You need to call Microsoft Graph APIS | GraphServiceClient | +| Azure SDK (Storage, KeyVault, etc.) | You need to call Azure APIs (Azure SDK) | MicrosoftIdentityTokenCredential with Azure SDK clients | +| Custom API | simple, configurable | IDownstreamApi | +| Custom API | using HttpClient + delegating handler | MicrosoftIdentityMessageHandler | +| Custom API | using your HttpClient | IAuthorizationHeaderProvider | + +## 📊 Comparison Table + +| Approach | Best For | Complexity | Configuration | Flexibility | +|----------|----------|------------|---------------|-------------| +| **GraphServiceClient** | Microsoft Graph APIs | Low | Simple | Medium | +| **MicrosoftIdentityTokenCredential** | Azure SDK clients | Low | Simple | Low | +| **IDownstreamApi** | REST APIs with standard patterns | Low | JSON + Code | Medium | +| **MicrosoftIdentityMessageHandler** | HttpClient with auth pipeline | Medium | Code | High | +| **IAuthorizationHeaderProvider** | Custom auth logic | High | Code | Very High | + +## 🔐 Token Acquisition Patterns + +Microsoft.Identity.Web supports three main token acquisition patterns: -## Decision Tree ```mermaid -graph TD; - A[Use Graph?] -->|Yes| B[Use Microsoft Graph SDK]; - A -->|No| C[Use Azure SDK?]; - C -->|Yes| D[Use Azure SDK Client]; - C -->|No| E[Use IDownstreamApi?]; - E -->|Yes| F[Use IDownstreamApi]; - E -->|No| G[Use MicrosoftIdentityMessageHandler?]; - G -->|Yes| H[Use MicrosoftIdentityMessageHandler]; - G -->|No| I[Use IAuthorizationHeaderProvider]; - I -->|Yes| J[Use IAuthorizationHeaderProvider]; - I -->|No| K[Handle Errors]; - -``` - -## Token Acquisition Patterns -- Overview of token acquisition patterns -- Error handling strategies -- Cross-references to related documents \ No newline at end of file +graph LR + A[Token Acquisition] --> B[Delegated
On behalf of user] + A --> C[App-Only
Application permissions in all apps] + A --> D[On-Behalf-Of OBO
in web API] + + B --> B1[Web Apps] + B --> B2[Daemon acting as user / user agent] + C --> C1[Daemon Apps] + C --> C2[Web APIs with app permissions] + D --> D1[Web APIs calling other APIs] + + style B fill:#cfe2ff + style C fill:#fff3cd + style D fill:#f8d7da +``` + +### Delegated Permissions (User Tokens) +- **Scenario**: Web app calls API on behalf of signed-in user, and autonomous agent user identity. +- **Token type**: Access token with delegated permissions +- **Methods**: `CreateAuthorizationHeaderForUserAsync()`, `GetForUserAsync()` + +### Application Permissions (App-Only Tokens) +- **Scenario**: Daemon app or background service calls API. Autonmous agent identity +- **Token type**: Access token with application permissions +- **Methods**: `CreateAuthorizationHeaderForAppAsync()`, `GetForAppAsync()` + +### On-Behalf-Of (OBO) +- **Scenario**: Web API receives user token, calls another API on behalf of that user and interactive agents. +- **Token type**: New access token via OBO flow +- **Methods**: `CreateAuthorizationHeaderForUserAsync()` from web API context + +## 🚀 Quick Start Examples + +### Microsoft Graph (Recommended for Graph APIs) + +```csharp +// Installation +// dotnet add package Microsoft.Identity.Web.GraphServiceClient + +// Startup configuration +using Microsoft.Identity.Web; + +builder.Services.AddMicrosoftGraph(); + +// Usage in controller +public class HomeController : Controller +{ + private readonly GraphServiceClient _graphClient; + + public HomeController(GraphServiceClient graphClient) + { + _graphClient = graphClient; + } + + public async Task Profile() + { + // Delegated - calls on behalf of signed-in user + var user = await _graphClient.Me.GetAsync(); + + // App-only - requires app permissions + var users = await _graphClient.Users + .GetAsync(r => r.Options.WithAppOnly()); + + return View(user); + } +} +``` + +[📖 Learn more about Microsoft Graph integration](microsoft-graph.md) + +### Azure SDKs (Recommended for Azure Services) + +```csharp +// Installation +// dotnet add package Microsoft.Identity.Web.Azure +// dotnet add package Azure.Storage.Blobs + +// Startup configuration +using Microsoft.Identity.Web; + +builder.Services.AddMicrosoftIdentityAzureTokenCredential(); + +// Usage +public class StorageService +{ + private readonly MicrosoftIdentityTokenCredential _credential; + + public StorageService(MicrosoftIdentityTokenCredential credential) + { + _credential = credential; + } + + public async Task> ListBlobsAsync() + { + var blobClient = new BlobServiceClient( + new Uri("https://myaccount.blob.core.windows.net"), + _credential); + + var container = blobClient.GetBlobContainerClient("mycontainer"); + var blobs = new List(); + + await foreach (var blob in container.GetBlobsAsync()) + { + blobs.Add(blob.Name); + } + + return blobs; + } +} +``` + +[📖 Learn more about Azure SDK integration](azure-sdks.md) + +### IDownstreamApi (Recommended for Custom REST APIs) + +```csharp +// Installation +// dotnet add package Microsoft.Identity.Web.DownstreamApi + +// appsettings.json +{ + "DownstreamApis": { + "MyApi": { + "BaseUrl": "https://myapi.example.com", + "Scopes": ["api://myapi/read", "api://myapi/write"] + } + } +} + +// Startup configuration +using Microsoft.Identity.Web; + +builder.Services.AddDownstreamApis( + builder.Configuration.GetSection("DownstreamApis")); + +// Usage +public class ApiService +{ + private readonly IDownstreamApi _api; + + public ApiService(IDownstreamApi api) + { + _api = api; + } + + public async Task GetProductAsync(int id) + { + // Delegated - on behalf of user + return await _api.GetForUserAsync( + "MyApi", + $"api/products/{id}" + ); + } + + public async Task> GetAllProductsAsync() + { + // App-only - using app permissions + return await _api.GetForAppAsync>( + "MyApi", + "api/products"); + } +} +``` + +[📖 Learn more about IDownstreamApi](custom-apis.md) + +### MicrosoftIdentityMessageHandler (For HttpClient Integration) + +```csharp +// Startup configuration +using Microsoft.Identity.Web; + +builder.Services.AddHttpClient("MyApiClient", client => +{ + client.BaseAddress = new Uri("https://myapi.example.com"); +}) +.AddHttpMessageHandler(sp => new MicrosoftIdentityMessageHandler( + sp.GetRequiredService(), + new MicrosoftIdentityMessageHandlerOptions + { + Scopes = new[] { "api://myapi/.default" } + })); + +// Usage +public class ApiService +{ + private readonly HttpClient _httpClient; + + public ApiService(IHttpClientFactory httpClientFactory) + { + _httpClient = httpClientFactory.CreateClient("MyApiClient"); + } + + public async Task GetProductAsync(int id) + { + var request = new HttpRequestMessage(HttpMethod.Get, $"api/products/{id}") + .WithAuthenticationOptions(options => + { + options.RequestAppToken = false; // Use delegated token + options.scopes = [ "myApi.scopes" ]; + }); + + var response = await _httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadFromJsonAsync(); + } +} +``` + +[📖 Learn more about MicrosoftIdentityMessageHandler](custom-apis.md#microsoftidentitymessagehandler) + +### IAuthorizationHeaderProvider (Maximum Flexibility) + +```csharp +// Direct usage for custom scenarios +public class CustomAuthService +{ + private readonly IAuthorizationHeaderProvider _headerProvider; + + public CustomAuthService(IAuthorizationHeaderProvider headerProvider) + { + _headerProvider = headerProvider; + } + + public async Task CallApiAsync() + { + // Get auth header (includes "Bearer " + token) + string authHeader = await _headerProvider + .CreateAuthorizationHeaderForUserAsync( + scopes: new[] { "api://myapi/.default" }); + + using var client = new HttpClient(); + client.DefaultRequestHeaders.Add("Authorization", authHeader); + client.DefaultRequestHeaders.Add("X-Custom-Header", "MyValue"); + + var response = await client.GetStringAsync("https://myapi.example.com/data"); + return response; + } +} +``` + +[📖 Learn more about IAuthorizationHeaderProvider](custom-apis.md#iauthorizationheaderprovider) + +## 📋 Configuration Patterns + +Microsoft.Identity.Web supports both JSON configuration and code-based configuration. + +### appsettings.json Configuration + +```json +{ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "your-tenant-id", + "ClientId": "your-client-id", + "ClientCredentials": [ + { + "SourceType": "SignedAssertionFromManagedIdentity" + } + ] + }, + "DownstreamApis": { + "MicrosoftGraph": { + "BaseUrl": "https://graph.microsoft.com/v1.0", + "Scopes": ["User.Read", "Mail.Read"] + }, + "MyApi": { + "BaseUrl": "https://myapi.example.com", + "Scopes": ["api://myapi/read"] + } + } +} +``` + +**Note**: For daemon/console apps, set `appsettings.json` properties: **"Copy to Output Directory" = "Copy if newer"** + +[📖 Learn more about credentials configuration](../authentication/credentials/README.md) + +### Code-Based Configuration + +```csharp +// Explicit configuration in code +builder.Services.Configure(options => +{ + options.Instance = "https://login.microsoftonline.com/"; + options.TenantId = "your-tenant-id"; + options.ClientId = "your-client-id"; + options.ClientCredentials = new[] + { + CertificateDescription.FromKeyVault( + "https://myvault.vault.azure.net", + "MyCertificate") + }; +}); + +builder.Services.AddDownstreamApi("MyApi", options => +{ + options.BaseUrl = "https://myapi.example.com"; + options.Scopes = new[] { "api://myapi/read" }; +}); +``` + +## 🎭 Scenario-Specific Guides + +The best approach depends on where you're calling the API from: + +### From Web Apps +- **Primary pattern**: Delegated permissions (on behalf of user) +- **Token acquisition**: Happens automatically during sign-in +- **Special considerations**: Incremental consent, handling consent failures + +[📖 Read the Web Apps guide](from-web-apps.md) + +### From Web APIs +- **Primary pattern**: On-Behalf-Of (OBO) flow +- **Token acquisition**: Exchange incoming token for downstream token +- **Special considerations**: Long-running processes, token caching, agent identities. + +[📖 Read the Web APIs guide](from-web-apis.md) + +### From Daemon Apps scenarios (also happen in web apps and APIs) +- **Primary pattern**: Application permissions (app-only) +- **Token acquisition**: Client credentials flow +- **Special considerations**: No user context, requires admin consent + +[📖 Read the main documentation](../README.md#daemon-applications) + +## ⚠️ Error Handling + +All token acquisition methods can throw exceptions that you should handle. +In web apps the `[AuthorizeForScope(scopes)]` attribute handles user incremental +consent or re-signing. + +```csharp +using Microsoft.Identity.Abstractions; + +try +{ + var result = await _api.GetForUserAsync("MyApi", "api/data"); +} +catch (MicrosoftIdentityWebChallengeUserException ex) +{ + // User needs to sign in or consent to additional scopes + // In web apps, this triggers a redirect to Azure AD + throw; +} +catch (HttpRequestException ex) +{ + // Downstream API returned error + _logger.LogError(ex, "API call failed"); +} +``` + +### Common Error Scenarios + +| Exception | Meaning | Solution | +|-----------|---------|----------| +| `MicrosoftIdentityWebChallengeUserException` | User consent required | Redirect to Azure AD for consent. Use AuthorizeForScopes attribute or ConsentHandler class | +| `MsalUiRequiredException` | Interactive auth needed | Handle in web apps with challenge | +| `MsalServiceException` | Azure AD service error | Check configuration, retry | +| `HttpRequestException` | Downstream API error | Handle API-specific errors | + +## 🔗 Related Documentation + +- **[Credentials Configuration](../authentication/credentials/README.md)** - How to configure authentication credentials +- **[Web App Scenarios](../scenarios/web-apps/README.md)** - Building web applications +- **[Web API Scenarios](../scenarios/web-apis/README.md)** - Building and protecting APIs +- **[Agent Identities](../scenarios/agent-identities/README.md)** - Calling downstream APIs from agent identities. + +## 📦 NuGet Packages + +| Package | Purpose | When to Use | +|---------|---------|-------------| +| **Microsoft.Identity.Web.TokenAcquisition** | Token acquisition services | core package | +| **Microsoft.Identity.Web.DownstreamApi** | IDownstreamApi abstraction | Calling REST APIs | +| **Microsoft.Identity.Web.GraphServiceClient** | Microsoft Graph integration | Calling Microsoft Graph | +| **Microsoft.Identity.Web.Azure** | Azure SDK integration | Calling Azure services | +| **Microsoft.Identity.Web** | ASP.NET Core web apps and web APIs | ASP.NET Core | +| **Microsoft.Identity.Web.OWIN** | ASP.NET OWIN web apps and web APIs | OWIN | + +## 🎓 Next Steps + +1. **Choose your approach** using the decision tree above +2. **Read the scenario-specific guide** for your application type +3. **Configure credentials** following the [credentials guide](../authentication/credentials/README.md) +4. **Implement and test** using the code examples provided +5. **Handle errors** gracefully using the patterns shown + +--- + +**Version Support**: This documentation covers Microsoft.Identity.Web 3.14.1+ with .NET 8 and .NET 9 examples. diff --git a/docs/calling-downstream-apis/azure-sdks.md b/docs/calling-downstream-apis/azure-sdks.md new file mode 100644 index 000000000..8d1033674 --- /dev/null +++ b/docs/calling-downstream-apis/azure-sdks.md @@ -0,0 +1,499 @@ +# Calling Azure SDKs with MicrosoftIdentityTokenCredential + +This guide explains how to use `MicrosoftIdentityTokenCredential` from Microsoft.Identity.Web.Azure to authenticate Azure SDK clients (Storage, KeyVault, ServiceBus, etc.) with Microsoft Identity. + +## Overview + +The `MicrosoftIdentityTokenCredential` class implements Azure SDK's `TokenCredential` interface, enabling seamless integration between Microsoft.Identity.Web and Azure SDK clients. This allows you to use the same authentication configuration and token caching infrastructure across your entire application. + +### Benefits + +- **Unified Authentication**: Use the same auth configuration for web apps, APIs, and Azure services +- **Token Caching**: Automatic token caching and refresh +- **Delegated & App Permissions**: Support for both user and application tokens +- **Agent Identities**: Compatible with agent identities feature +- **Managed Identity**: Seamless integration with Azure Managed Identity + +## Installation + +Install the Azure integration package: + +```bash +dotnet add package Microsoft.Identity.Web.Azure +``` + +Then install the Azure SDK client packages you need: + +```bash +# Examples +dotnet add package Azure.Storage.Blobs +dotnet add package Azure.Security.KeyVault.Secrets +dotnet add package Azure.Messaging.ServiceBus +dotnet add package Azure.Data.Tables +``` + +## ASP.NET Core Setup + +### 1. Configure Services + +Add Azure token credential support: + +```csharp +using Microsoft.Identity.Web; + +var builder = WebApplication.CreateBuilder(args); + +// Add authentication +builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd")) + .EnableTokenAcquisitionToCallDownstreamApi() + .AddInMemoryTokenCaches(); + +// Add Azure token credential support +builder.Services.AddMicrosoftIdentityAzureTokenCredential(); + +builder.Services.AddControllersWithViews(); + +var app = builder.Build(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.Run(); +``` + +### 2. Configure appsettings.json + +```json +{ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "your-tenant-id", + "ClientId": "your-client-id", + "ClientCredentials": [ + { + "SourceType": "SignedAssertionFromManagedIdentity" + } + ] + } +} +``` + +## Using MicrosoftIdentityTokenCredential + +### Inject and Use with Azure SDK Clients + +This code sample shows how to use MicrosoftIdentityTokenCredential with the Blob Storage. The same principle applies to all Azure SDKs + +```csharp +using Azure.Storage.Blobs; +using Microsoft.Identity.Web; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +[Authorize] +public class StorageController : Controller +{ + private readonly MicrosoftIdentityTokenCredential _credential; + private readonly IConfiguration _configuration; + + public StorageController( + MicrosoftIdentityTokenCredential credential, + IConfiguration configuration) + { + _credential = credential; + _configuration = configuration; + } + + public async Task ListBlobs() + { + // Create Azure SDK client with credential + var blobClient = new BlobServiceClient( + new Uri($"https://{_configuration["StorageAccountName"]}.blob.core.windows.net"), + _credential); + + var container = blobClient.GetBlobContainerClient("mycontainer"); + var blobs = new List(); + + await foreach (var blob in container.GetBlobsAsync()) + { + blobs.Add(blob.Name); + } + + return View(blobs); + } +} +``` + +## Delegated Permissions (User Tokens) + +Call Azure services on behalf of signed-in user. + +### Azure Storage Example + +```csharp +using Azure.Storage.Blobs; +using Microsoft.Identity.Web; + +[Authorize] +public class FileController : Controller +{ + private readonly MicrosoftIdentityTokenCredential _credential; + + public FileController(MicrosoftIdentityTokenCredential credential) + { + _credential = credential; + } + + public async Task UploadFile(IFormFile file) + { + // Credential will automatically acquire delegated token + var blobClient = new BlobServiceClient( + new Uri("https://myaccount.blob.core.windows.net"), + _credential); + + var container = blobClient.GetBlobContainerClient("uploads"); + await container.CreateIfNotExistsAsync(); + + var blob = container.GetBlobClient(file.FileName); + await blob.UploadAsync(file.OpenReadStream(), overwrite: true); + + return Ok($"File {file.FileName} uploaded"); + } +} +``` + + +## Application Permissions (App-Only Tokens) + +Call Azure services with application permissions (no user context). + +### Configuration + +```csharp +public class AzureService +{ + private readonly MicrosoftIdentityTokenCredential _credential; + + public AzureService(MicrosoftIdentityTokenCredential credential) + { + _credential = credential; + } + + public async Task> ListBlobsAsync() + { + // Configure credential for app-only token + _credential.Options.RequestAppToken = true; + + var blobClient = new BlobServiceClient( + new Uri("https://myaccount.blob.core.windows.net"), + _credential); + + var container = blobClient.GetBlobContainerClient("data"); + var blobs = new List(); + + await foreach (var blob in container.GetBlobsAsync()) + { + blobs.Add(blob.Name); + } + + return blobs; + } +} +``` + +### Daemon Application Example + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using Azure.Storage.Blobs; + +class Program +{ + static async Task Main(string[] args) + { + // Build service provider + var tokenAcquirerFactory = TokenAcquirerFactory.GetDefaultInstance(); + tokenAcquirerFactory.Services.AddMicrosoftIdentityAzureTokenCredential(); + var sp = tokenAcquirerFactory.Build(); + + // Get credential + var credential = sp.GetRequiredService(); + credential.Options.RequestAppToken = true; + + // Use with Azure SDK + var blobClient = new BlobServiceClient( + new Uri("https://myaccount.blob.core.windows.net"), + credential); + + var container = blobClient.GetBlobContainerClient("data"); + + await foreach (var blob in container.GetBlobsAsync()) + { + Console.WriteLine($"Blob: {blob.Name}"); + } + } +} +``` + +## Using with Agent Identities + +`MicrosoftIdentityTokenCredential` supports agent identities through the `Options` property: + +```csharp +using Microsoft.Identity.Web; + +public class AgentService +{ + private readonly MicrosoftIdentityTokenCredential _credential; + + public AgentService(MicrosoftIdentityTokenCredential credential) + { + _credential = credential; + } + + public async Task> ListBlobsForAgentAsync(string agentIdentity) + { + // Configure for agent identity + _credential.Options.WithAgentIdentity(agentIdentity); + _credential.Options.RequestAppToken = true; + + var blobClient = new BlobServiceClient( + new Uri("https://myaccount.blob.core.windows.net"), + _credential); + + var container = blobClient.GetBlobContainerClient("agent-data"); + var blobs = new List(); + + await foreach (var blob in container.GetBlobsAsync()) + { + blobs.Add(blob.Name); + } + + return blobs; + } + + public async Task GetSecretForAgentUserAsync(string agentIdentity, Guid userOid, string secretName) + { + // Configure for agent user identity + _credential.Options.WithAgentUserIdentity(agentIdentity, userOid); + + var secretClient = new SecretClient( + new Uri("https://myvault.vault.azure.net"), + _credential); + + var secret = await secretClient.GetSecretAsync(secretName); + return secret.Value.Value; + } +} +``` + +See [Agent Identities documentation](../scenarios/agent-identities/README.md) for more details. + +## FIC+Managed Identity Integration + +`MicrosoftIdentityTokenCredential` works seamlessly with FIC+Azure Managed Identity: + +### Configuration for Managed Identity + +```json +{ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "your-tenant-id", + "ClientId": "your-client-id", + "ClientCredentials": [ + { + "SourceType": "SignedAssertionFromManagedIdentity" + } + ] + } +} +``` + +### Using System-Assigned Managed Identity + +```csharp +// No additional code needed! +// When deployed to Azure, the credential automatically uses managed identity + +public class StorageService +{ + private readonly MicrosoftIdentityTokenCredential _credential; + + public StorageService(MicrosoftIdentityTokenCredential credential) + { + _credential = credential; + _credential.Options.RequestAppToken = true; + } + + public async Task> ListContainersAsync() + { + // Uses managed identity when running in Azure + var blobClient = new BlobServiceClient( + new Uri("https://myaccount.blob.core.windows.net"), + _credential); + + var containers = new List(); + await foreach (var container in blobClient.GetBlobContainersAsync()) + { + containers.Add(container.Name); + } + + return containers; + } +} +``` + +### Using User-Assigned Managed Identity + +```json +{ + "AzureAd": { + "ClientCredentials": [ + { + "SourceType": "SignedAssertionFromManagedIdentity", + "ManagedIdentityClientId": "user-assigned-identity-client-id" + } + ] + } +} +``` + +## OWIN Implementation + +For ASP.NET applications using OWIN: + +```csharp +using Microsoft.Identity.Web; +using Microsoft.Identity.Web.OWIN; +using Owin; + +public class Startup +{ + public void Configuration(IAppBuilder app) + { + app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType); + app.UseCookieAuthentication(new CookieAuthenticationOptions()); + + OwinTokenAcquirerFactory factory = TokenAcquirerFactory.GetDefaultInstance(); + + app.AddMicrosoftIdentityWebApp(factory); + factory.Services + .AddMicrosoftIdentityAzureTokenCredential(); + factory.Build(); + } +} +``` + +## Best Practices + +### 1. Reuse Azure SDK Clients + +Azure SDK clients are thread-safe and should be reused, but `MicrosoftIdentityTokenCredential` is a scoped service. You can't use it with AddAzureServices() which creates singletons. + +### 2. Use Managed Identity in Production + +```csharp +// ✅ Good: Certificateless auth with managed identity +{ + "ClientCredentials": [ + { + "SourceType": "SignedAssertionFromManagedIdentity" + } + ] +} +``` + +### 3. Handle Azure SDK Exceptions + +```csharp +using Azure; + +try +{ + var blob = await blobClient.DownloadAsync(); +} +catch (RequestFailedException ex) when (ex.Status == 404) +{ + // Blob not found +} +catch (RequestFailedException ex) when (ex.Status == 403) +{ + // Insufficient permissions +} +catch (RequestFailedException ex) +{ + _logger.LogError(ex, "Azure SDK call failed with status {Status}", ex.Status); +} +``` + +### 5. Use Configuration for URIs + +```csharp +// ❌ Bad: Hardcoded URIs +var blobClient = new BlobServiceClient(new Uri("https://myaccount.blob.core.windows.net"), credential); + +// ✅ Good: Configuration-driven +var storageUri = _configuration["Azure:Storage:Uri"]; +var blobClient = new BlobServiceClient(new Uri(storageUri), credential); +``` + +## Troubleshooting + +### Error: "ManagedIdentityCredential authentication failed" + +**Cause**: Managed identity not enabled or misconfigured. + +**Solution**: +- Enable managed identity on Azure resource (App Service, VM, etc.) +- For user-assigned identity, specify `ManagedIdentityClientId` +- Verify identity has required role assignments + +### Error: "This request is not authorized to perform this operation" + +**Cause**: Missing Azure RBAC role assignment. + +**Solution**: +- Assign appropriate role to managed identity or user +- Example: "Storage Blob Data Contributor" for blob operations +- Wait up to 5 minutes for role assignments to propagate + +### Token Acquisition Fails Locally + +**Cause**: Managed identity only works in Azure. + +**Solution**: Use different credential source locally: + +```json +{ + "ClientCredentials": [ + { + "SourceType": "ClientSecret", + "ClientSecret": "secret-for-local-dev" + } + ] +} +``` + +### Scope Errors with Azure Resources + +**Cause**: Incorrect scope format. + +**Solution**: Use Azure resource-specific scopes: +- Storage: `https://storage.azure.com/user_impersonation` or `.default` +- KeyVault: `https://vault.azure.net/user_impersonation` or `.default` +- Service Bus: `https://servicebus.azure.net/user_impersonation` or `.default` + +## Related Documentation + +- [Azure SDK for .NET Documentation](https://learn.microsoft.com/dotnet/azure/sdk/overview) +- [Managed Identity Documentation](https://learn.microsoft.com/azure/active-directory/managed-identities-azure-resources/overview) +- [Credentials Configuration](../authentication/credentials/README.md) +- [Agent Identities](../scenarios/agent-identities/README.md) +- [Calling Downstream APIs Overview](README.md) + +--- + +**Next Steps**: Learn about [calling custom APIs](custom-apis.md) with IDownstreamApi and IAuthorizationHeaderProvider. diff --git a/docs/calling-downstream-apis/custom-apis.md b/docs/calling-downstream-apis/custom-apis.md new file mode 100644 index 000000000..01d511d03 --- /dev/null +++ b/docs/calling-downstream-apis/custom-apis.md @@ -0,0 +1,762 @@ +# Calling Custom APIs + +This guide explains the different approaches for calling your own protected APIs using Microsoft.Identity.Web: IDownstreamApi, IAuthorizationHeaderProvider, and MicrosoftIdentityMessageHandler. + +## Overview + +When calling custom REST APIs, you have three main options depending on your needs: + +| Approach | Complexity | Flexibility | Use Case | +|----------|------------|-------------|----------| +| **IDownstreamApi** | Low | Medium | Standard REST APIs with configuration | +| **MicrosoftIdentityMessageHandler** | Medium | High | HttpClient with DI and composable pipeline | +| **IAuthorizationHeaderProvider** | High | Very High | Complete control over HTTP requests | + +## IDownstreamApi - Recommended for Most Scenarios + +`IDownstreamApi` provides a simple, configuration-driven approach for calling REST APIs with automatic token acquisition. + +### Installation + +```bash +dotnet add package Microsoft.Identity.Web.DownstreamApi +``` + +### Configuration + +Define your API in appsettings.json: + +```json +{ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "your-tenant-id", + "ClientId": "your-client-id", + "ClientCredentials": [ + { + "SourceType": "ClientSecret", + "ClientSecret": "your-client-secret" + } + ] + }, + "DownstreamApis": { + "MyApi": { + "BaseUrl": "https://api.example.com", + "Scopes": ["api://my-api-client-id/read", "api://my-api-client-id/write"], + "RelativePath": "api/v1", + "RequestAppToken": false + }, + "PartnerApi": { + "BaseUrl": "https://partner.example.com", + "Scopes": ["api://partner-api-id/.default"], + "RequestAppToken": true + } + } +} +``` + +### ASP.NET Core Setup + +```csharp +using Microsoft.Identity.Web; + +var builder = WebApplication.CreateBuilder(args); + +// Add authentication +builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd")) + .EnableTokenAcquisitionToCallDownstreamApi() + .AddInMemoryTokenCaches(); + +// Register downstream APIs +builder.Services.AddDownstreamApis( + builder.Configuration.GetSection("DownstreamApis")); + +builder.Services.AddControllersWithViews(); + +var app = builder.Build(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.Run(); +``` + +### Basic Usage + +```csharp +using Microsoft.Identity.Abstractions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +[Authorize] +public class ProductsController : Controller +{ + private readonly IDownstreamApi _api; + + public ProductsController(IDownstreamApi api) + { + _api = api; + } + + // GET request + public async Task Index() + { + var products = await _api.GetForUserAsync>( + "MyApi", + "products"); + + return View(products); + } + + // Call downstream API with GET request with query parameters + public async Task Details(int id) + { + var product = await _api.GetForUserAsync( + "MyApi", + $"products/{id}"); + + return View(product); + } + + // Call downstream API with POST request + [HttpPost] + public async Task Create([FromBody] Product product) + { + var created = await _api.PostForUserAsync( + "MyApi", + "products", + product); + + return CreatedAtAction(nameof(Details), new { id = created.Id }, created); + } + + // Call downstream API with PUT request + [HttpPut("{id}")] + public async Task Update(int id, [FromBody] Product product) + { + var updated = await _api.PutForUserAsync( + "MyApi", + $"products/{id}", + product); + + return Ok(updated); + } + + // Call downstream API with DELETE request + [HttpDelete("{id}")] + public async Task Delete(int id) + { + await _api.DeleteForUserAsync( + "MyApi", + $"products/{id}"); + + return NoContent(); + } +} +``` + +### Advanced IDownstreamApi Options + +#### Custom Headers and Options + +```csharp +public async Task GetDataWithHeaders() +{ + var options = new DownstreamApiOptions + { + CustomizeHttpRequestMessage = message => + { + message.Headers.Add("X-Custom-Header", "MyValue"); + message.Headers.Add("X-Request-Id", Guid.NewGuid().ToString()); + message.Headers.Add("X-Correlation-Id", HttpContext.TraceIdentifier); + } + }; + + var data = await _api.CallApiForUserAsync( + "MyApi", + options, + content: null); + + return Ok(data); +} +``` + +#### Override Configuration Per Request + +```csharp +public async Task CallDifferentEndpoint() +{ + var options = new DownstreamApiOptions + { + BaseUrl = "https://alternative-api.example.com", + RelativePath = "v2/data", + Scopes = new[] { "api://alternative/.default" }, + RequestAppToken = true + }; + + var data = await _api.CallApiForAppAsync( + "MyApi", + options); + + return Ok(data); +} +``` + +#### Query Parameters + +```csharp +public async Task Search(string query, int page, int pageSize) +{ + var options = new DownstreamApiOptions + { + RelativePath = $"search?q={Uri.EscapeDataString(query)}&page={page}&pageSize={pageSize}" + }; + + var results = await _api.GetForUserAsync( + "MyApi", + options); + + return Ok(results); +} +``` + +You can also use the options.ExtraQueryParameters dictionary. + +#### Handling Response Headers + +```csharp +public async Task GetWithHeaders() +{ + var response = await _api.CallApiAsync( + "MyApi", + options => + { + options.RelativePath = "data"; + }); + + // Access response headers + if (response.Headers.TryGetValues("X-RateLimit-Remaining", out var values)) + { + var remaining = values.FirstOrDefault(); + _logger.LogInformation("Rate limit remaining: {Remaining}", remaining); + } + + return Ok(response.Content); +} +``` + +### App-Only Tokens with IDownstreamApi + +```csharp +[ApiController] +[Route("api/[controller]")] +public class DataController : ControllerBase +{ + private readonly IDownstreamApi _api; + + public DataController(IDownstreamApi api) + { + _api = api; + } + + [HttpGet("batch")] + public async Task GetBatchData() + { + // Call with application permissions + var data = await _api.GetForAppAsync( + "MyApi", + "batch/process"); + + return Ok(data); + } +} +``` + +## MicrosoftIdentityMessageHandler - For HttpClient Integration + +`MicrosoftIdentityMessageHandler` is a `DelegatingHandler` that adds authentication to HttpClient requests. Use this when you need full HttpClient functionality with automatic token acquisition. + +### When to Use + +- You need fine-grained control over HTTP requests +- You want to compose multiple message handlers +- You're integrating with existing HttpClient-based code +- You need access to raw HttpResponseMessage + +### Configuration + +```csharp +using Microsoft.Identity.Web; +using Microsoft.Identity.Abstractions; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd")) + .EnableTokenAcquisitionToCallDownstreamApi() + .AddInMemoryTokenCaches(); + +// Configure named HttpClient with authentication +builder.Services.AddHttpClient("MyApiClient", client => +{ + client.BaseAddress = new Uri("https://api.example.com"); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + client.Timeout = TimeSpan.FromSeconds(30); +}) +.AddHttpMessageHandler(sp => +{ + var authProvider = sp.GetRequiredService(); + return new MicrosoftIdentityMessageHandler( + authProvider, + new MicrosoftIdentityMessageHandlerOptions + { + Scopes = new[] { "api://my-api-client-id/read" }, + RequestAppToken = false // Use delegated token + }); +}); + +builder.Services.AddControllersWithViews(); + +var app = builder.Build(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.Run(); +``` + +### Basic Usage + +```csharp +using System.Net.Http.Json; + +[Authorize] +public class ProductsController : Controller +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public ProductsController( + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _httpClient = httpClientFactory.CreateClient("MyApiClient"); + _logger = logger; + } + + public async Task Index() + { + // Token automatically added by MicrosoftIdentityMessageHandler + var response = await _httpClient.GetAsync("api/products"); + response.EnsureSuccessStatusCode(); + + var products = await response.Content.ReadFromJsonAsync>(); + return View(products); + } + + [HttpPost] + public async Task Create([FromBody] Product product) + { + var response = await _httpClient.PostAsJsonAsync("api/products", product); + response.EnsureSuccessStatusCode(); + + var created = await response.Content.ReadFromJsonAsync(); + return CreatedAtAction(nameof(Details), new { id = created.Id }, created); + } +} +``` + +### Per-Request Authentication Options + +Override authentication options for specific requests: + +```csharp +public async Task GetAdminData() +{ + var request = new HttpRequestMessage(HttpMethod.Get, "api/admin/data"); + + // Override authentication options for this request + request.WithAuthenticationOptions(options => + { + options.Scopes = new[] { "api://my-api/.default" }; + options.RequestAppToken = true; // Use app token instead + }); + + var response = await _httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var data = await response.Content.ReadFromJsonAsync(); + return Ok(data); +} +``` + +### Composing Multiple Message Handlers + +```csharp +builder.Services.AddHttpClient("MyApiClient", client => +{ + client.BaseAddress = new Uri("https://api.example.com"); +}) +.AddHttpMessageHandler(sp => +{ + // First handler: Add authentication + var authProvider = sp.GetRequiredService(); + return new MicrosoftIdentityMessageHandler(authProvider, new MicrosoftIdentityMessageHandlerOptions + { + Scopes = new[] { "api://my-api/read" } + }); +}) +.AddHttpMessageHandler() // Second handler: Logging +.AddHttpMessageHandler() // Third handler: Retry logic +.AddPolicyHandler(GetRetryPolicy()); // Polly retry policy +``` + +### Custom Message Handlers + +```csharp +public class LoggingHandler : DelegatingHandler +{ + private readonly ILogger _logger; + + public LoggingHandler(ILogger logger) + { + _logger = logger; + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + _logger.LogInformation("Request: {Method} {Uri}", request.Method, request.RequestUri); + + var response = await base.SendAsync(request, cancellationToken); + + _logger.LogInformation("Response: {StatusCode}", response.StatusCode); + + return response; + } +} +``` + +## IAuthorizationHeaderProvider - Maximum Control + +`IAuthorizationHeaderProvider` gives you direct access to authorization headers for complete control over HTTP requests. + +### When to Use + +- You need complete control over HTTP request construction +- You're integrating with non-standard HTTP APIs +- You need to use HttpClient without DI +- You're building custom HTTP abstractions + +### Basic Usage + +```csharp +using Microsoft.Identity.Abstractions; + +[Authorize] +public class CustomApiController : Controller +{ + private readonly IAuthorizationHeaderProvider _headerProvider; + private readonly ILogger _logger; + + public CustomApiController( + IAuthorizationHeaderProvider headerProvider, + ILogger logger) + { + _headerProvider = headerProvider; + _logger = logger; + } + + public async Task GetData() + { + // Get authorization header (includes "Bearer " prefix) + var authHeader = await _headerProvider.CreateAuthorizationHeaderForUserAsync( + scopes: new[] { "api://my-api/read" }); + + using var client = new HttpClient(); + client.DefaultRequestHeaders.Add("Authorization", authHeader); + client.DefaultRequestHeaders.Add("X-Custom-Header", "MyValue"); + + var response = await client.GetAsync("https://api.example.com/data"); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + return Content(content, "application/json"); + } +} +``` + +### App-Only Tokens + +```csharp +public async Task GetBackgroundData() +{ + // Get app-only authorization header + var authHeader = await _headerProvider.CreateAuthorizationHeaderForAppAsync( + scopes: new[] { "api://my-api/.default" }); + + using var client = new HttpClient(); + client.DefaultRequestHeaders.Add("Authorization", authHeader); + + var response = await client.GetAsync("https://api.example.com/background"); + var data = await response.Content.ReadFromJsonAsync(); + + return Ok(data); +} +``` + +### With Custom HTTP Libraries + +```csharp +public async Task CallWithRestSharp() +{ + var authHeader = await _headerProvider.CreateAuthorizationHeaderForUserAsync( + scopes: new[] { "api://my-api/read" }); + + // Example with RestSharp + var client = new RestClient("https://api.example.com"); + var request = new RestRequest("data", Method.Get); + request.AddHeader("Authorization", authHeader); + + var response = await client.ExecuteAsync(request); + + return Ok(response.Data); +} +``` + +### Advanced Options + +```csharp +public async Task GetDataWithOptions() +{ + var options = new AuthorizationHeaderProviderOptions + { + Scopes = new[] { "api://my-api/read" }, + RequestAppToken = false, + AcquireTokenOptions = new AcquireTokenOptions + { + AuthenticationOptionsName = JwtBearerDefaults.AuthenticationScheme, + ForceRefresh = false, + Claims = null + } + }; + + var authHeader = await _headerProvider.CreateAuthorizationHeaderAsync(options); + + using var client = new HttpClient(); + client.DefaultRequestHeaders.Add("Authorization", authHeader); + + var response = await client.GetAsync("https://api.example.com/data"); + var data = await response.Content.ReadFromJsonAsync(); + + return Ok(data); +} +``` + +## Comparison and Decision Guide + +### Use IDownstreamApi When: + +✅ Calling standard REST APIs +✅ Want configuration-driven approach +✅ Need automatic serialization/deserialization +✅ Want minimal code +✅ Following Microsoft.Identity.Web patterns + +**Example:** +```csharp +var product = await _api.GetForUserAsync("MyApi", "products/123"); +``` + +### Use MicrosoftIdentityMessageHandler When: + +✅ Need full HttpClient capabilities +✅ Want to compose multiple handlers +✅ Using HttpClientFactory patterns +✅ Need access to HttpResponseMessage +✅ Integrating with existing HttpClient code + +**Example:** +```csharp +var response = await _httpClient.GetAsync("api/products/123"); +var product = await response.Content.ReadFromJsonAsync(); +``` + +### Use IAuthorizationHeaderProvider When: + +✅ Need complete control over HTTP requests +✅ Using custom HTTP libraries +✅ Building custom abstractions +✅ Can't use HttpClientFactory +✅ Need to manually construct requests + +**Example:** +```csharp +var authHeader = await _headerProvider.CreateAuthorizationHeaderForUserAsync(scopes); +client.DefaultRequestHeaders.Add("Authorization", authHeader); +``` + +## Error Handling + +### IDownstreamApi Errors + +```csharp +try +{ + var data = await _api.GetForUserAsync("MyApi", "data"); +} +catch (MicrosoftIdentityWebChallengeUserException ex) +{ + // User needs to consent + _logger.LogWarning(ex, "Consent required for scopes: {Scopes}", string.Join(", ", ex.Scopes)); + throw; // Let ASP.NET Core handle consent flow +} +catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) +{ + return NotFound("Resource not found"); +} +catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized) +{ + return Unauthorized("API returned 401"); +} +catch (Exception ex) +{ + _logger.LogError(ex, "API call failed"); + return StatusCode(500, "An error occurred"); +} +``` + +### MicrosoftIdentityMessageHandler Errors + +```csharp +try +{ + var response = await _httpClient.GetAsync("api/data"); + + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadAsStringAsync(); + _logger.LogError("API returned {StatusCode}: {Error}", response.StatusCode, error); + return StatusCode((int)response.StatusCode, error); + } + + var data = await response.Content.ReadFromJsonAsync(); + return Ok(data); +} +catch (HttpRequestException ex) +{ + _logger.LogError(ex, "HTTP request failed"); + return StatusCode(500, "Failed to call API"); +} +``` + +## Best Practices + +### 1. Configure Timeout Values + +```csharp +builder.Services.AddDownstreamApi("MyApi", options => +{ + options.BaseUrl = "https://api.example.com"; + options.HttpClientName = "MyApi"; +}); + +builder.Services.AddHttpClient("MyApi", client => +{ + client.Timeout = TimeSpan.FromSeconds(30); +}); +``` + +### 2. Use Typed Clients + +```csharp +public interface IProductApiClient +{ + Task> GetProductsAsync(); + Task GetProductAsync(int id); + Task CreateProductAsync(Product product); +} + +public class ProductApiClient : IProductApiClient +{ + private readonly IDownstreamApi _api; + + public ProductApiClient(IDownstreamApi api) + { + _api = api; + } + + public Task> GetProductsAsync() => + _api.GetForUserAsync>("MyApi", "products"); + + public Task GetProductAsync(int id) => + _api.GetForUserAsync("MyApi", $"products/{id}"); + + public Task CreateProductAsync(Product product) => + _api.PostForUserAsync("MyApi", "products", product); +} + +// Register +builder.Services.AddScoped(); +``` + +### 3. Log Request Details + +```csharp +public async Task GetDataWithLogging() +{ + _logger.LogInformation("Calling MyApi for data"); + + var stopwatch = Stopwatch.StartNew(); + + try + { + var data = await _api.GetForUserAsync("MyApi", "data"); + + stopwatch.Stop(); + _logger.LogInformation("API call succeeded in {ElapsedMs}ms", stopwatch.ElapsedMilliseconds); + + return Ok(data); + } + catch (Exception ex) + { + stopwatch.Stop(); + _logger.LogError(ex, "API call failed after {ElapsedMs}ms", stopwatch.ElapsedMilliseconds); + throw; + } +} +``` + +## OWIN Implementation + +```csharp +using Microsoft.Identity.Web; +using Microsoft.Identity.Web.OWIN; +using Owin; + +public class Startup +{ + public void Configuration(IAppBuilder app) + { + OwinTokenAcquirerFactory factory = TokenAcquirerFactory.GetDefaultInstance(); + + app.AddMicrosoftIdentityWebApp(factory); + factory.Services + .AddDownstreamApis(factory.Configuration.GetSection("DownstreamAPI")) + .AddInMemoryTokenCaches(); + factory.Build(); + } +} +``` + +## Related Documentation + +- [IDownstreamApi Reference](../api-reference/idownstreamapi.md) +- [Calling from Web Apps](from-web-apps.md) +- [Calling from Web APIs](from-web-apis.md) +- [Microsoft Graph Integration](microsoft-graph.md) +- [Agent Identities](../scenarios/agent-identities/README.md) + +--- + +**Next Steps**: Review the [main documentation](README.md) for decision tree and comparison of all approaches. diff --git a/docs/calling-downstream-apis/from-web-apis.md b/docs/calling-downstream-apis/from-web-apis.md new file mode 100644 index 000000000..0f6d456be --- /dev/null +++ b/docs/calling-downstream-apis/from-web-apis.md @@ -0,0 +1,592 @@ +# Calling Downstream APIs from Web APIs + +This guide explains how to call downstream APIs from ASP.NET Core and OWIN web APIs using Microsoft.Identity.Web, focusing on the **On-Behalf-Of (OBO) flow** where your API receives a token from a client and exchanges it for a new token to call another API. + +## Overview + +The On-Behalf-Of (OBO) flow enables your web API to call downstream APIs on behalf of the user who called your API. This maintains the user's identity and permissions throughout the call chain. + +### On-Behalf-Of Flow + +```mermaid +sequenceDiagram + participant Client as Client App + participant YourAPI as Your Web API + participant AzureAD as Azure AD + participant DownstreamAPI as Downstream API + + Client->>YourAPI: 1. Call with access token + Note over YourAPI: Validate token + YourAPI->>AzureAD: 2. OBO request with user token + AzureAD->>AzureAD: 3. Validate & check consent + AzureAD->>YourAPI: 4. New access token for downstream API + Note over YourAPI: Cache token for user + YourAPI->>DownstreamAPI: 5. Call with new token + DownstreamAPI->>YourAPI: 6. Return data + YourAPI->>Client: 7. Return processed data +``` + +## Prerequisites + +- Web API configured with JWT Bearer authentication +- App registration with API permissions to downstream API +- Client app must have permissions to call your API +- User must have consented to both your API and downstream API + +## ASP.NET Core Implementation + +### 1. Configure Authentication + +Set up JWT Bearer authentication with explicit authentication scheme: + +```csharp +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Identity.Web; + +var builder = WebApplication.CreateBuilder(args); + +// Add authentication with explicit scheme +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")) + .EnableTokenAcquisitionToCallDownstreamApi() + .AddInMemoryTokenCaches(); + +builder.Services.AddAuthorization(); +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); +app.Run(); +``` + +### 2. Configure appsettings.json + +```json +{ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "your-tenant-id", + "ClientId": "your-api-client-id", + "ClientCredentials": [ + { + "SourceType": "ClientSecret", + "ClientSecret": "your-client-secret" + } + ], + "Audience": "api://your-api-client-id" + }, + "DownstreamApis": { + "GraphAPI": { + "BaseUrl": "https://graph.microsoft.com/v1.0", + "Scopes": ["https://graph.microsoft.com/.default"] + }, + "PartnerAPI": { + "BaseUrl": "https://partnerapi.example.com", + "Scopes": ["api://partner-api-id/read"] + } + } +} +``` + +### 3. Add Downstream API Support + +```csharp +using Microsoft.Identity.Web; + +builder.Services.AddDownstreamApis( + builder.Configuration.GetSection("DownstreamApis")); +``` + +### 4. Call Downstream API from Your API + +```csharp +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Identity.Web; +using Microsoft.Identity.Abstractions; + +[Authorize] +[ApiController] +[Route("api/[controller]")] +public class DataController : ControllerBase +{ + private readonly IDownstreamApi _downstreamApi; + private readonly ILogger _logger; + + public DataController( + IDownstreamApi downstreamApi, + ILogger logger) + { + _downstreamApi = downstreamApi; + _logger = logger; + } + + [HttpGet("userdata")] + public async Task> GetUserData() + { + try + { + // Call downstream API using OBO flow + // Token from incoming request is automatically used + var userData = await _downstreamApi.GetForUserAsync( + "PartnerAPI", + "api/users/me"); + + return Ok(userData); + } + catch (MicrosoftIdentityWebChallengeUserException ex) + { + // User needs to consent to downstream API permissions + _logger.LogWarning(ex, "User consent required for downstream API"); + return Unauthorized(new { error = "consent_required", scopes = ex.Scopes }); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Downstream API call failed"); + return StatusCode(500, "Failed to retrieve data from downstream service"); + } + } + + [HttpPost("process")] + public async Task> ProcessData([FromBody] DataRequest request) + { + // Call downstream API with POST + var result = await _downstreamApi.PostForUserAsync( + "PartnerAPI", + "api/process", + request); + + return Ok(result); + } +} +``` + +## Token cache + +### In-Memory Cache (Development) + +```csharp +builder.Services.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")) + .EnableTokenAcquisitionToCallDownstreamApi() + .AddInMemoryTokenCaches(); +``` + +⚠️ **Warning**: Use distributed cache for production. + +### Distributed Cache (Production) + +For production APIs with multiple instances, use distributed caching: + +```csharp +using Microsoft.Extensions.Caching.StackExchangeRedis; + +builder.Services.AddStackExchangeRedisCache(options => +{ + options.Configuration = builder.Configuration.GetConnectionString("Redis"); + options.InstanceName = "MyWebApi"; +}); + +builder.Services.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")) + .EnableTokenAcquisitionToCallDownstreamApi() + .AddDistributedTokenCaches(); +``` + +### Other Distributed Cache Options + +```csharp +// SQL Server +builder.Services.AddDistributedSqlServerCache(options => +{ + options.ConnectionString = builder.Configuration.GetConnectionString("TokenCacheDb"); + options.SchemaName = "dbo"; + options.TableName = "TokenCache"; +}); + +// Cosmos DB +builder.Services.AddCosmosDbTokenCaches(options => +{ + options.DatabaseId = "TokenCache"; + options.ContainerId = "Tokens"; +}); +``` + +## Long-Running Processes with OBO + +For long-running background processes, you need special handling because the user's token may expire. + +### The Challenge + +```mermaid +graph TD + A[Client calls API] --> B[API receives user token] + B --> C[API starts long process] + C --> D{Token expires?} + D -->|Yes| E[❌ OBO fails] + D -->|No| F[✅ OBO succeeds] + + style E fill:#f8d7da + style F fill:#d4edda +``` + +### Long-Running Process Pattern + +```csharp +[Authorize] +[ApiController] +[Route("api/[controller]")] +public class ProcessingController : ControllerBase +{ + private readonly IDownstreamApi _downstreamApi; + private readonly IBackgroundTaskQueue _taskQueue; + + public ProcessingController( + IDownstreamApi downstreamApi, + IBackgroundTaskQueue taskQueue) + { + _downstreamApi = downstreamApi; + _taskQueue = taskQueue; + } + + [HttpPost("start")] + public async Task> StartLongProcess([FromBody] ProcessRequest request) + { + var processId = Guid.NewGuid(); + + // Queue the long-running task + _taskQueue.QueueBackgroundWorkItem(async (cancellationToken) => + { + await ProcessDataAsync(processId, request, cancellationToken); + }); + + return Accepted(new ProcessStatus + { + ProcessId = processId, + Status = "Started" + }); + } + + private async Task ProcessDataAsync( + Guid processId, + ProcessRequest request, + CancellationToken cancellationToken) + { + try + { + // The cached refresh token allows token acquisition even if original token expired + var data = await _downstreamApi.GetForUserAsync( + "PartnerAPI", + options => { + options.RelativePath = "api/process/data"; + options.AcquireTokenOptions.LongRunningWebApiSessionKey = processId.ToString() + }, + cancellationToken: cancellationToken); + + // Process data... + await Task.Delay(TimeSpan.FromMinutes(5), cancellationToken); + + // Call API again (token may need refresh) + await _downstreamApi.PostForUserAsync( + "PartnerAPI", + options => { + options.RelativePath = "api/process/complete"; + options.AcquireTokenOptions.LongRunningWebApiSessionKey = processId.ToString() + }, + data, + cancellationToken: cancellationToken); + } + catch (Exception ex) + { + // Log error and update process status + } + } +} +``` + +### Important Considerations + +2. **Token Cache**: Must use distributed cache for background processes +3. **User Context**: `HttpContext.User` is available in the background worker +4. **Error Handling**: Token may still expire if user revokes consent + +## Error Handling Specific to APIs + +### MicrosoftIdentityWebChallengeUserException + +In web APIs, you can't redirect users to consent. Instead, return a proper error response: + +```csharp +[HttpGet("data")] +public async Task GetData() +{ + try + { + var data = await _downstreamApi.GetForUserAsync("PartnerAPI", "api/data"); + return Ok(data); + } + catch (MicrosoftIdentityWebChallengeUserException ex) + { + // Return 401 with consent information + return Unauthorized(new + { + error = "consent_required", + error_description = "Additional user consent required", + scopes = ex.Scopes, + claims = ex.Claims + }); + } +} +``` + +### Client Handling Consent Requirement + +The client app should handle the 401 response and trigger consent: + +```csharp +// Client app code +var response = await httpClient.GetAsync("https://yourapi.example.com/api/data"); + +if (response.StatusCode == HttpStatusCode.Unauthorized) +{ + var error = await response.Content.ReadFromJsonAsync(); + + if (error?.error == "consent_required") + { + // Trigger incremental consent in client app + // This will redirect user to Azure AD for consent + throw new MsalUiRequiredException(error.error_description, error.scopes); + } +} +``` + +### Downstream API Failures + +```csharp +[HttpGet("data")] +public async Task GetData() +{ + try + { + var data = await _downstreamApi.GetForUserAsync("PartnerAPI", "api/data"); + return Ok(data); + } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return NotFound("Resource not found in downstream service"); + } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.BadRequest) + { + return BadRequest("Invalid request to downstream service"); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Downstream API returned {StatusCode}", ex.StatusCode); + return StatusCode(502, "Downstream service error"); + } +} +``` + +## OWIN Implementation (.NET Framework) + +### 1. Configure Startup.cs + +```csharp +using Microsoft.Identity.Web; +using Microsoft.Identity.Web.OWIN; +using Owin; + +public class Startup +{ + public void Configuration(IAppBuilder app) + { + OwinTokenAcquirerFactory factory = TokenAcquirerFactory.GetDefaultInstance(); + app.AddMicrosoftIdentityWebApi(factory); + factory.Services + .AddMicrosoftGraph() + .AddDownstreamApis(factory.Configuration.GetSection("DownstreamAPIs")); + factory.Build(); + } +} +``` + +### 2. Call API from Controllers + +```csharp +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using System.Web.Http; + +[Authorize] +public class DataController : ApiController +{ + private readonly IDownstreamApi _downstreamApi; + + public DataController() + { + GraphServiceClient graphServiceClient = this.GetGraphServiceClient(); + var me = await graphServiceClient.Me.Request().GetAsync(); + + // OR - Example calling a downstream directly with the IDownstreamApi helper (uses the + // authorization header provider, encapsulates MSAL.NET) + // downstreamApi won't be null if you added services.AddMicrosoftGraph() + // in the Startup.auth.cs + IDownstreamApi downstreamApi = this.GetDownstreamApi(); + var result = await downstreamApi.CallApiForUserAsync("DownstreamAPI"); + + // OR - Get an authorization header (uses the token acquirer) + IAuthorizationHeaderProvider authorizationHeaderProvider = + this.GetAuthorizationHeaderProvider(); + } + + [HttpGet] + [Route("api/data")] + public async Task GetData() + { + var data = await _downstreamApi.GetForUserAsync( + "PartnerAPI", + options => options.RelativePath = "api/data", + options => options.Scopes = new[] { "api://partner/read" }); + + return Ok(data); + } +} +``` + +## Calling Multiple Downstream APIs + +Your API can call multiple downstream APIs in a single request: + +```csharp +[HttpGet("dashboard")] +public async Task> GetDashboard() +{ + try + { + // Call multiple APIs in parallel + var userTask = _downstreamApi.GetForUserAsync( + "GraphAPI", "me"); + + var dataTask = _downstreamApi.GetForUserAsync( + "PartnerAPI", "api/data"); + + var settingsTask = _downstreamApi.GetForUserAsync( + "PartnerAPI", "api/settings"); + + await Task.WhenAll(userTask, dataTask, settingsTask); + + return Ok(new Dashboard + { + User = userTask.Result, + Data = dataTask.Result, + Settings = settingsTask.Result + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve dashboard data"); + return StatusCode(500, "Failed to retrieve dashboard"); + } +} +``` + +## Best Practices + +### 1. Always Use Distributed Cache in Production + +```csharp +// ❌ Bad: In-memory cache in production +.AddInMemoryTokenCaches(); + +// ✅ Good: Distributed cache in production +.AddDistributedTokenCaches(); +``` + +### 3. Log + +```csharp +builder.Services.AddLogging(config => +{ + config.AddConsole(); + config.AddApplicationInsights(); + config.SetMinimumLevel(LogLevel.Information); +}); +``` + +### 4. Set Appropriate Timeouts + +```csharp +builder.Services.AddDownstreamApi("PartnerAPI", options => +{ + options.BaseUrl = "https://partnerapi.example.com"; + options.HttpClientName = "PartnerAPI"; +}); + +builder.Services.AddHttpClient("PartnerAPI", client => +{ + client.Timeout = TimeSpan.FromSeconds(30); +}); +``` + +### 5. Validate Incoming Tokens + +Ensure your API validates tokens properly: + +```csharp +builder.Services.AddMicrosoftIdentityWebApi(options => +{ + builder.Configuration.Bind("AzureAd", options); +}); +``` + +## Troubleshooting + +### Error: "AADSTS50013: Assertion failed signature validation" + +**Cause**: Client secret or certificate misconfigured in your API's app registration. + +**Solution**: Verify client credentials in appsettings.json match Azure AD app registration. + +### Error: "AADSTS65001: User or administrator has not consented" + +**Cause**: User hasn't consented to your API calling the downstream API. + +**Solution**: Return proper error to client app and trigger consent flow in client. + +### Error: "AADSTS500133: Assertion is not within its valid time range" + +**Cause**: Clock skew between servers or expired token. + +**Solution**: +- Sync server clocks +- Check token expiration +- Ensure token cache is working properly + +### OBO Token Not Cached + +**Cause**: Distributed cache not configured or cache key issues. + +**Solution**: +- Verify distributed cache connection +- Check that `oid` and `tid` claims exist in incoming token +- Enable debug logging to see cache operations + +### Multiple API Instances Not Sharing Cache + +**Cause**: Using in-memory cache instead of distributed cache. + +**Solution**: Switch to distributed cache (Redis, SQL Server, Cosmos DB). + +## Related Documentation + +- [Long-Running Processes](../advanced/long-running-processes.md) +- [Token Caching](../authentication/token-cache/README.md) +- [Calling from Web Apps](from-web-apps.md) +- [Web API Scenarios](../scenarios/web-apis/README.md) +- [API Behind Gateways](../advanced/api-gateways.md) + +--- + +**Next Steps**: Learn about [calling Microsoft Graph](microsoft-graph.md) or [custom APIs](custom-apis.md) with specialized integration patterns. diff --git a/docs/calling-downstream-apis/microsoft-graph.md b/docs/calling-downstream-apis/microsoft-graph.md new file mode 100644 index 000000000..0561fce42 --- /dev/null +++ b/docs/calling-downstream-apis/microsoft-graph.md @@ -0,0 +1,874 @@ +# Calling Microsoft Graph + +This guide explains how to call Microsoft Graph from your ASP.NET Core and OWIN applications using Microsoft.Identity.Web and the Microsoft Graph SDK. + +## Overview + +Microsoft Graph provides a unified API endpoint for accessing data across Microsoft 365, Windows, and Enterprise Mobility + Security. Microsoft.Identity.Web simplifies authentication and token acquisition for Graph, while the Microsoft Graph SDK provides a fluent, typed API for calling Graph endpoints. + +### Why Use Microsoft.Identity.Web.GraphServiceClient? + +- **Automatic token acquisition**: Handles user and app tokens seamlessly +- **Token caching**: Built-in caching for performance +- **Fluent API**: Type-safe, IntelliSense-friendly Graph calls +- **Incremental consent**: Request additional scopes on demand +- **Multiple authentication schemes**: Support for web apps and web APIs +- **Both v1.0 and Beta**: Use stable and preview endpoints together + +## Installation + +Install the Microsoft Graph SDK integration package: + +```bash +dotnet add package Microsoft.Identity.Web.GraphServiceClient +``` + +For Microsoft Graph Beta APIs: + +```bash +dotnet add package Microsoft.Identity.Web.GraphServiceClientBeta +``` + +## ASP.NET Core Setup + +### 1. Configure Services + +Add Microsoft Graph support to your application: + +```csharp +using Microsoft.Identity.Web; + +var builder = WebApplication.CreateBuilder(args); + +// Add authentication (web app or web API) +builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd")) + .EnableTokenAcquisitionToCallDownstreamApi() + .AddInMemoryTokenCaches(); + +// Add Microsoft Graph support +builder.Services.AddMicrosoftGraph(); + +builder.Services.AddControllersWithViews(); + +var app = builder.Build(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.Run(); +``` + +### 2. Configure appsettings.json + +Configure Graph options in your configuration file: + +```json +{ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "your-tenant-id", + "ClientId": "your-client-id", + "ClientSecret": "your-client-secret", + "CallbackPath": "/signin-oidc" + }, + "DownstreamApis": { + "MicrosoftGraph": { + "BaseUrl": "https://graph.microsoft.com/v1.0", + "Scopes": ["User.Read", "User.ReadBasic.All"] + } + } +} +``` + +**Configuration with Code:** + +```csharp +builder.Services.AddMicrosoftGraph(options => +{ + builder.Configuration.GetSection("DownstreamApis:MicrosoftGraph").Bind(options); +}); +``` + +Or configure directly in code: + +```csharp +builder.Services.AddMicrosoftGraph(); +builder.Services.Configure(options => +{ + options.BaseUrl = "https://graph.microsoft.com/v1.0"; + options.Scopes = new[] { "User.Read", "Mail.Read" }; +}); +``` + +### 3. National Cloud Support + +For Microsoft Graph in national clouds, specify the BaseUrl: + +```json +{ + "DownstreamApis": { + "MicrosoftGraph": { + "BaseUrl": "https://graph.microsoft.us/v1.0", + "Scopes": ["User.Read"] + } + } +} +``` + +See [Microsoft Graph deployments](https://learn.microsoft.com/graph/deployments#microsoft-graph-and-graph-explorer-service-root-endpoints) for endpoint URLs. + +## Using GraphServiceClient + +### Inject GraphServiceClient + +Inject `GraphServiceClient` from the constructor: + +```csharp +using Microsoft.Graph; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +[Authorize] +public class ProfileController : Controller +{ + private readonly GraphServiceClient _graphClient; + + public ProfileController(GraphServiceClient graphClient) + { + _graphClient = graphClient; + } + + public async Task Index() + { + // Call Microsoft Graph + var user = await _graphClient.Me.GetAsync(); + return View(user); + } +} +``` + +## Delegated Permissions (User Tokens) + +Call Graph on behalf of the signed-in user using delegated permissions. + +### Basic User Profile + +```csharp +[Authorize] +public class ProfileController : Controller +{ + private readonly GraphServiceClient _graphClient; + + public ProfileController(GraphServiceClient graphClient) + { + _graphClient = graphClient; + } + + public async Task Me() + { + // Get current user's profile + var user = await _graphClient.Me.GetAsync(); + + return View(new UserViewModel + { + DisplayName = user.DisplayName, + Mail = user.Mail, + JobTitle = user.JobTitle + }); + } +} +``` + +### Incremental Consent + +Request additional scopes dynamically when needed: + +```csharp +[Authorize] +[AuthorizeForScopes("Mail.Read")] +public class MailController : Controller +{ + private readonly GraphServiceClient _graphClient; + + public MailController(GraphServiceClient graphClient) + { + _graphClient = graphClient; + } + + public async Task Inbox() + { + try + { + // Request Mail.Read scope dynamically + var messages = await _graphClient.Me.Messages + .GetAsync(r => r.Options.WithScopes("Mail.Read")); + + return View(messages); + } + catch (MicrosoftIdentityWebChallengeUserException) + { + // ASP.NET Core will redirect user to consent + throw; + } + } +} +``` + +### Query Options + +Use Graph SDK query options for filtering, selecting, and ordering: + +```csharp +public async Task UnreadMessages() +{ + var messages = await _graphClient.Me.Messages + .GetAsync(requestConfiguration => + { + requestConfiguration.QueryParameters.Filter = "isRead eq false"; + requestConfiguration.QueryParameters.Select = new[] { "subject", "from", "receivedDateTime" }; + requestConfiguration.QueryParameters.Orderby = new[] { "receivedDateTime desc" }; + requestConfiguration.QueryParameters.Top = 10; + + // Request specific scope + requestConfiguration.Options.WithScopes("Mail.Read"); + }); + + return View(messages); +} +``` + +### Paging Through Results + +Handle paged results from Microsoft Graph: + +```csharp +public async Task AllUsers() +{ + var allUsers = new List(); + + // Get first page + var users = await _graphClient.Users + .GetAsync(r => r.Options.WithScopes("User.ReadBasic.All")); + + // Add first page + allUsers.AddRange(users.Value); + + // Iterate through remaining pages + var pageIterator = PageIterator + .CreatePageIterator( + _graphClient, + users, + user => + { + allUsers.Add(user); + return true; // Continue iteration + }); + + await pageIterator.IterateAsync(); + + return View(allUsers); +} +``` + +## Application Permissions (App-Only Tokens) + +Call Graph with application permissions (no user context). + +### Using WithAppOnly() + +```csharp +[Authorize] +[ApiController] +[Route("api/[controller]")] +public class AdminController : ControllerBase +{ + private readonly GraphServiceClient _graphClient; + + public AdminController(GraphServiceClient graphClient) + { + _graphClient = graphClient; + } + + [HttpGet("users/count")] + public async Task> GetUserCount() + { + // Get count using app permissions + var count = await _graphClient.Users.Count + .GetAsync(r => r.Options.WithAppOnly()); + + return Ok(count); + } + + [HttpGet("applications")] + public async Task GetApplications() + { + // List applications using app permissions + var apps = await _graphClient.Applications + .GetAsync(r => r.Options.WithAppOnly()); + + return Ok(apps.Value); + } +} +``` + +### App Permissions Configuration + +In appsettings.json, you can specify to request an app token: + +```json +{ + "DownstreamApis": { + "MicrosoftGraph": { + "BaseUrl": "https://graph.microsoft.com/v1.0", + "RequestAppToken": true + } + } +} +``` + +The scopes will automatically be set to `["https://graph.microsoft.com/.default"]`. + +### Detailed App-Only Configuration + +```csharp +public async Task GetApplicationsDetailed() +{ + var apps = await _graphClient.Applications + .GetAsync(r => + { + r.Options.WithAuthenticationOptions(options => + { + // Request app token explicitly + options.RequestAppToken = true; + + // Scopes automatically become [.default] + // No need to specify: options.Scopes = new[] { "https://graph.microsoft.com/.default" }; + }); + }); + + return Ok(apps); +} +``` + +## Multiple Authentication Schemes + +If your app uses multiple authentication schemes (e.g., web app + API), specify which scheme to use: + +```csharp +using Microsoft.AspNetCore.Authentication.JwtBearer; + +[Authorize] +public class ApiDataController : ControllerBase +{ + private readonly GraphServiceClient _graphClient; + + public ApiDataController(GraphServiceClient graphClient) + { + _graphClient = graphClient; + } + + [HttpGet("profile")] + public async Task GetProfile() + { + // Specify JWT Bearer scheme + var user = await _graphClient.Me + .GetAsync(r => r.Options + .WithAuthenticationScheme(JwtBearerDefaults.AuthenticationScheme)); + + return Ok(user); + } +} +``` + +### Detailed Scheme Configuration + +```csharp +public async Task GetMailWithScheme() +{ + var messages = await _graphClient.Me.Messages + .GetAsync(r => + { + r.Options.WithAuthenticationOptions(options => + { + // Specify authentication scheme + options.AcquireTokenOptions.AuthenticationOptionsName = + JwtBearerDefaults.AuthenticationScheme; + + // Specify scopes + options.Scopes = new[] { "Mail.Read" }; + }); + }); + + return Ok(messages); +} +``` + +## Using Both v1.0 and Beta + +You can use both Microsoft Graph v1.0 and Beta in the same application. + +### 1. Install Both Packages + +```bash +dotnet add package Microsoft.Identity.Web.GraphServiceClient +dotnet add package Microsoft.Identity.Web.GraphServiceClientBeta +``` + +### 2. Register Both Services + +```csharp +using Microsoft.Identity.Web; + +builder.Services.AddMicrosoftGraph(); +builder.Services.AddMicrosoftGraphBeta(); +``` + +### 3. Use Both Clients + +```csharp +using GraphServiceClient = Microsoft.Graph.GraphServiceClient; +using GraphBetaServiceClient = Microsoft.Graph.Beta.GraphServiceClient; + +public class MyController : Controller +{ + private readonly GraphServiceClient _graphClient; + private readonly GraphBetaServiceClient _graphBetaClient; + + public MyController( + GraphServiceClient graphClient, + GraphBetaServiceClient graphBetaClient) + { + _graphClient = graphClient; + _graphBetaClient = graphBetaClient; + } + + public async Task GetData() + { + // Use stable v1.0 endpoint + var user = await _graphClient.Me.GetAsync(); + + // Use beta endpoint for preview features + var profile = await _graphBetaClient.Me.Profile.GetAsync(); + + return View(new { user, profile }); + } +} +``` + +## Batch Requests + +Combine multiple Graph calls into a single request: + +```csharp +using Microsoft.Graph.Models; + +public async Task GetDashboard() +{ + var batchRequestContent = new BatchRequestContentCollection(_graphClient); + + // Add multiple requests to batch + var userRequest = _graphClient.Me.ToGetRequestInformation(); + var messagesRequest = _graphClient.Me.Messages.ToGetRequestInformation(); + var eventsRequest = _graphClient.Me.Events.ToGetRequestInformation(); + + var userRequestId = await batchRequestContent.AddBatchRequestStepAsync(userRequest); + var messagesRequestId = await batchRequestContent.AddBatchRequestStepAsync(messagesRequest); + var eventsRequestId = await batchRequestContent.AddBatchRequestStepAsync(eventsRequest); + + // Send batch request + var batchResponse = await _graphClient.Batch.PostAsync(batchRequestContent); + + // Extract responses + var user = await batchResponse.GetResponseByIdAsync(userRequestId); + var messages = await batchResponse.GetResponseByIdAsync(messagesRequestId); + var events = await batchResponse.GetResponseByIdAsync(eventsRequestId); + + return View(new DashboardViewModel + { + User = user, + Messages = messages.Value, + Events = events.Value + }); +} +``` + +## Common Graph Patterns + +### Get User's Manager + +```csharp +public async Task GetManager() +{ + var manager = await _graphClient.Me.Manager.GetAsync(); + + // Cast to User (manager is DirectoryObject) + if (manager is User managerUser) + { + return View(managerUser); + } + + return NotFound("Manager not found"); +} +``` + +### Get User's Photo + +```csharp +public async Task GetPhoto() +{ + try + { + var photoStream = await _graphClient.Me.Photo.Content.GetAsync(); + + return File(photoStream, "image/jpeg"); + } + catch (ServiceException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return NotFound("Photo not available"); + } +} +``` + +### Send Email + +```csharp +public async Task SendEmail([FromBody] EmailRequest request) +{ + var message = new Message + { + Subject = request.Subject, + Body = new ItemBody + { + ContentType = BodyType.Html, + Content = request.Body + }, + ToRecipients = new List + { + new Recipient + { + EmailAddress = new EmailAddress + { + Address = request.ToEmail + } + } + } + }; + + await _graphClient.Me.SendMail + .PostAsync(new SendMailPostRequestBody + { + Message = message, + SaveToSentItems = true + }, + requestConfiguration => + { + requestConfiguration.Options.WithScopes("Mail.Send"); + }); + + return Ok("Email sent"); +} +``` + +### Create Calendar Event + +```csharp +public async Task CreateEvent([FromBody] EventRequest request) +{ + var newEvent = new Event + { + Subject = request.Subject, + Start = new DateTimeTimeZone + { + DateTime = request.StartTime.ToString("yyyy-MM-ddTHH:mm:ss"), + TimeZone = "UTC" + }, + End = new DateTimeTimeZone + { + DateTime = request.EndTime.ToString("yyyy-MM-ddTHH:mm:ss"), + TimeZone = "UTC" + }, + Attendees = request.Attendees.Select(email => new Attendee + { + EmailAddress = new EmailAddress { Address = email }, + Type = AttendeeType.Required + }).ToList() + }; + + var createdEvent = await _graphClient.Me.Events + .PostAsync(newEvent, r => r.Options.WithScopes("Calendars.ReadWrite")); + + return Ok(createdEvent); +} +``` + +### Search Users + +```csharp +public async Task SearchUsers(string searchTerm) +{ + var users = await _graphClient.Users + .GetAsync(requestConfiguration => + { + requestConfiguration.QueryParameters.Filter = + $"startswith(displayName,'{searchTerm}') or startswith(mail,'{searchTerm}')"; + requestConfiguration.QueryParameters.Select = + new[] { "displayName", "mail", "jobTitle" }; + requestConfiguration.QueryParameters.Top = 10; + + requestConfiguration.Options.WithScopes("User.ReadBasic.All"); + }); + + return Ok(users.Value); +} +``` + +## OWIN Implementation + +For ASP.NET applications using OWIN: + + +```csharp +using Microsoft.Identity.Web; +using Microsoft.Identity.Web.OWIN; +using Owin; + +public class Startup +{ + public void Configuration(IAppBuilder app) + { + OwinTokenAcquirerFactory factory = TokenAcquirerFactory.GetDefaultInstance(); + app.AddMicrosoftIdentityWebApi(factory); + factory.Services + .AddMicrosoftGraph() + factory.Build(); + } +} +``` + +### 2. Call API from Controllers + +```csharp +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; +using System.Web.Http; + +[Authorize] +public class DataController : ApiController +{ + private readonly IDownstreamApi _downstreamApi; + + public DataController() + { + GraphServiceClient graphServiceClient = this.GetGraphServiceClient(); + var me = await graphServiceClient.Me.Request().GetAsync(); + } +``` + +## Migration from Microsoft.Identity.Web.MicrosoftGraph 2.x + +If you're migrating from the older Microsoft.Identity.Web.MicrosoftGraph package (SDK 4.x), here are the key changes: + +### 1. Remove Old Package, Add New + +```bash +dotnet remove package Microsoft.Identity.Web.MicrosoftGraph +dotnet add package Microsoft.Identity.Web.GraphServiceClient +``` + +### 2. Update Method Calls + +The `.Request()` method has been removed in SDK 5.x: + +**Before (SDK 4.x):** +```csharp +var user = await _graphClient.Me.Request().GetAsync(); + +var messages = await _graphClient.Me.Messages + .Request() + .WithScopes("Mail.Read") + .GetAsync(); +``` + +**After (SDK 5.x):** +```csharp +var user = await _graphClient.Me.GetAsync(); + +var messages = await _graphClient.Me.Messages + .GetAsync(r => r.Options.WithScopes("Mail.Read")); +``` + +### 3. WithScopes() Location Changed + +**Before:** +```csharp +var users = await _graphClient.Users + .Request() + .WithScopes("User.Read.All") + .GetAsync(); +``` + +**After:** +```csharp +var users = await _graphClient.Users + .GetAsync(r => r.Options.WithScopes("User.Read.All")); +``` + +### 4. WithAppOnly() Location Changed + +**Before:** +```csharp +var apps = await _graphClient.Applications + .Request() + .WithAppOnly() + .GetAsync(); +``` + +**After:** +```csharp +var apps = await _graphClient.Applications + .GetAsync(r => r.Options.WithAppOnly()); +``` + +### 5. WithAuthenticationScheme() Location Changed + +**Before:** +```csharp +var user = await _graphClient.Me + .Request() + .WithAuthenticationScheme(JwtBearerDefaults.AuthenticationScheme) + .GetAsync(); +``` + +**After:** +```csharp +var user = await _graphClient.Me + .GetAsync(r => r.Options + .WithAuthenticationScheme(JwtBearerDefaults.AuthenticationScheme)); +``` + +See [Microsoft Graph .NET SDK v5 changelog](https://github.com/microsoftgraph/msgraph-sdk-dotnet/blob/dev/docs/upgrade-to-v5.md) for complete migration details. + +## Error Handling + +### Handle ServiceException + +```csharp +using Microsoft.Graph.Models.ODataErrors; + +public async Task GetData() +{ + try + { + var user = await _graphClient.Me.GetAsync(); + return Ok(user); + } + catch (ODataError ex) when (ex.ResponseStatusCode == 404) + { + return NotFound("Resource not found"); + } + catch (ODataError ex) when (ex.ResponseStatusCode == 403) + { + return Forbid("Insufficient permissions"); + } + catch (MicrosoftIdentityWebChallengeUserException) + { + // User needs to consent + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Graph API call failed"); + return StatusCode(500, "An error occurred"); + } +} +``` + +## Best Practices + +### 1. Request Minimum Scopes + +Only request scopes you need: + +```csharp +// ❌ Bad: Requesting too many scopes +options.Scopes = new[] { "User.Read", "Mail.ReadWrite", "Calendars.ReadWrite", "Files.ReadWrite.All" }; + +// ✅ Good: Request only what you need +options.Scopes = new[] { "User.Read" }; +``` + +### 2. Use Incremental Consent + +Request additional scopes only when needed: + +```csharp +// Sign-in: Only User.Read +// Later, when accessing mail: +var messages = await _graphClient.Me.Messages + .GetAsync(r => r.Options.WithScopes("Mail.Read")); +``` + +### 3. Cache GraphServiceClient + +GraphServiceClient is safe to reuse. Register as singleton or inject from DI. + +### 4. Use Select to Reduce Response Size + +```csharp +// ❌ Bad: Getting all properties +var users = await _graphClient.Users.GetAsync(); + +// ✅ Good: Select only needed properties +var users = await _graphClient.Users + .GetAsync(r => r.QueryParameters.Select = + new[] { "displayName", "mail", "id" }); +``` + +## Troubleshooting + +### Error: "Insufficient privileges to complete the operation" + +**Cause**: App doesn't have required Graph permissions. + +**Solution**: +- Add required API permissions in app registration +- Admin consent required for app permissions +- User consent required for delegated permissions + +### Error: "AADSTS65001: The user or administrator has not consented" + +**Cause**: User hasn't consented to requested scopes. + +**Solution**: Use incremental consent with `.WithScopes()` to trigger consent flow. + +### Photo Returns 404 + +**Cause**: User doesn't have a profile photo. + +**Solution**: Handle 404 gracefully and provide default avatar. + +### Batch Request Fails + +**Cause**: Individual requests in batch may fail independently. + +**Solution**: Check each response in batch for errors: + +```csharp +var userResponse = await batchResponse.GetResponseByIdAsync(userRequestId); +if (userResponse == null) +{ + // Handle individual request failure +} +``` + +## Related Documentation + +- [Microsoft Graph Documentation](https://learn.microsoft.com/graph/) +- [Graph SDK v5 Migration Guide](https://github.com/microsoftgraph/msgraph-sdk-dotnet/blob/dev/docs/upgrade-to-v5.md) +- [Calling Downstream APIs Overview](README.md) +- [Calling from Web Apps](from-web-apps.md) +- [Calling from Web APIs](from-web-apis.md) + +--- + +**Next Steps**: Learn about [calling Azure SDKs](azure-sdks.md) or [custom APIs](custom-apis.md).