diff --git a/samples/AzureB2CClientCredentials/AzureB2CClientCredentials.csproj b/samples/AzureB2CClientCredentials/AzureB2CClientCredentials.csproj new file mode 100644 index 000000000..b4c35c779 --- /dev/null +++ b/samples/AzureB2CClientCredentials/AzureB2CClientCredentials.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + 783daef3-9c45-408d-a1d3-7caf44724f39 + + + + + + + + \ No newline at end of file diff --git a/samples/AzureB2CClientCredentials/Program.cs b/samples/AzureB2CClientCredentials/Program.cs new file mode 100644 index 000000000..5ac2e70a5 --- /dev/null +++ b/samples/AzureB2CClientCredentials/Program.cs @@ -0,0 +1,113 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using ModelContextProtocol.AspNetCore.Authentication; +using AzureB2CClientCredentials.Tools; +using System.Net.Http.Headers; +using System.Security.Claims; + +var builder = WebApplication.CreateBuilder(args); + +var serverUrl = "http://localhost:7071/"; + +// Azure B2C Configuration for Client Credentials Flow +// IMPORTANT: Azure B2C requires a policy even for client credentials flow +// This is different from Azure AD which supports policy-free client credentials +var azureB2CInstance = builder.Configuration["AzureB2C:Instance"] ?? "https://yourtenant.b2clogin.com"; +var azureB2CTenant = builder.Configuration["AzureB2C:Tenant"] ?? "yourtenant.onmicrosoft.com"; +var azureB2CPolicy = builder.Configuration["AzureB2C:Policy"] ?? "B2C_1_signupsignin"; +var azureB2CClientId = builder.Configuration["AzureB2C:ClientId"] ?? "your-client-id"; +// Azure B2C requires the policy in the authority URL even for client credentials flow +var azureB2CAuthority = $"{azureB2CInstance}/{azureB2CTenant}/{azureB2CPolicy}/v2.0"; +var azureB2CMetadataAddress = $"{azureB2CAuthority}/.well-known/openid-configuration"; + +builder.Services.AddAuthentication(options => +{ + options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme; + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; +}) +.AddJwtBearer(options => +{ + // Configure to validate tokens from Azure B2C + options.Authority = azureB2CAuthority; + options.MetadataAddress = azureB2CMetadataAddress; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidAudience = azureB2CClientId, + ValidIssuer = azureB2CAuthority, + NameClaimType = ClaimTypes.Name, + RoleClaimType = ClaimTypes.Role, + // Azure B2C uses 'aud' claim for audience validation + ValidAudiences = new[] { azureB2CClientId }, + // Allow for clock skew + ClockSkew = TimeSpan.FromMinutes(5) + }; + + options.Events = new JwtBearerEvents + { + OnTokenValidated = context => + { + // For client credentials flow, we don't have user claims like email + // Instead, we have application/service claims + var clientId = context.Principal?.FindFirstValue("aud") ?? "unknown"; + var appId = context.Principal?.FindFirstValue("appid") ?? + context.Principal?.FindFirstValue("azp") ?? "unknown"; + var objectId = context.Principal?.FindFirstValue("oid") ?? + context.Principal?.FindFirstValue("sub") ?? "unknown"; + Console.WriteLine($"Token validated for client: {clientId} - App ID: {appId} - Object ID: {objectId}"); + return Task.CompletedTask; + }, + OnAuthenticationFailed = context => + { + Console.WriteLine($"Authentication failed: {context.Exception.Message}"); + return Task.CompletedTask; + }, + OnChallenge = context => + { + Console.WriteLine($"Challenging client to authenticate with Azure B2C"); + return Task.CompletedTask; + } + }; +}) +.AddMcp(options => +{ + options.ResourceMetadata = new() + { + Resource = new Uri(serverUrl), + ResourceDocumentation = new Uri("https://docs.example.com/api/weather"), + AuthorizationServers = { new Uri(azureB2CAuthority) }, + ScopesSupported = ["mcp:tools"], + }; +}); + +builder.Services.AddAuthorization(); + +builder.Services.AddHttpContextAccessor(); +builder.Services.AddMcpServer() + .WithTools() + .WithHttpTransport(); + +// Configure HttpClientFactory for weather.gov API +builder.Services.AddHttpClient("WeatherApi", client => +{ + client.BaseAddress = new Uri("https://api.weather.gov"); + client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("weather-tool", "1.0")); +}); + +var app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); + +// Use the default MCP policy name that we've configured +app.MapMcp().RequireAuthorization(); + +Console.WriteLine($"Starting Azure B2C Client Credentials MCP server with authorization at {serverUrl}"); +Console.WriteLine($"Using Azure B2C authority: {azureB2CAuthority}"); +Console.WriteLine($"Protected Resource Metadata URL: {serverUrl}.well-known/oauth-protected-resource"); +Console.WriteLine("Press Ctrl+C to stop the server"); + +app.Run(serverUrl); diff --git a/samples/AzureB2CClientCredentials/README.md b/samples/AzureB2CClientCredentials/README.md new file mode 100644 index 000000000..9fe120ae4 --- /dev/null +++ b/samples/AzureB2CClientCredentials/README.md @@ -0,0 +1,303 @@ +# Azure B2C Client Credentials MCP Server + +This sample demonstrates how to create an MCP (Model Context Protocol) server that requires OAuth 2.0 Client Credentials authentication using Azure B2C to protect its tools and resources. + +> **⚠️ Important Note**: Azure B2C requires a policy/user flow even for client credentials flow, which is different from Azure AD. This is a key architectural difference between Azure B2C (consumer identity) and Azure AD (enterprise identity). + +## Overview + +The Azure B2C Client Credentials MCP Server shows how to: +- Create an MCP server with Azure B2C OAuth 2.0 protection +- Configure JWT bearer token authentication with Azure B2C +- Implement protected MCP tools and resources +- Integrate with ASP.NET Core authentication and authorization +- Provide OAuth resource metadata for client discovery + +## Prerequisites + +- .NET 9.0 or later +- Azure B2C tenant +- VSCode with REST Client extension (for testing) + +## Azure B2C Setup + +### 1. Create Azure B2C Tenant + +1. Navigate to the Azure portal +2. Create a new Azure AD B2C tenant +3. Note your tenant name (e.g., `yourtenant.onmicrosoft.com`) + +### 2. Register Application + +1. In your B2C tenant, go to **App registrations** +2. Click **New registration** +3. Configure: + - **Name**: MCP Server API + - **Supported account types**: Accounts in any identity provider or organizational directory + - **Redirect URI**: Leave blank (not needed for client credentials flow) +4. After creation, note the **Application (client) ID** + +### 3. Create User Flow (Required for Azure B2C) + +**IMPORTANT**: Unlike Azure AD, Azure B2C requires a policy/user flow even for client credentials flow. This is a key difference between Azure B2C and Azure AD. + +**For Azure B2C Client Credentials Flow:** +1. Go to **User flows** in your B2C tenant +2. Click **New user flow** +3. Select **Sign up and sign in** +4. Choose **Recommended** version +5. Name it `B2C_1_signupsignin` (or your preferred name) +6. Configure the user flow (even though it won't be used for user interaction) + +**Note**: While the user flow won't be used for actual user sign-in (since this is machine-to-machine authentication), Azure B2C's architecture requires the policy context for token issuance. This is different from Azure AD which supports policy-free client credentials flow. + +### 4. Generate Client Secret + +1. Go to **Certificates & secrets** in your app registration +2. Click **New client secret** +3. Add a description and set expiration +4. Copy the secret value (you won't be able to see it again) + +### 5. Configure API Permissions (Optional) + +For client credentials flow, you may want to: +1. Go to **API permissions** in your app registration +2. Add any required permissions for your application +3. Grant admin consent if needed + +## Configuration + +Update the configuration in `appsettings.Development.json`: + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information", + "Microsoft.AspNetCore.Authentication": "Debug" + } + }, + "AzureB2C": { + "Instance": "https://yourtenant.b2clogin.com", + "Tenant": "yourtenant.onmicrosoft.com", + "Policy": "B2C_1A_SIGNUP_SIGNIN", + "ClientId": "your-actual-client-id" + } +} +``` + +**Important Notes:** +- Replace `yourtenant` with your actual B2C tenant name +- Replace `your-actual-client-id` with your application's client ID +- **Policy is Required**: Azure B2C requires the policy even for client credentials flow (unlike Azure AD) +- The `Policy` should match the user flow you created (e.g., `B2C_1_signupsignin`) or custom policy (e.g., `B2C_1A_SIGNUP_SIGNIN`) +- Client secrets are not stored in configuration files for security reasons - they should be provided via environment variables or Azure Key Vault in production + +## Running the Server + +1. Update the Azure B2C configuration in `appsettings.Development.json` +2. Run the server: + ```bash + dotnet run + ``` +3. The server will start at `http://localhost:7071/` + +## What the Server Provides + +### Protected Resources + +- **MCP Endpoint**: `http://localhost:7071/` (requires authentication) +- **OAuth Resource Metadata**: `http://localhost:7071/.well-known/oauth-protected-resource` + +### Available Tools + +The server provides weather-related tools that require authentication: + +1. **get_alerts**: Get weather alerts for a US state + - Parameter: `state` (string) - 2-letter US state abbreviation + - Example: `get_alerts` with `state: "WA"` + +2. **get_forecast**: Get weather forecast for a location + - Parameters: + - `latitude` (number) - Latitude coordinate + - `longitude` (number) - Longitude coordinate + - Example: `get_forecast` with `latitude: 47.6062, longitude: -122.3321` + +> **Note**: Tool names follow the MCP convention of snake_case. The C# method names `GetAlerts` and `GetForecast` are automatically converted to `get_alerts` and `get_forecast` respectively. + +## Testing the Server + +### Using REST Client Extension + +1. Install the REST Client extension in VSCode +2. Use the included `test-azure-b2c.http` file to test the API: + +```http +### Get Azure B2C Token +POST https://yourtenant.b2clogin.com/yourtenant.onmicrosoft.com/B2C_1A_SIGNUP_SIGNIN/oauth2/v2.0/token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=your-client-id +&client_secret=your-client-secret +&scope=https://yourtenant.onmicrosoft.com/your-client-id/.default + +### Test MCP Server Metadata (replace {{token}} with actual token from above) +GET http://localhost:7071/.well-known/oauth-protected-resource +Authorization: Bearer {{token}} + +### Test MCP Tools List +POST http://localhost:7071/ +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": {} +} + +### Test Weather Forecast Tool +POST http://localhost:7071/ +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "get_forecast", + "arguments": { + "latitude": 47.6062, + "longitude": -122.3321 + } + } +} +``` + +**Important Notes:** +- Replace `yourtenant` with your actual B2C tenant name +- Replace `your-client-id` with your application's client ID +- Replace `your-client-secret` with your application's client secret +- The scope format is `https://yourtenant.onmicrosoft.com/your-client-id/.default` +- Copy the `access_token` from the first request and use it in subsequent requests + +## Authentication Flow + +1. **Client Credentials Grant**: Client (application) requests token from Azure B2C using client credentials (client ID and client secret) +2. **Token Request**: POST to `https://yourtenant.b2clogin.com/yourtenant.onmicrosoft.com/B2C_1A_SIGNUP_SIGNIN/oauth2/v2.0/token` +3. **Token Response**: Azure B2C returns JWT access token (note: policy is required in URL even for client credentials) +4. **API Call**: Client includes token in Authorization header: `Bearer ` +5. **Token Validation**: MCP Server validates token against Azure B2C's public keys +6. **Access Granted**: If valid, MCP tools are accessible + +**Key Points:** +- **Policy Required**: Azure B2C requires a policy in the URL even for client credentials flow (unlike Azure AD) +- No user interaction required (server-to-server authentication) +- Client must be registered in Azure B2C +- Client secret must be kept secure +- Tokens have expiration times and should be refreshed as needed + +## Architecture + +The server uses: +- **ASP.NET Core** for hosting and HTTP handling +- **JWT Bearer Authentication** for Azure B2C token validation +- **MCP Authentication Extensions** for OAuth resource metadata +- **HttpClient** for calling the weather.gov API +- **Authorization** to protect MCP endpoints + +## Configuration Details + +- **Server URL**: `http://localhost:7071` +- **Azure B2C Authority**: `https://yourtenant.b2clogin.com/yourtenant.onmicrosoft.com/B2C_1A_SIGNUP_SIGNIN/v2.0` +- **OAuth Metadata**: `https://yourtenant.b2clogin.com/yourtenant.onmicrosoft.com/B2C_1A_SIGNUP_SIGNIN/v2.0/.well-known/openid-configuration` +- **Token Endpoint**: `https://yourtenant.b2clogin.com/yourtenant.onmicrosoft.com/B2C_1A_SIGNUP_SIGNIN/oauth2/v2.0/token` +- **Token Validation**: Audience (ClientId) and issuer validation against Azure B2C +- **CORS**: Enabled for all origins (configure appropriately for production) +- **Scope Format**: `https://yourtenant.onmicrosoft.com/your-client-id/.default` + +**Azure B2C vs Azure AD Differences:** +- **Azure B2C**: Requires policy in URL even for client credentials flow +- **Azure AD**: Supports policy-free client credentials flow +- **Azure B2C**: Designed for consumer identity scenarios +- **Azure AD**: Designed for enterprise/application scenarios + +## Security Considerations + +- Use HTTPS in production +- Implement proper CORS policies +- Use Azure Key Vault for secrets +- Enable logging and monitoring +- Implement rate limiting +- Use least privilege access + +## Troubleshooting + +### Common Issues + +1. **Token validation fails**: + - Check that the Authority URL is correct and matches your B2C tenant + - Verify the metadata endpoint is accessible (no policy needed for client credentials) + - Ensure the tenant domain is correct + +2. **Audience validation fails**: + - Ensure ClientId in configuration matches the token audience + - Check that the client ID is correct in both the token request and server configuration + +3. **Issuer validation fails**: + - Verify the Azure B2C tenant configuration + - Check that the issuer URL format matches the expected pattern (without policy) + +4. **Authentication fails**: + - Verify client secret is correct and not expired + - Check that the scope format is correct: `https://yourtenant.onmicrosoft.com/your-client-id/.default` + - Ensure the token endpoint URL is correct (without policy path) + +5. **CORS issues**: + - Check browser developer tools for CORS-related errors + - Verify CORS policy configuration in the server + +6. **PowerShell Unicode errors**: + - Use the provided test script which handles Unicode properly + - Consider using UTF-8 encoding for HTTP files + +### Debug Logging + +Enable debug logging in `appsettings.Development.json`: + +```json +{ + "Logging": { + "LogLevel": { + "Microsoft.AspNetCore.Authentication": "Debug" + } + } +} +``` + +## External Dependencies + +- **National Weather Service API**: The weather tools use the free API at `api.weather.gov` to fetch real weather data +- **Microsoft.AspNetCore.Authentication.JwtBearer**: For JWT token validation +- **Microsoft.IdentityModel.Tokens**: For token validation parameters +- **ModelContextProtocol.AspNetCore**: For MCP server functionality and OAuth metadata + +## Key Files + +- `Program.cs`: Server setup with Azure B2C authentication and MCP configuration +- `Tools/WeatherTools.cs`: Weather tool implementations +- `Tools/HttpClientExt.cs`: HTTP client extensions +- `test-azure-b2c.http`: REST client test file +- `appsettings.Development.json`: Development configuration + +## Next Steps + +1. Set up Azure B2C tenant and application +2. Configure the application settings +3. Test the authentication flow +4. Implement additional MCP tools as needed +5. Deploy to Azure with proper security configurations \ No newline at end of file diff --git a/samples/AzureB2CClientCredentials/Tools/HttpClientExt.cs b/samples/AzureB2CClientCredentials/Tools/HttpClientExt.cs new file mode 100644 index 000000000..f7b2b5499 --- /dev/null +++ b/samples/AzureB2CClientCredentials/Tools/HttpClientExt.cs @@ -0,0 +1,13 @@ +using System.Text.Json; + +namespace ModelContextProtocol; + +internal static class HttpClientExt +{ + public static async Task ReadJsonDocumentAsync(this HttpClient client, string requestUri) + { + using var response = await client.GetAsync(requestUri); + response.EnsureSuccessStatusCode(); + return await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + } +} \ No newline at end of file diff --git a/samples/AzureB2CClientCredentials/Tools/WeatherTools.cs b/samples/AzureB2CClientCredentials/Tools/WeatherTools.cs new file mode 100644 index 000000000..b7ab90d14 --- /dev/null +++ b/samples/AzureB2CClientCredentials/Tools/WeatherTools.cs @@ -0,0 +1,67 @@ +using ModelContextProtocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Globalization; +using System.Text.Json; + +namespace AzureB2CClientCredentials.Tools; + +[McpServerToolType] +public sealed class WeatherTools +{ + private readonly IHttpClientFactory _httpClientFactory; + + public WeatherTools(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + + [McpServerTool, Description("Get weather alerts for a US state.")] + public async Task GetAlerts( + [Description("The US state to get alerts for. Use the 2 letter abbreviation for the state (e.g. NY).")] string state) + { + var client = _httpClientFactory.CreateClient("WeatherApi"); + using var jsonDocument = await client.ReadJsonDocumentAsync($"/alerts/active/area/{state}"); + var jsonElement = jsonDocument.RootElement; + var alerts = jsonElement.GetProperty("features").EnumerateArray(); + + if (!alerts.Any()) + { + return "No active alerts for this state."; + } + + return string.Join("\n--\n", alerts.Select(alert => + { + JsonElement properties = alert.GetProperty("properties"); + return $""" + Event: {properties.GetProperty("event").GetString()} + Area: {properties.GetProperty("areaDesc").GetString()} + Severity: {properties.GetProperty("severity").GetString()} + Description: {properties.GetProperty("description").GetString()} + Instruction: {properties.GetProperty("instruction").GetString()} + """; + })); + } + + [McpServerTool, Description("Get weather forecast for a location.")] + public async Task GetForecast( + [Description("Latitude of the location.")] double latitude, + [Description("Longitude of the location.")] double longitude) + { + var client = _httpClientFactory.CreateClient("WeatherApi"); + var pointUrl = string.Create(CultureInfo.InvariantCulture, $"/points/{latitude},{longitude}"); + using var jsonDocument = await client.ReadJsonDocumentAsync(pointUrl); + var forecastUrl = jsonDocument.RootElement.GetProperty("properties").GetProperty("forecast").GetString() + ?? throw new Exception($"No forecast URL provided by {client.BaseAddress}points/{latitude},{longitude}"); + + using var forecastDocument = await client.ReadJsonDocumentAsync(forecastUrl); + var periods = forecastDocument.RootElement.GetProperty("properties").GetProperty("periods").EnumerateArray(); + + return string.Join("\n---\n", periods.Select(period => $""" + {period.GetProperty("name").GetString()} + Temperature: {period.GetProperty("temperature").GetInt32()}°F + Wind: {period.GetProperty("windSpeed").GetString()} {period.GetProperty("windDirection").GetString()} + Forecast: {period.GetProperty("detailedForecast").GetString()} + """)); + } +} diff --git a/samples/AzureB2CClientCredentials/appsettings.json b/samples/AzureB2CClientCredentials/appsettings.json new file mode 100644 index 000000000..108cfd0fb --- /dev/null +++ b/samples/AzureB2CClientCredentials/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "AzureB2C": { + "Instance": "https://yourtenant.b2clogin.com", + "Tenant": "yourtenant.onmicrosoft.com", + "Policy": "B2C_1A_SIGNUP_SIGNIN", + "ClientId": "your-client-id-here" + } +} diff --git a/samples/AzureB2CClientCredentials/test-azure-b2c.http b/samples/AzureB2CClientCredentials/test-azure-b2c.http new file mode 100644 index 000000000..deca20cc3 --- /dev/null +++ b/samples/AzureB2CClientCredentials/test-azure-b2c.http @@ -0,0 +1,108 @@ +### Azure B2C Client Credentials MCP Server Testing + +# Your Azure B2C configuration for Client Credentials Flow +# IMPORTANT: Azure B2C requires a policy even for client credentials flow +# This is different from Azure AD which supports policy-free client credentials +@tenantName = yourtenant +@tenantDomain = yourtenant.onmicrosoft.com +@policyName = B2C_1A_SIGNUP_SIGNIN +@clientId = your-client-id-here +@clientSecret = your-client-secret-here +@serverUrl = http://localhost:7071 + +# This will be populated after running step 1 +@accessToken = your-access-token-here + +# Azure B2C endpoints - policy is required even for client credentials +@authority = https://{{tenantName}}.b2clogin.com/{{tenantDomain}}/{{policyName}} +@tokenEndpoint = {{authority}}/oauth2/v2.0/token +@scope = https://{{tenantDomain}}/{{clientId}}/.default + +### 1. Get Access Token from Azure B2C +# Run this first and copy the access_token from the response +# Then update the @accessToken variable above with the token value +POST {{tokenEndpoint}} +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id={{clientId}} +&client_secret={{clientSecret}} +&scope={{scope}} + +### 2. Test MCP Server Metadata (make sure to update @accessToken first) +GET {{serverUrl}}/.well-known/oauth-protected-resource +Authorization: Bearer {{accessToken}} + +### 3. Test MCP Server with JSON-RPC (this is the correct way to call MCP tools) +### This is a JSON-RPC request to list available tools +POST {{serverUrl}}/ +Authorization: Bearer {{accessToken}} +Content-Type: application/json +Accept: application/json, text/event-stream + +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": {} +} + +### 4. Test Weather Forecast Tool using JSON-RPC +POST {{serverUrl}}/ +Authorization: Bearer {{accessToken}} +Content-Type: application/json +Accept: application/json, text/event-stream + +{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "get_forecast", + "arguments": { + "latitude": 47.6062, + "longitude": -122.3321 + } + } +} + +### 5. Test Weather Alerts Tool using JSON-RPC +POST {{serverUrl}}/ +Authorization: Bearer {{accessToken}} +Content-Type: application/json +Accept: application/json, text/event-stream + +{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "get_alerts", + "arguments": { + "state": "WA" + } + } +} + +### 6. Test without Authorization (should fail) +POST {{serverUrl}}/ +Content-Type: application/json + +{ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/list", + "params": {} +} + +### 7. Test with Invalid Token (should fail) +POST {{serverUrl}}/ +Authorization: Bearer invalid-token +Content-Type: application/json + +{ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/list", + "params": {} +}