diff --git a/.github/skills/README.md b/.github/skills/README.md index fbd07c285..75a75019a 100644 --- a/.github/skills/README.md +++ b/.github/skills/README.md @@ -19,8 +19,11 @@ Skills work with multiple AI coding assistants that support the open skills form | Skill | Description | Full Guide | |-------|-------------|------------| | [entra-id-aspire-authentication](./entra-id-aspire-authentication/SKILL.md) | Adding Microsoft Entra ID authentication to .NET Aspire applications | [Aspire Integration Guide](../../docs/frameworks/aspire.md) | +| [entra-id-aspire-provisioning](./entra-id-aspire-provisioning/SKILL.md) | Provisioning Entra ID app registrations for Aspire apps using Microsoft Graph PowerShell | [Aspire Integration Guide](../../docs/frameworks/aspire.md) | > **πŸ’‘ Tip:** Skills are condensed versions optimized for AI assistants. For comprehensive documentation with detailed explanations, diagrams, and troubleshooting, see the linked full guides. +> +> **πŸ”„ Two-phase workflow:** Use the **authentication skill** first to add code (Phase 1), then the **provisioning skill** to create app registrations (Phase 2). ## How to Use Skills diff --git a/.github/skills/entra-id-aspire-authentication/BlazorAuthenticationChallengeHandler.cs b/.github/skills/entra-id-aspire-authentication/BlazorAuthenticationChallengeHandler.cs new file mode 100644 index 000000000..cce77251d --- /dev/null +++ b/.github/skills/entra-id-aspire-authentication/BlazorAuthenticationChallengeHandler.cs @@ -0,0 +1,120 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; + +namespace Microsoft.Identity.Web; + +/// +/// Handles authentication challenges for Blazor Server components. +/// Provides functionality for incremental consent and Conditional Access scenarios. +/// +public class BlazorAuthenticationChallengeHandler( + NavigationManager navigation, + AuthenticationStateProvider authenticationStateProvider, + IConfiguration configuration) +{ + private const string MsaTenantId = "9188040d-6c67-4c5b-b112-36a304b66dad"; + + /// + /// Gets the current user's authentication state. + /// + public async Task GetUserAsync() + { + var authState = await authenticationStateProvider.GetAuthenticationStateAsync(); + return authState.User; + } + + /// + /// Checks if the current user is authenticated. + /// + public async Task IsAuthenticatedAsync() + { + var user = await GetUserAsync(); + return user.Identity?.IsAuthenticated == true; + } + + /// + /// Handles exceptions that may require user re-authentication. + /// Returns true if a challenge was initiated, false otherwise. + /// + public async Task HandleExceptionAsync(Exception exception) + { + var challengeException = exception as MicrosoftIdentityWebChallengeUserException + ?? exception.InnerException as MicrosoftIdentityWebChallengeUserException; + + if (challengeException != null) + { + var user = await GetUserAsync(); + ChallengeUser(user, challengeException.Scopes, challengeException.MsalUiRequiredException?.Claims); + return true; + } + + return false; + } + + /// + /// Initiates a challenge to authenticate the user or request additional consent. + /// + public void ChallengeUser(ClaimsPrincipal user, string[]? scopes = null, string? claims = null) + { + var currentUri = navigation.Uri; + + // Build scopes string (add OIDC scopes) + var allScopes = (scopes ?? []) + .Union(["openid", "offline_access", "profile"]) + .Distinct(); + var scopeString = Uri.EscapeDataString(string.Join(" ", allScopes)); + + // Get login hint from user claims + var loginHint = Uri.EscapeDataString(GetLoginHint(user)); + + // Get domain hint + var domainHint = Uri.EscapeDataString(GetDomainHint(user)); + + // Build the challenge URL + var challengeUrl = $"/authentication/login?returnUrl={Uri.EscapeDataString(currentUri)}" + + $"&scope={scopeString}" + + $"&loginHint={loginHint}" + + $"&domainHint={domainHint}"; + + // Add claims if present (for Conditional Access) + if (!string.IsNullOrEmpty(claims)) + { + challengeUrl += $"&claims={Uri.EscapeDataString(claims)}"; + } + + navigation.NavigateTo(challengeUrl, forceLoad: true); + } + + /// + /// Initiates a challenge with scopes from configuration. + /// + public async Task ChallengeUserWithConfiguredScopesAsync(string configurationSection) + { + var user = await GetUserAsync(); + var scopes = configuration.GetSection(configurationSection).Get(); + ChallengeUser(user, scopes); + } + + private static string GetLoginHint(ClaimsPrincipal user) + { + return user.FindFirst("preferred_username")?.Value ?? + user.FindFirst("login_hint")?.Value ?? + string.Empty; + } + + private static string GetDomainHint(ClaimsPrincipal user) + { + var tenantId = user.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid")?.Value ?? + user.FindFirst("tid")?.Value; + + if (string.IsNullOrEmpty(tenantId)) + return "organizations"; + + // MSA tenant + if (tenantId == MsaTenantId) + return "consumers"; + + return "organizations"; + } +} diff --git a/.github/skills/entra-id-aspire-authentication/LoginLogoutEndpointRouteBuilderExtensions.cs b/.github/skills/entra-id-aspire-authentication/LoginLogoutEndpointRouteBuilderExtensions.cs new file mode 100644 index 000000000..e2b447896 --- /dev/null +++ b/.github/skills/entra-id-aspire-authentication/LoginLogoutEndpointRouteBuilderExtensions.cs @@ -0,0 +1,89 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; + +namespace Microsoft.Identity.Web; + +/// +/// Extension methods for mapping login and logout endpoints that support +/// incremental consent and Conditional Access scenarios. +/// +public static class LoginLogoutEndpointRouteBuilderExtensions +{ + /// + /// Maps login and logout endpoints under the current route group. + /// The login endpoint supports incremental consent via scope, loginHint, domainHint, and claims parameters. + /// + /// The endpoint route builder. + /// The endpoint convention builder for further configuration. + public static IEndpointConventionBuilder MapLoginAndLogout(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup(""); + + // Enhanced login endpoint that supports incremental consent and Conditional Access + group.MapGet("/login", ( + string? returnUrl, + string? scope, + string? loginHint, + string? domainHint, + string? claims) => + { + var properties = GetAuthProperties(returnUrl); + + // Add scopes if provided (for incremental consent) + if (!string.IsNullOrEmpty(scope)) + { + var scopes = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries); + properties.SetParameter(OpenIdConnectParameterNames.Scope, scopes); + } + + // Add login hint (pre-fills username) + if (!string.IsNullOrEmpty(loginHint)) + { + properties.SetParameter(OpenIdConnectParameterNames.LoginHint, loginHint); + } + + // Add domain hint (skips home realm discovery) + if (!string.IsNullOrEmpty(domainHint)) + { + properties.SetParameter(OpenIdConnectParameterNames.DomainHint, domainHint); + } + + // Add claims challenge (for Conditional Access / step-up auth) + if (!string.IsNullOrEmpty(claims)) + { + properties.Items["claims"] = claims; + } + + return TypedResults.Challenge(properties, [OpenIdConnectDefaults.AuthenticationScheme]); + }) + .AllowAnonymous(); + + group.MapPost("/logout", async (HttpContext context) => + { + string? returnUrl = null; + if (context.Request.HasFormContentType) + { + var form = await context.Request.ReadFormAsync(); + returnUrl = form["ReturnUrl"]; + } + + return TypedResults.SignOut(GetAuthProperties(returnUrl), + [CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]); + }) + .DisableAntiforgery(); + + return group; + } + + private static AuthenticationProperties GetAuthProperties(string? returnUrl) + { + const string pathBase = "/"; + if (string.IsNullOrEmpty(returnUrl)) returnUrl = pathBase; + else if (returnUrl.StartsWith("//", StringComparison.Ordinal)) returnUrl = pathBase; // Prevent protocol-relative redirects + else if (!Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)) returnUrl = new Uri(returnUrl, UriKind.Absolute).PathAndQuery; + else if (returnUrl[0] != '/') returnUrl = $"{pathBase}{returnUrl}"; + return new AuthenticationProperties { RedirectUri = returnUrl }; + } +} diff --git a/.github/skills/entra-id-aspire-authentication/SKILL.md b/.github/skills/entra-id-aspire-authentication/SKILL.md index a087da26a..11d3d16c1 100644 --- a/.github/skills/entra-id-aspire-authentication/SKILL.md +++ b/.github/skills/entra-id-aspire-authentication/SKILL.md @@ -1,6 +1,9 @@ --- name: entra-id-aspire-authentication -description: Guide for adding Microsoft Entra ID (Azure AD) authentication to .NET Aspire applications. Use this when asked to add authentication, Entra ID, Azure AD, OIDC, or identity to an Aspire app, or when working with Microsoft.Identity.Web in Aspire projects. +description: | + Guide for adding Microsoft Entra ID (Azure AD) authentication to .NET Aspire applications. + Use this when asked to add authentication, Entra ID, Azure AD, OIDC, or identity to an Aspire app, + or when working with Microsoft.Identity.Web in Aspire projects. license: MIT --- @@ -29,14 +32,99 @@ User Browser β†’ Blazor Server (OIDC) β†’ Entra ID β†’ Access Token β†’ Protecte --- +## Pre-Implementation Checklist + +Before starting, the agent MUST: + +### 1. Detect Project Types + +Scan each project's `Program.cs` to identify its type: + +```powershell +# Find all Program.cs files in solution +Get-ChildItem -Recurse -Filter "Program.cs" | ForEach-Object { + $content = Get-Content $_.FullName -Raw + $projectDir = Split-Path $_.FullName -Parent + $projectName = Split-Path $projectDir -Leaf + + # Skip AppHost and ServiceDefaults + if ($projectName -match "AppHost|ServiceDefaults") { return } + + $isWebApp = $content -match "AddRazorComponents|MapRazorComponents|AddServerSideBlazor" + $isApi = $content -match "MapGet|MapPost|MapPut|MapDelete|AddControllers" + + if ($isWebApp) { + Write-Host "WEB APP: $projectName (has Razor/Blazor components)" + } elseif ($isApi) { + Write-Host "API: $projectName (exposes endpoints)" + } +} +``` + +**Detection rules:** +| Pattern in `Program.cs` | Project Type | +|------------------------|--------------| +| `AddRazorComponents` / `MapRazorComponents` / `AddServerSideBlazor` | **Blazor Web App** | +| `MapGet` / `MapPost` / `AddControllers` (without Razor) | **Web API** | + +> **Note:** APIs can call other APIs (downstream). The Aspire `.WithReference()` shows service dependencies, not necessarily web-to-API relationships. + +### 2. Confirm with User + +**AGENT: Show detected topology and ask for confirmation:** +> "I detected: +> - **Web App** (Blazor): `{webProjectName}` +> - **API**: `{apiProjectName}` +> +> The web app will authenticate users and call the API. Is this correct?" + +### 3. Establish Workflow + +**AGENT: Explain the two-phase approach:** +> "I'll implement authentication in two phases: +> +> **Phase 1 (now):** Add authentication code with placeholder values. The app will **build** but won't **run** until app registrations are configured. +> +> **Phase 2 (after):** Use the `entra-id-aspire-provisioning` skill to create Entra ID app registrations and update the configuration with real values. +> +> Ready to proceed with Phase 1?" + +--- + +## Implementation Checklist + +**CRITICAL: Complete ALL steps in order. Do not skip any step.** + +### API Project Steps +- [ ] Step 1.1: Add Microsoft.Identity.Web package +- [ ] Step 1.2: Update appsettings.json with AzureAd section +- [ ] Step 1.3: Update Program.cs with JWT Bearer authentication +- [ ] Step 1.4: Add RequireAuthorization() to protected endpoints + +### Web/Blazor Project Steps +- [ ] Step 2.1: Add Microsoft.Identity.Web package +- [ ] Step 2.2: Update appsettings.json with AzureAd and scopes +- [ ] Step 2.3: Update Program.cs with OIDC, token acquisition, and **BlazorAuthenticationChallengeHandler** +- [ ] Step 2.4: Copy LoginLogoutEndpointRouteBuilderExtensions.cs from skill folder (adds incremental consent support) +- [ ] Step 2.5: Copy BlazorAuthenticationChallengeHandler.cs from skill folder +- [ ] Step 2.6: Create UserInfo.razor component (LOGIN BUTTON) +- [ ] Step 2.7: Update MainLayout.razor to include UserInfo +- [ ] Step 2.8: Update Routes.razor with AuthorizeRouteView +- [ ] Step 2.9: Store client secret in user-secrets +- [ ] Step 2.10: Add try/catch with ChallengeHandler on **every page calling APIs** + +--- + ## Step-by-Step Implementation ### Prerequisites -1. Azure AD tenant with two app registrations: - - **Web app** (Blazor): with redirect URI `{app-url}/signin-oidc` - - **API app**: exposing scopes (App ID URI like `api://`) -2. Client credentials for the web app (secret or certificate) +1. .NET Aspire solution with API and Web (Blazor) projects +2. Azure AD tenant + +> **Two-phase workflow:** +> - **Phase 1**: Add authentication code with placeholder values β†’ App will **build** but **not run** +> - **Phase 2**: Run `entra-id-aspire-provisioning` skill to create app registrations β†’ App will **run** ### Part 1: Protect the API with JWT Bearer @@ -130,14 +218,16 @@ builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) builder.Services.AddCascadingAuthenticationState(); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); -builder.Services.AddServerSideBlazor().AddMicrosoftIdentityConsentHandler(); + +// Add Blazor authentication challenge handler for incremental consent and Conditional Access +builder.Services.AddScoped(); // HttpClient with automatic token attachment builder.Services.AddHttpClient(client => { client.BaseAddress = new("https+http://apiservice"); // Aspire service discovery }) -.AddMicrosoftIdentityMessageHandler(builder.Configuration.GetSection("WeatherApi")); +.AddMicrosoftIdentityMessageHandler(builder.Configuration.GetSection("WeatherApi").Bind); var app = builder.Build(); @@ -151,39 +241,105 @@ app.MapGroup("/authentication").MapLoginAndLogout(); app.Run(); ``` -**2.4 Create Login/Logout Endpoints (`LoginLogoutEndpointRouteBuilderExtensions.cs`):** -```csharp -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Authentication.OpenIdConnect; -using Microsoft.AspNetCore.Mvc; +**2.4 Copy `LoginLogoutEndpointRouteBuilderExtensions.cs` from skill folder:** -namespace MyService.Web; +Copy the `LoginLogoutEndpointRouteBuilderExtensions.cs` file from this skill's folder to the Web project root. -internal static class LoginLogoutEndpointRouteBuilderExtensions -{ - internal static IEndpointConventionBuilder MapLoginAndLogout(this IEndpointRouteBuilder endpoints) - { - var group = endpoints.MapGroup(""); +> **AGENT:** The file is located at `./LoginLogoutEndpointRouteBuilderExtensions.cs`. The namespace is `Microsoft.Identity.Web` so no changes are needed β€” it provides the `MapLoginAndLogout()` extension method with support for incremental consent and Conditional Access. - group.MapGet("/login", (string? returnUrl) => TypedResults.Challenge(GetAuthProperties(returnUrl))) - .AllowAnonymous(); +**2.5 Copy `BlazorAuthenticationChallengeHandler.cs` from skill folder:** - group.MapPost("/logout", ([FromForm] string? returnUrl) => TypedResults.SignOut(GetAuthProperties(returnUrl), - [CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme])); +Copy the `BlazorAuthenticationChallengeHandler.cs` file from this skill's folder to the Web project root. - return group; - } +> **AGENT:** The file is located at `./BlazorAuthenticationChallengeHandler.cs`. The namespace is `Microsoft.Identity.Web` so no changes are needed β€” it will be available once Microsoft.Identity.Web is referenced. - private static AuthenticationProperties GetAuthProperties(string? returnUrl) - { - const string pathBase = "/"; - if (string.IsNullOrEmpty(returnUrl)) returnUrl = pathBase; - else if (returnUrl.StartsWith("//", StringComparison.Ordinal)) returnUrl = pathBase; // Prevent protocol-relative redirects - else if (!Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)) returnUrl = new Uri(returnUrl, UriKind.Absolute).PathAndQuery; - else if (returnUrl[0] != '/') returnUrl = $"{pathBase}{returnUrl}"; - return new AuthenticationProperties { RedirectUri = returnUrl }; - } +**2.6 Create UserInfo Component (`Components/UserInfo.razor`) β€” THE LOGIN BUTTON:** + +> **CRITICAL: This step is frequently forgotten. Without this, users have no way to log in!** + +```razor +@using Microsoft.AspNetCore.Components.Authorization + + + + Hello, @context.User.Identity?.Name + + + Login + + +``` + +**2.7 Update MainLayout.razor to include UserInfo:** + +Find the `
` or navigation section in `Components/Layout/MainLayout.razor` and add the UserInfo component: + +```razor +@inherits LayoutComponentBase + +
+ + +
+
+ @* <-- ADD THIS LINE *@ +
+ +
+ @Body +
+
+
+``` + +**2.8 Update Routes.razor for AuthorizeRouteView:** + +Replace `RouteView` with `AuthorizeRouteView` in `Components/Routes.razor`: + +```razor +@using Microsoft.AspNetCore.Components.Authorization + + + + + +

You are not authorized to view this page.

+ Login +
+
+ +
+
+``` + +**2.9 Store Client Secret in User Secrets:** + +> **Never commit secrets to source control!** + +```powershell +cd MyService.Web +dotnet user-secrets init +dotnet user-secrets set "AzureAd:ClientCredentials:0:ClientSecret" "" +``` + +Then update `appsettings.json` to reference user secrets (remove the hardcoded secret): +```jsonc +{ + "AzureAd": { + "ClientCredentials": [ + { + // For more options see https://aka.ms/ms-id-web/credentials + "SourceType": "ClientSecret" + } + ] + } } ``` @@ -247,6 +403,80 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) builder.Services.AddDownstreamApi("GraphApi", builder.Configuration.GetSection("GraphApi")); ``` +### Handle Conditional Access / MFA / Incremental Consent + +> **This is NOT optional** β€” Blazor Server requires explicit exception handling for Conditional Access and consent. + +When calling APIs, Conditional Access policies or consent requirements can trigger `MicrosoftIdentityWebChallengeUserException`. You MUST handle this on **every page that calls a downstream API**. + +**Step 2.3 registers the handler** β€” `AddScoped()` makes the service available. + +**Each page calling APIs needs this pattern:** + +```razor +@page "/weather" +@attribute [Authorize] + +@using Microsoft.AspNetCore.Authorization +@using Microsoft.Identity.Web + +@inject WeatherApiClient WeatherApi +@inject BlazorAuthenticationChallengeHandler ChallengeHandler + +Weather + +

Weather

+ +@if (!string.IsNullOrEmpty(errorMessage)) +{ +
@errorMessage
+} +else if (forecasts == null) +{ +

Loading...

+} +else +{ + @* Display your data *@ +} + +@code { + private WeatherForecast[]? forecasts; + private string? errorMessage; + + protected override async Task OnInitializedAsync() + { + if (!await ChallengeHandler.IsAuthenticatedAsync()) + { + // Not authenticated - redirect to login with required scopes + await ChallengeHandler.ChallengeUserWithConfiguredScopesAsync("WeatherApi:Scopes"); + return; + } + + try + { + forecasts = await WeatherApi.GetWeatherAsync(); + } + catch (Exception ex) + { + // Handle incremental consent / Conditional Access + if (!await ChallengeHandler.HandleExceptionAsync(ex)) + { + errorMessage = $"Error loading data: {ex.Message}"; + } + } + } +} +``` + +> **Why this pattern?** +> 1. `IsAuthenticatedAsync()` checks if user is signed in before making API calls +> 2. `HandleExceptionAsync()` catches `MicrosoftIdentityWebChallengeUserException` (or as InnerException) +> 3. If it is a challenge exception β†’ redirects user to re-authenticate with required claims/scopes +> 4. If it is NOT a challenge exception β†’ returns false so you can handle the error + +> **Why is this not automatic?** Blazor Server's circuit-based architecture requires explicit handling. The handler re-challenges the user by navigating to the login endpoint with the required claims/scopes. + --- ## Troubleshooting @@ -257,6 +487,7 @@ builder.Services.AddDownstreamApi("GraphApi", builder.Configuration.GetSection(" | OIDC redirect fails | Add `/signin-oidc` to Azure AD redirect URIs | | Token not attached | Ensure `AddMicrosoftIdentityMessageHandler` is configured | | AADSTS65001 | Admin consent required - grant in Azure Portal | +| 404 on `/MicrosoftIdentity/Account/Challenge` | Use `BlazorAuthenticationChallengeHandler` instead of `MicrosoftIdentityConsentHandler` | --- @@ -266,8 +497,47 @@ builder.Services.AddDownstreamApi("GraphApi", builder.Configuration.GetSection(" |---------|------|---------| | ApiService | `Program.cs` | JWT auth + `RequireAuthorization()` | | ApiService | `appsettings.json` | AzureAd config (ClientId, TenantId) | -| Web | `Program.cs` | OIDC + token acquisition + message handler | +| Web | `Program.cs` | OIDC + token acquisition + challenge handler registration | | Web | `appsettings.json` | AzureAd config + downstream API scopes | +| Web | `LoginLogoutEndpointRouteBuilderExtensions.cs` | Login/logout with incremental consent support (**copy from skill**) | +| Web | `BlazorAuthenticationChallengeHandler.cs` | Reusable auth challenge handler (**copy from skill**) | +| Web | `Components/UserInfo.razor` | **Login/logout button UI** | +| Web | `Components/Layout/MainLayout.razor` | Include UserInfo in layout | +| Web | `Components/Routes.razor` | AuthorizeRouteView for protected pages | + +--- + +## Post-Implementation Verification + +**AGENT: After completing all steps, verify:** + +1. **Build succeeds:** + ```powershell + dotnet build + ``` + +2. **Check all files were created/modified:** + - [ ] API `Program.cs` has `AddMicrosoftIdentityWebApi` + - [ ] API `appsettings.json` has `AzureAd` section + - [ ] Web `Program.cs` has `AddMicrosoftIdentityWebApp` and `AddMicrosoftIdentityMessageHandler` + - [ ] Web `Program.cs` has `AddScoped()` + - [ ] Web `appsettings.json` has `AzureAd` and scope configuration + - [ ] Web has `LoginLogoutEndpointRouteBuilderExtensions.cs` (with incremental consent params) + - [ ] Web has `BlazorAuthenticationChallengeHandler.cs` + - [ ] Web has `Components/UserInfo.razor` (**LOGIN BUTTON**) + - [ ] Web `MainLayout.razor` includes `` + - [ ] Web `Routes.razor` uses `AuthorizeRouteView` + - [ ] **Every page calling protected APIs** has try/catch with `ChallengeHandler.HandleExceptionAsync(ex)` + +3. **AGENT: Inform user of next step:** + > "βœ… **Phase 1 complete!** Authentication code is in place. The app will **build** but **won't run** until app registrations are configured. + > + > **Next:** Run the `entra-id-aspire-provisioning` skill to: + > - Create Entra ID app registrations + > - Update `appsettings.json` with real ClientIds + > - Store client secret securely + > + > Ready to proceed with provisioning?" --- diff --git a/.github/skills/entra-id-aspire-provisioning/SKILL.md b/.github/skills/entra-id-aspire-provisioning/SKILL.md new file mode 100644 index 000000000..1f7051d86 --- /dev/null +++ b/.github/skills/entra-id-aspire-provisioning/SKILL.md @@ -0,0 +1,811 @@ +--- +name: entra-id-aspire-provisioning +description: | + Provision Entra ID (Azure AD) app registrations for .NET Aspire applications and update configuration. + Use after adding Microsoft.Identity.Web authentication code to create or update app registrations, + configure scopes, credentials, and update appsettings.json files. + Triggers: "provision entra id", "create app registration", "register azure ad app", + "configure entra id apps", "set up authentication apps". +--- + +# Entra ID Provisioning for .NET Aspire + +Provision Entra ID app registrations for Aspire solutions and update `appsettings.json` configuration. + +## Prerequisites + +### Install Microsoft Graph PowerShell + +```powershell +# Install the required modules (only if needed, one-time setup) +Install-Module Microsoft.Graph.Applications -Scope CurrentUser -Force +Install-Module Microsoft.Graph.Identity.SignIns -Scope CurrentUser -Force + +# Note: Microsoft.Graph.Users is NOT required - this skill uses Invoke-MgGraphRequest +# to get current user info, which avoids module version compatibility issues. +``` + +### Connect to Microsoft Graph + +```powershell +# Connect with required scopes +Connect-MgGraph -Scopes "Application.ReadWrite.All", "Directory.ReadWrite.All" + +# Verify connection +Get-MgContext +``` + +> **Note**: You may be prompted to consent to permissions on first use. + +## Provisioning Checklist + +Use this checklist to verify all provisioning steps are complete: + +### For Each Web API Project +- [ ] App registration created (or existing one found or user-provided) +- [ ] App ID URI set (`api://{clientId}`) +- [ ] `access_as_user` scope configured +- [ ] Service principal created +- [ ] Current user added as owner +- [ ] `appsettings.json` updated with `TenantId` and `ClientId` + +### For Each Web App Project +- [ ] App registration created (or existing one found or user-provided) +- [ ] Redirect URIs configured (from `launchSettings.json`) +- [ ] Client secret generated and stored in user-secrets +- [ ] API permission added (to call the web API) +- [ ] Admin consent granted (or manual steps provided) +- [ ] Service principal created +- [ ] Current user added as owner +- [ ] `appsettings.json` updated with `TenantId`, `ClientId`, and `Scopes` + +### Final Verification +- [ ] API provisioned **before** web app (web app needs API's ClientId and ScopeId) +- [ ] All `appsettings.json` files have real GUIDs (no placeholders) +- [ ] Client secret stored in user-secrets (not in `appsettings.json`) +- [ ] `Disconnect-MgGraph` called when done + +## When to Use This Skill + +Use this skill **after** the `entra-id-aspire-authentication` skill has added authentication code. This skill: +- Creates or updates Entra ID app registrations +- Configures App ID URIs and scopes for APIs +- Sets up redirect URIs for web apps +- Generates client secrets and stores them securely +- Updates `appsettings.json` with `TenantId`, `ClientId`, and scopes + +## Workflow + +### Step 1: Detect Project Types + +Scan `Program.cs` files to identify which projects need app registrations: + +```powershell +# Detect projects with Microsoft.Identity.Web +Get-ChildItem -Recurse -Filter "Program.cs" | ForEach-Object { + $content = Get-Content $_.FullName -Raw + $projectDir = Split-Path $_.FullName -Parent + $projectName = Split-Path $projectDir -Leaf + + if ($content -match "AddMicrosoftIdentityWebApi") { + Write-Host "API: $projectName" + } elseif ($content -match "AddMicrosoftIdentityWebApp") { + Write-Host "WebApp: $projectName" + } +} +``` + +### Step 2: Gather Configuration + +Before provisioning, the agent MUST gather required information interactively. + +#### 2a. Get Tenant ID + +First, detect the default tenant from the current connection if Microsoft Graph powershell is connected: + +```powershell +$context = Get-MgContext +if ($context) { + $defaultTenant = $context.TenantId + Write-Host "Connected to tenant: $defaultTenant" +} else { + Write-Host "Not connected. Run: Connect-MgGraph -TenantId '' -Scopes 'Application.ReadWrite.All'" +} +``` + +**AGENT: Ask the user:** +> "I detected tenant ID `{defaultTenant}`. Should I use this tenant, or would you like to specify a different one?" + +- If user confirms β†’ use `$defaultTenant` +- If user provides different ID β†’ use that value +- If not connected β†’ instruct user to run `Connect-MgGraph` first + +#### 2b. Check for Existing ClientIds in appsettings.json + +Before asking about new vs. existing apps, scan `appsettings.json` files: + +```powershell +# === Detect existing ClientIds from appsettings.json === + +$projects = @() + +Get-ChildItem -Recurse -Filter "Program.cs" | ForEach-Object { + $content = Get-Content $_.FullName -Raw + $projectDir = Split-Path $_.FullName -Parent + $projectName = Split-Path $projectDir -Leaf + + # Skip AppHost and ServiceDefaults + if ($projectName -match "AppHost|ServiceDefaults") { return } + + $appSettingsPath = Join-Path $projectDir "appsettings.json" + $existingClientId = $null + $isPlaceholder = $false + + if (Test-Path $appSettingsPath) { + $appSettings = Get-Content $appSettingsPath -Raw | ConvertFrom-Json + if ($appSettings.AzureAd.ClientId) { + $clientId = $appSettings.AzureAd.ClientId + # Check if it's a placeholder value + if ($clientId -match "^<.*>$" -or $clientId -match "YOUR_" -or $clientId -eq "") { + $isPlaceholder = $true + } else { + $existingClientId = $clientId + } + } + } + + $projectType = $null + if ($content -match "AddMicrosoftIdentityWebApi") { + $projectType = "API" + } elseif ($content -match "AddMicrosoftIdentityWebApp") { + $projectType = "WebApp" + } + + if ($projectType) { + $projects += @{ + Name = $projectName + Path = $projectDir + Type = $projectType + ExistingClientId = $existingClientId + IsPlaceholder = $isPlaceholder + } + } +} + +# Output findings +$projects | ForEach-Object { + if ($_.ExistingClientId) { + Write-Host "$($_.Type): $($_.Name) - EXISTING ClientId: $($_.ExistingClientId)" -ForegroundColor Yellow + } elseif ($_.IsPlaceholder) { + Write-Host "$($_.Type): $($_.Name) - Placeholder ClientId (needs provisioning)" -ForegroundColor Cyan + } else { + Write-Host "$($_.Type): $($_.Name) - No ClientId configured" -ForegroundColor Cyan + } +} +``` + +**AGENT: Based on findings, ask the user:** + +**If existing ClientIds found:** +> "I found existing app registrations in your configuration: +> - **API** (`{apiProjectName}`): ClientId `{apiClientId}` +> - **Web App** (`{webProjectName}`): ClientId `{webClientId}` +> +> Should I: +> 1. **Use these existing apps** and complement them if needed (add missing scopes, redirect URIs)? +> 2. **Create new app registrations** and update the configuration?" + +**If only placeholders or no ClientIds:** +> "No existing app registrations found in `appsettings.json`. I'll create new ones." + +- If user chooses **existing** β†’ use the "Existing App Flow" section with detected ClientIds +- If user chooses **new** β†’ proceed to Step 3 + +#### 2c. Confirm or Provide ClientIds + +Based on the detection results, present options to the user: + +**AGENT: Ask the user:** +> "I found the following configuration: +> - **API** (`{apiProjectName}`): {`ClientId: {id}` OR `No ClientId configured`} +> - **Web App** (`{webProjectName}`): {`ClientId: {id}` OR `No ClientId configured`} +> +> What would you like to do? +> 1. **Create new app registrations** for projects without valid ClientIds +> 2. **Use existing app registrations** β€” provide ClientIds if not detected +> 3. **Replace all** β€” create new apps even if ClientIds exist" + +**If user provides ClientIds manually:** +> "Please provide the ClientIds: +> - API ClientId: ___ +> - Web App ClientId: ___" + +Store the final decision: +```powershell +# Final configuration after user input +$apiConfig = @{ + ProjectName = "MyService.ApiService" + ProjectPath = "path/to/api" + ClientId = $null # Or user-provided/detected GUID + Action = "Create" # Or "UseExisting" +} + +$webConfig = @{ + ProjectName = "MyService.Web" + ProjectPath = "path/to/web" + ClientId = $null # Or user-provided/detected GUID + Action = "Create" # Or "UseExisting" +} +``` + +**Decision logic:** +- If `Action = "Create"` β†’ proceed to Step 3 (provision new app) +- If `Action = "UseExisting"` β†’ use the "Existing App Flow" section with the ClientId (detected or user-provided) + +> **Important for existing apps:** +> - **Web APIs**: The Existing App Flow checks for and adds `access_as_user` scope if missing +> - **Web Apps**: Run Step 5 (Discover Redirect URIs) first, then pass URIs to Existing App Flow to add any missing redirect URIs +> - **Both**: App ID URI and service principal are created if missing + +### Step 3: Provision API App Registration + +For each project with `AddMicrosoftIdentityWebApi`: + +```powershell +# === Provision API App Registration === + +param( + [Parameter(Mandatory=$true)][string]$TenantId, + [Parameter(Mandatory=$true)][string]$DisplayName, + [string]$SignInAudience = "AzureADMyOrg" +) + +Write-Host "Creating API app registration: $DisplayName" -ForegroundColor Cyan + +# Create the app registration +$apiApp = New-MgApplication -DisplayName $DisplayName -SignInAudience $SignInAudience + +$apiClientId = $apiApp.AppId +$apiObjectId = $apiApp.Id + +Write-Host "Created app: $apiClientId" + +# Set App ID URI +$appIdUri = "api://$apiClientId" +Update-MgApplication -ApplicationId $apiObjectId -IdentifierUris @($appIdUri) +Write-Host "Set App ID URI: $appIdUri" + +# Expose scope: access_as_user +$scopeId = [guid]::NewGuid().ToString() +$scope = @{ + Id = $scopeId + AdminConsentDescription = "Allow the application to access $DisplayName on behalf of the signed-in user." + AdminConsentDisplayName = "Access $DisplayName" + IsEnabled = $true + Type = "User" + UserConsentDescription = "Allow the application to access $DisplayName on your behalf." + UserConsentDisplayName = "Access $DisplayName" + Value = "access_as_user" +} + +$api = @{ + Oauth2PermissionScopes = @($scope) +} + +Update-MgApplication -ApplicationId $apiObjectId -Api $api +Write-Host "Added scope: access_as_user (id: $scopeId)" + +# Create service principal +New-MgServicePrincipal -AppId $apiClientId | Out-Null +Write-Host "Created service principal" + +# Add current user as owner (using Invoke-MgGraphRequest for robustness - avoids module version issues) +$currentUser = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/me" +if ($currentUser) { + $ownerRef = @{ + "@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$($currentUser.id)" + } + New-MgApplicationOwnerByRef -ApplicationId $apiObjectId -BodyParameter $ownerRef + Write-Host "Added owner: $($currentUser.userPrincipalName)" +} + +# Output for next steps +Write-Host "" +Write-Host "=== API Provisioning Complete ===" -ForegroundColor Green +Write-Host "ClientId: $apiClientId" +Write-Host "AppIdUri: $appIdUri" +Write-Host "ScopeId: $scopeId" +Write-Host "Owner: $($currentUser.userPrincipalName)" +``` + +### Step 4: Update API appsettings.json + +Update the API project's `appsettings.json`: + +```json +{ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "", + "ClientId": "", + "Audiences": ["api://"] + } +} +``` + +### Step 5: Discover Redirect URIs + +Parse `Properties/launchSettings.json` for the web project: + +```powershell +# === Discover Redirect URIs === + +param( + [Parameter(Mandatory=$true)][string]$ProjectPath +) + +$launchSettingsPath = Join-Path $ProjectPath "Properties/launchSettings.json" +$launchSettings = Get-Content $launchSettingsPath | ConvertFrom-Json + +$redirectUris = @() + +foreach ($profile in $launchSettings.profiles.PSObject.Properties) { + $appUrl = $profile.Value.applicationUrl + if ($appUrl) { + $urls = $appUrl -split ";" + foreach ($url in $urls) { + if ($url -match "^https://") { + $redirectUris += "$url/signin-oidc" + } + } + } +} + +Write-Host "Redirect URIs: $($redirectUris -join ', ')" +$redirectUris +``` + +### Step 6: Provision Web App Registration + +For each project with `AddMicrosoftIdentityWebApp`: + +```powershell +# === Provision Web App Registration === + +param( + [Parameter(Mandatory=$true)][string]$TenantId, + [Parameter(Mandatory=$true)][string]$DisplayName, + [Parameter(Mandatory=$true)][string]$ApiClientId, + [Parameter(Mandatory=$true)][string]$ApiScopeId, + [Parameter(Mandatory=$true)][string[]]$RedirectUris, + [string]$SignInAudience = "AzureADMyOrg" +) + +Write-Host "Creating Web app registration: $DisplayName" -ForegroundColor Cyan + +# Configure web platform with redirect URIs and enable ID tokens +$webConfig = @{ + RedirectUris = $RedirectUris + ImplicitGrantSettings = @{ + EnableIdTokenIssuance = $true + } +} + +# Create the app registration +$webApp = New-MgApplication ` + -DisplayName $DisplayName ` + -SignInAudience $SignInAudience ` + -Web $webConfig + +$webClientId = $webApp.AppId +$webObjectId = $webApp.Id + +Write-Host "Created app: $webClientId" + +# Add API permission for access_as_user scope +# First, get the Microsoft Graph resource ID for the API +$apiServicePrincipal = Get-MgServicePrincipal -Filter "appId eq '$ApiClientId'" + +$requiredResourceAccess = @{ + ResourceAppId = $ApiClientId + ResourceAccess = @( + @{ + Id = $ApiScopeId + Type = "Scope" + } + ) +} + +Update-MgApplication -ApplicationId $webObjectId -RequiredResourceAccess @($requiredResourceAccess) +Write-Host "Added API permission for $ApiClientId" + +# Create client secret +$passwordCredential = @{ + DisplayName = "dev-secret" + EndDateTime = (Get-Date).AddYears(1) +} + +$secret = Add-MgApplicationPassword -ApplicationId $webObjectId -PasswordCredential $passwordCredential +$secretValue = $secret.SecretText + +Write-Host "Created client secret" + +# Create service principal for the web app +New-MgServicePrincipal -AppId $webClientId | Out-Null +Write-Host "Created service principal" + +# Add current user as owner (using Invoke-MgGraphRequest for robustness - avoids module version issues) +$currentUser = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/me" +if ($currentUser) { + $ownerRef = @{ + "@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$($currentUser.id)" + } + New-MgApplicationOwnerByRef -ApplicationId $webObjectId -BodyParameter $ownerRef + Write-Host "Added owner: $($currentUser.userPrincipalName)" +} + +# Output for next steps +Write-Host "" +Write-Host "=== Web App Provisioning Complete ===" -ForegroundColor Green +Write-Host "ClientId: $webClientId" +Write-Host "Secret: $secretValue" +Write-Host "Owner: $($currentUser.userPrincipalName)" +Write-Host "" +Write-Host "IMPORTANT: Store this secret securely. It will not be shown again." +``` + +### Step 7: Store Secret in User Secrets + +```powershell +# === Store secret in dotnet user-secrets === + +param( + [Parameter(Mandatory=$true)][string]$ProjectPath, + [Parameter(Mandatory=$true)][string]$Secret +) + +Push-Location $ProjectPath + +# Initialize user-secrets if needed +$csproj = Get-ChildItem -Filter "*.csproj" | Select-Object -First 1 +$csprojContent = Get-Content $csproj.FullName -Raw + +if ($csprojContent -notmatch "UserSecretsId") { + dotnet user-secrets init + Write-Host "Initialized user-secrets" +} + +# Set the secret +dotnet user-secrets set "AzureAd:ClientSecret" $Secret +Write-Host "Stored ClientSecret in user-secrets" + +Pop-Location +``` + +### Step 8: Update Web App appsettings.json + +Update the web project's `appsettings.json`: + +```json +{ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "", + "ClientId": "", + "CallbackPath": "/signin-oidc" + }, + "DownstreamApi": { + "Scopes": ["api:///.default"] + } +} +``` + +> **Note**: The `ClientSecret` is stored in user-secrets, not in `appsettings.json`. + +## Existing App Flow + +When using an existing app registration (detected from `appsettings.json` or provided by user), this flow **complements** it by adding any missing configuration: + +| Check | API | Web App | +|-------|-----|---------| +| App ID URI (`api://{clientId}`) | βœ… Add if missing | β€” | +| `access_as_user` scope | βœ… Add if missing | β€” | +| Redirect URIs | β€” | βœ… Add missing URIs | +| API Permission to call API | β€” | βœ… Add if missing | +| Service Principal | βœ… Create if missing | βœ… Create if missing || Owner (current user) | βœ… Add if not owner | βœ… Add if not owner | +### Complement Existing API App + +```powershell +# === Complement Existing API App Registration === + +param( + [Parameter(Mandatory=$true)][string]$ClientId +) + +Write-Host "Fetching existing API app: $ClientId" -ForegroundColor Cyan + +# Get the application by AppId +$app = Get-MgApplication -Filter "appId eq '$ClientId'" +$objectId = $app.Id + +# Check App ID URI +if (-not $app.IdentifierUris -or $app.IdentifierUris.Count -eq 0) { + Write-Host "Adding App ID URI..." + Update-MgApplication -ApplicationId $objectId -IdentifierUris @("api://$ClientId") +} + +# Check for access_as_user scope +$existingScope = $app.Api.Oauth2PermissionScopes | Where-Object { $_.Value -eq "access_as_user" } +$scopeId = $null + +if (-not $existingScope) { + Write-Host "Adding access_as_user scope..." + $scopeId = [guid]::NewGuid().ToString() + $displayName = $app.DisplayName ?? "API" + + # Get existing scopes and add new one + $existingScopes = @($app.Api.Oauth2PermissionScopes) + $newScope = @{ + Id = $scopeId + AdminConsentDescription = "Allow access on behalf of signed-in user" + AdminConsentDisplayName = "Access $displayName" + IsEnabled = $true + Type = "User" + UserConsentDescription = "Allow access on your behalf" + UserConsentDisplayName = "Access $displayName" + Value = "access_as_user" + } + + $api = @{ + Oauth2PermissionScopes = $existingScopes + $newScope + } + + Update-MgApplication -ApplicationId $objectId -Api $api +} else { + $scopeId = $existingScope.Id + Write-Host "access_as_user scope already exists (id: $scopeId)" +} + +# Check service principal +$sp = Get-MgServicePrincipal -Filter "appId eq '$ClientId'" -ErrorAction SilentlyContinue +if (-not $sp) { + New-MgServicePrincipal -AppId $ClientId | Out-Null + Write-Host "Created service principal" +} + +# Check and add current user as owner if not already (using Invoke-MgGraphRequest for robustness) +$currentUser = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/me" +if ($currentUser) { + $existingOwners = Get-MgApplicationOwner -ApplicationId $objectId + $isOwner = $existingOwners | Where-Object { $_.Id -eq $currentUser.id } + if (-not $isOwner) { + $ownerRef = @{ + "@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$($currentUser.id)" + } + New-MgApplicationOwnerByRef -ApplicationId $objectId -BodyParameter $ownerRef + Write-Host "Added owner: $($currentUser.userPrincipalName)" + } else { + Write-Host "Current user is already an owner" + } +} + +Write-Host "API app registration updated" -ForegroundColor Green +Write-Host "ScopeId: $scopeId" + +# Return scope ID for web app configuration +$scopeId +``` + +### Complement Existing Web App + +```powershell +# === Complement Existing Web App Registration === + +param( + [Parameter(Mandatory=$true)][string]$ClientId, + [Parameter(Mandatory=$true)][string]$ApiClientId, + [Parameter(Mandatory=$true)][string]$ApiScopeId, + [string[]]$RequiredRedirectUris = @() +) + +Write-Host "Fetching existing Web app: $ClientId" -ForegroundColor Cyan + +# Get the application by AppId +$app = Get-MgApplication -Filter "appId eq '$ClientId'" +$objectId = $app.Id + +# Check redirect URIs +if ($RequiredRedirectUris.Count -gt 0) { + $existingUris = @($app.Web.RedirectUris) + $missingUris = $RequiredRedirectUris | Where-Object { $_ -notin $existingUris } + if ($missingUris.Count -gt 0) { + Write-Host "Adding missing redirect URIs: $($missingUris -join ', ')" + $allUris = $existingUris + $missingUris + + $webConfig = @{ + RedirectUris = $allUris + ImplicitGrantSettings = @{ + EnableIdTokenIssuance = $true + } + } + + Update-MgApplication -ApplicationId $objectId -Web $webConfig + } else { + Write-Host "All redirect URIs already configured" + } +} + +# Check API permission +$existingPermission = $app.RequiredResourceAccess | Where-Object { $_.ResourceAppId -eq $ApiClientId } +if (-not $existingPermission) { + Write-Host "Adding API permission for $ApiClientId..." + + $requiredResourceAccess = @{ + ResourceAppId = $ApiClientId + ResourceAccess = @( + @{ + Id = $ApiScopeId + Type = "Scope" + } + ) + } + + # Preserve existing permissions and add new one + $allPermissions = @($app.RequiredResourceAccess) + $requiredResourceAccess + Update-MgApplication -ApplicationId $objectId -RequiredResourceAccess $allPermissions + Write-Host "Added API permission" +} else { + Write-Host "API permission already configured" +} + +# Check service principal +$sp = Get-MgServicePrincipal -Filter "appId eq '$ClientId'" -ErrorAction SilentlyContinue +if (-not $sp) { + $sp = New-MgServicePrincipal -AppId $ClientId + Write-Host "Created service principal" +} + +# Check and add current user as owner if not already (using Invoke-MgGraphRequest for robustness) +$currentUser = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/me" +if ($currentUser) { + $existingOwners = Get-MgApplicationOwner -ApplicationId $objectId + $isOwner = $existingOwners | Where-Object { $_.Id -eq $currentUser.id } + if (-not $isOwner) { + $ownerRef = @{ + "@odata.id" = "https://graph.microsoft.com/v1.0/directoryObjects/$($currentUser.id)" + } + New-MgApplicationOwnerByRef -ApplicationId $objectId -BodyParameter $ownerRef + Write-Host "Added owner: $($currentUser.userPrincipalName)" + } else { + Write-Host "Current user is already an owner" + } +} + +# Grant admin consent for the web app to call the API +Write-Host "Attempting to grant admin consent for API access..." +try { + $apiSp = Get-MgServicePrincipal -Filter "appId eq '$ApiClientId'" + + # Check if consent already exists + $existingGrant = Get-MgOauth2PermissionGrant -Filter "clientId eq '$($sp.Id)' and resourceId eq '$($apiSp.Id)'" -ErrorAction SilentlyContinue + + if (-not $existingGrant) { + $grant = @{ + ClientId = $sp.Id + ConsentType = "AllPrincipals" + ResourceId = $apiSp.Id + Scope = "access_as_user" + } + New-MgOauth2PermissionGrant -BodyParameter $grant | Out-Null + Write-Host "Admin consent granted successfully" -ForegroundColor Green + } else { + Write-Host "Admin consent already exists" + } +} catch { + Write-Host "" + Write-Host "⚠️ Could not grant admin consent automatically." -ForegroundColor Yellow + Write-Host " This requires DelegatedPermissionGrant.ReadWrite.All permission." -ForegroundColor Yellow + Write-Host "" + Write-Host " To grant consent manually:" -ForegroundColor Cyan + Write-Host " 1. Go to Azure Portal > Entra ID > App registrations" -ForegroundColor Cyan + Write-Host " 2. Select the web app: $($app.DisplayName)" -ForegroundColor Cyan + Write-Host " 3. Go to 'API permissions'" -ForegroundColor Cyan + Write-Host " 4. Click 'Grant admin consent for [tenant]'" -ForegroundColor Cyan + Write-Host "" + Write-Host " Alternatively, users will be prompted for consent on first sign-in." -ForegroundColor Cyan + Write-Host "" +} + +Write-Host "Web app registration updated" -ForegroundColor Green +``` + +## Error Handling: Admin Script Fallback + +If the user lacks permissions, generate a script for an admin: + +```powershell +# === Generate Admin Script === + +$scriptContent = @" +# ============================================================ +# Admin Script: Entra ID App Provisioning +# ============================================================ +# This script requires Application Administrator or Global Administrator role. +# Generated: $(Get-Date -Format "yyyy-MM-dd HH:mm") +# Solution: $SolutionName +# Tenant: $TenantId +# ============================================================ + +# Prerequisites - run once +# Install-Module Microsoft.Graph.Applications -Scope CurrentUser -Force + +# Connect with admin privileges +Connect-MgGraph -Scopes "Application.ReadWrite.All", "Directory.ReadWrite.All" + +Write-Host "Provisioning Entra ID apps..." -ForegroundColor Cyan + +# [Full provisioning script content here] + +Write-Host "" +Write-Host "=== PROVISIONING COMPLETE ===" -ForegroundColor Green +Write-Host "API ClientId: `$apiClientId" +Write-Host "Web ClientId: `$webClientId" +Write-Host "" +Write-Host "Please provide these values to the developer." + +# Cleanup +Disconnect-MgGraph +"@ + +$scriptPath = "entra-provision-admin.ps1" +$scriptContent | Out-File -FilePath $scriptPath -Encoding UTF8 +Write-Host "Admin script saved to: $scriptPath" -ForegroundColor Yellow +``` + +## Configuration Reference + +### API appsettings.json + +```json +{ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "YOUR_TENANT_ID", + "ClientId": "YOUR_API_CLIENT_ID", + "Audiences": ["api://YOUR_API_CLIENT_ID"] + } +} +``` + +### Web App appsettings.json + +```json +{ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "YOUR_TENANT_ID", + "ClientId": "YOUR_WEB_CLIENT_ID", + "CallbackPath": "/signin-oidc" + }, + "DownstreamApi": { + "Scopes": ["api://YOUR_API_CLIENT_ID/.default"] + } +} +``` + +## Best Practices + +1. **Provision API first** β€” Web app needs the API's Client ID and scope ID +2. **Use `.default` scope** β€” Safer for downstream API calls in composed scenarios +3. **Store secrets in user-secrets** β€” Never commit secrets to source control +4. **Single tenant by default** β€” Use `AzureADMyOrg`; switch to `AzureADMultipleOrgs` only when needed +5. **Parse launchSettings.json** β€” Get accurate redirect URIs for all launch profiles +6. **Complement, don't duplicate** β€” When using existing apps, only add what's missing +7. **Disconnect when done** β€” Run `Disconnect-MgGraph` after provisioning + +## Related + +- [Entra ID Aspire Authentication Skill](../entra-id-aspire-authentication/SKILL.md) β€” Code wiring (run first) +- [Aspire Framework Docs](../../docs/frameworks/aspire.md) β€” Full integration guide +- [Microsoft Graph PowerShell SDK](https://learn.microsoft.com/powershell/microsoftgraph/) β€” Reference +- [New-MgApplication](https://learn.microsoft.com/powershell/module/microsoft.graph.applications/new-mgapplication) β€” App registration cmdlet diff --git a/docs/frameworks/aspire.md b/docs/frameworks/aspire.md index 74e93b30b..7bf0f267b 100644 --- a/docs/frameworks/aspire.md +++ b/docs/frameworks/aspire.md @@ -14,31 +14,200 @@ aspire new aspire-starter --name MyService --- +## Table of contents + +- [Prerequisites](#prerequisites) +- [Two-phase implementation workflow](#two-phase-implementation-workflow) +- [App registrations in Entra ID](#app-registrations-in-entra-id) +- [Quick start (TL;DR)](#quick-start-tldr) +- [Files you'll modify](#files-youll-modify) +- [What you'll build & how it works](#what-youll-build--how-it-works) +- [Part 1: Secure the API (Phase 1)](#part-1-secure-the-api-backend-with-microsoftidentityweb) +- [Part 2: Configure Blazor frontend (Phase 1)](#part-2-configure-blazor-frontend-for-authentication) +- [Implementation checklist](#implementation-checklist) +- [Part 3: Testing and troubleshooting](#part-3-testing-and-troubleshooting) +- [Part 4: Common scenarios](#part-4-common-scenarios) +- [Resources](#resources) +- [AI coding assistant skills](#-ai-coding-assistant-skills) + +--- + ## Prerequisites -- **.NET 10 SDK** or later +- **.NET 9 SDK** or later (or .NET 10+ for latest features) - **.NET Aspire CLI** - See [Install Aspire CLI](https://aspire.dev/get-started/install-cli/) -- **Azure AD tenant** with two app registrations +- **Azure AD tenant** β€” See [App Registrations in Entra ID](#app-registrations-in-entra-id) section below for setup + +> πŸ“š **New to Aspire?** See [.NET Aspire Overview](https://learn.microsoft.com/dotnet/aspire/get-started/aspire-overview) + +--- + +## Two-phase implementation workflow + +This guide follows a **two-phase approach**: + +| Phase | What Happens | Result | +|-------|--------------|--------| +| **Phase 1** | Add authentication code with placeholder values | App **builds** but won't **run** | +| **Phase 2** | Provision Entra ID app registrations | App **runs** with real authentication | + +> πŸ’‘ **AI Assistant Tip:** If you're using GitHub Copilot or another AI assistant, the [entra-id-aspire-authentication](../../.github/skills/entra-id-aspire-authentication/SKILL.md) and [entra-id-aspire-provisioning](../../.github/skills/entra-id-aspire-provisioning/SKILL.md) skills can automate both phases. + +--- + +## App registrations in Entra ID
-πŸ“‹ App Registration Details +πŸ“‹ Already have app registrations? Skip to Quick Start or Part 1. -- **Web app** (Blazor): supports redirect URIs (configured to `{URL of the blazorapp}/signin-oidc`). For details see: - - [How to add a redirect URI to your application](https://learn.microsoft.com/entra/identity-platform/how-to-add-redirect-uri) -- **API app** (ApiService): exposes scopes (e.g., App ID URI is `api://`). For details, see: - - [Configure an application to expose a web API](https://learn.microsoft.com/entra/identity-platform/quickstart-configure-app-expose-web-apis) -- **Client credentials** (certificate or secret) for the web app registration. For details see: - - [Add and manage application credentials in Microsoft Entra ID](https://learn.microsoft.com/entra/identity-platform/how-to-add-credentials?tabs=certificate) and [Client credentials](../authentication/credentials/credentials-README.md) +If you already have app registrations configured, you just need these values for your `appsettings.json`: +- **TenantId** β€” Your Azure AD tenant ID +- **API ClientId** β€” Application (client) ID of your API app registration +- **API App ID URI** β€” Usually `api://` (used in `Audiences` and `Scopes`) +- **Web App ClientId** β€” Application (client) ID of your web app registration +- **Client Secret** (or certificate) β€” Credential for the web app (store in user-secrets, not appsettings.json) +- **Scopes** β€” The scope(s) your web app requests, e.g., `api:///.default` or `api:///access_as_user`
-> πŸ“š **New to Aspire?** See [.NET Aspire Overview](https://learn.microsoft.com/dotnet/aspire/get-started/aspire-overview) +Before your app can authenticate users, you need **two app registrations** in Microsoft Entra ID: + +| App Registration | Purpose | Key Configuration | +|------------------|---------|-------------------| +| **API** (`MyService.ApiService`) | Validates incoming tokens | App ID URI, `access_as_user` scope | +| **Web App** (`MyService.Web`) | Signs in users, acquires tokens | Redirect URIs, client secret, API permissions | + +### Option A: Azure Portal (manual) + +
+πŸ“‹ Step 1: Register the API + +1. Go to [Microsoft Entra admin center](https://entra.microsoft.com) > **Identity** > **Applications** > **App registrations** +2. Click **New registration** + - **Name:** `MyService.ApiService` + - **Supported account types:** Accounts in this organizational directory only (Single tenant) + - Click **Register** +3. **Expose an API:** + - Go to **Expose an API** > **Add** next to Application ID URI + - Accept the default (`api://`) or customize it + - Click **Add a scope**: + - **Scope name:** `access_as_user` + - **Who can consent:** Admins and users + - **Admin consent display name:** Access MyService API + - **Admin consent description:** Allows the app to access MyService API on behalf of the signed-in user. + - Click **Add scope** +4. Copy the **Application (client) ID** β€” you'll need this for both `appsettings.json` files + +πŸ“š [Quickstart: Configure an app to expose a web API](https://learn.microsoft.com/entra/identity-platform/quickstart-configure-app-expose-web-apis) + +
+ +
+πŸ“‹ Step 2: Register the Web App + +1. Go to **App registrations** > **New registration** + - **Name:** `MyService.Web` + - **Supported account types:** Accounts in this organizational directory only + - **Redirect URI:** Select **Web** and enter your app's URL + `/signin-oidc` + - For local development: `https://localhost:7001/signin-oidc` (check your `launchSettings.json` for the actual port) + - Click **Register** +2. **Configure redirect URIs:** + - Go to **Authentication** > **Add URI** to add all your dev URLs (from `launchSettings.json`) +3. **Create a client secret:** + - Go to **Certificates & secrets** > **Client secrets** > **New client secret** + - Add a description and expiration + - **Copy the secret value immediately** β€” it won't be shown again! + + > **Note:** Some organizations don't allow client secrets. Alternatives: + > - **Certificates** β€” See [Certificate credentials](https://learn.microsoft.com/entra/identity-platform/certificate-credentials) and [Microsoft.Identity.Web certificate support](../authentication/credentials/credentials-README.md#certificates) + > - **Federated Identity Credentials (FIC) + Managed Identity** β€” See [Workload identity federation](https://learn.microsoft.com/entra/workload-id/workload-identity-federation) and [Certificateless authentication](../authentication/credentials/certificateless.md) + +4. **Add API permission:** + - Go to **API permissions** > **Add a permission** > **My APIs** + - Select `MyService.ApiService` + - Check `access_as_user` > **Add permissions** + - Click **Grant admin consent for [tenant]** (or users will be prompted) +5. Copy the **Application (client) ID** for the web app's `appsettings.json` + +πŸ“š [Quickstart: Register an application](https://learn.microsoft.com/entra/identity-platform/quickstart-register-app) +πŸ“š [Add credentials to your app](https://learn.microsoft.com/entra/identity-platform/quickstart-register-app#add-credentials) +πŸ“š [Add a redirect URI](https://learn.microsoft.com/entra/identity-platform/quickstart-register-app#add-a-redirect-uri) + +
+ +
+πŸ“‹ Step 3: Update Configuration + +After creating the app registrations, update your `appsettings.json` files: + +**API (`MyService.ApiService/appsettings.json`):** +```json +{ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "YOUR_TENANT_ID", + "ClientId": "YOUR_API_CLIENT_ID", + "Audiences": ["api://YOUR_API_CLIENT_ID"] + } +} +``` + +**Web App (`MyService.Web/appsettings.json`):** +```json +{ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "TenantId": "YOUR_TENANT_ID", + "ClientId": "YOUR_WEB_CLIENT_ID", + "CallbackPath": "/signin-oidc", + "ClientCredentials": [ + { "SourceType": "ClientSecret" } + ] + }, + "WeatherApi": { + "Scopes": ["api://YOUR_API_CLIENT_ID/.default"] + } +} +``` + +**Store the secret securely:** +```powershell +cd MyService.Web +dotnet user-secrets set "AzureAd:ClientCredentials:0:ClientSecret" "YOUR_SECRET_VALUE" +``` + +| Value | Where to Find | +|-------|---------------| +| `TenantId` | Azure Portal > Entra ID > Overview > Tenant ID | +| `API ClientId` | App registrations > MyService.ApiService > Application (client) ID | +| `Web ClientId` | App registrations > MyService.Web > Application (client) ID | +| `Client Secret` | Created in Step 2 (copy immediately when created) | + +
+ +### Option B: Automated with PowerShell + +For automated provisioning using Microsoft Graph PowerShell, use the **entra-id-aspire-provisioning** skill: + +```powershell +# Prerequisites (one-time) +Install-Module Microsoft.Graph.Applications -Scope CurrentUser + +# Connect to your tenant +Connect-MgGraph -Scopes "Application.ReadWrite.All" +``` + +Then ask your AI assistant: +> "Using the entra-id-aspire-provisioning skill, create app registrations for my Aspire solution" + +πŸ“š [Microsoft Graph PowerShell SDK](https://learn.microsoft.com/powershell/microsoftgraph/installation) +πŸ“ [Provisioning Skill](../../.github/skills/entra-id-aspire-provisioning/SKILL.md) --- > **Note:** The Aspire starter template automatically creates a `WeatherApiClient` class in the `MyService.Web` project. This "typed HttpClient" is used throughout this guide to demonstrate calling the protected API. You don't need to create this class yourselfβ€”it's part of the template. -## Quick Start (TL;DR) +## Quick start (TL;DR)
Click to expand the 5-minute version @@ -100,53 +269,65 @@ builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) .EnableTokenAcquisitionToCallDownstreamApi() .AddInMemoryTokenCaches(); +builder.Services.AddCascadingAuthenticationState(); +builder.Services.AddScoped(); + builder.Services.AddHttpClient(client => client.BaseAddress = new("https+http://apiservice")) .AddMicrosoftIdentityMessageHandler(builder.Configuration.GetSection("WeatherApi")); // ... app.UseAuthentication(); app.UseAuthorization(); +app.MapGroup("/authentication").MapLoginAndLogout(); ``` -**That's it!** The `MicrosoftIdentityMessageHandler` automatically acquires and attaches tokens. +> ⚠️ **Don't forget:** Copy the helper files (`BlazorAuthenticationChallengeHandler.cs`, `LoginLogoutEndpointRouteBuilderExtensions.cs`) and create `UserInfo.razor`. See [Part 2](#part-2-configure-blazor-frontend-for-authentication) for details. + +**That's it!** The `MicrosoftIdentityMessageHandler` automatically acquires and attaches tokens, and `BlazorAuthenticationChallengeHandler` handles consent/Conditional Access challenges.
--- -## Files You'll Modify +## Files you'll modify | Project | File | Changes | |---------|------|---------| | **ApiService** | `Program.cs` | JWT Bearer auth, authorization middleware | | | `appsettings.json` | Azure AD configuration | | | `.csproj` | Add `Microsoft.Identity.Web` | -| **Web** | `Program.cs` | OIDC auth, token acquisition, message handler | +| **Web** | `Program.cs` | OIDC auth, token acquisition, BlazorAuthenticationChallengeHandler | | | `appsettings.json` | Azure AD config, downstream API scopes | | | `.csproj` | Add `Microsoft.Identity.Web` | -| | `LoginLogoutEndpointRouteBuilderExtensions.cs` | Login/logout endpoints *(new file)* | -| | `Components/Layout/LogInOrOut.razor` | Login/logout UI *(new file)* | +| | `LoginLogoutEndpointRouteBuilderExtensions.cs` | Login/logout with incremental consent *(copy from skill)* | +| | `BlazorAuthenticationChallengeHandler.cs` | Auth challenge handler *(copy from skill)* | +| | `Components/UserInfo.razor` | **Login button UI** *(new file)* | +| | `Components/Layout/MainLayout.razor` | Include UserInfo component | +| | `Components/Routes.razor` | AuthorizeRouteView for protected pages | +| | Pages calling APIs | Try/catch with ChallengeHandler | --- -## What you'll Build & How It Works +## What you'll build & how it works ```mermaid flowchart LR A[User Browser] -->|1 Login OIDC| B[Blazor Server
MyService.Web] B -->|2 Redirect| C[Entra ID] C -->|3 auth code| B - C -->|4 id token + cookie + Access token| B - B -->|5 HTTP + Bearer token| D[ASP.NET API
MyService.ApiService
Microsoft.Identity.Web] - D -->|6 Validate JWT| C - D -->|7 Weather data| B + B -->|4 exchange auth code| C + C -->|5 tokens| B + B -->|6 cookie + session| A + B -->|7 HTTP + Bearer token| D[ASP.NET API
MyService.ApiService
Microsoft.Identity.Web] + D -->|8 Validate JWT| C + D -->|9 Weather data| B ``` **Key Technologies:** - **Microsoft.Identity.Web** (Blazor & API): OIDC authentication, JWT validation, token acquisition - **.NET Aspire**: Service discovery (`https+http://apiservice`), orchestration, health checks -### How the Authentication Flow Works +### How the authentication flow works 1. **User visits Blazor app** β†’ Not authenticated β†’ sees "Login" button 2. **User clicks Login** β†’ Redirects to `/authentication/login` β†’ OIDC challenge β†’ Entra ID @@ -180,7 +361,7 @@ client.BaseAddress = new("https+http://apiservice"); --- -## Solution Structure +## Solution structure ``` MyService/ @@ -193,13 +374,15 @@ MyService/ --- -## Part 1: Secure the API Backend with Microsoft.Identity.Web +## Part 1: Secure the API backend with Microsoft.Identity.Web + +> πŸ“ **You are in Phase 1** β€” Parts 1 and 2 add the authentication code. After completing both, proceed to [App Registrations](#app-registrations-in-entra-id) if you haven't already. **Microsoft.Identity.Web** provides streamlined JWT Bearer authentication for ASP.NET Core APIs with minimal configuration. πŸ“š **Learn more:** [Microsoft.Identity.Web Documentation](https://github.com/AzureAD/microsoft-identity-web/tree/master/docs) -### 1.1: Add Microsoft.Identity.Web Package +### 1.1: Add Microsoft.Identity.Web package ```powershell cd MyService.ApiService @@ -218,7 +401,7 @@ dotnet add package Microsoft.Identity.Web -### 1.2: Configure Azure AD Settings +### 1.2: Configure Azure AD settings Add Azure AD configuration to `MyService.ApiService/appsettings.json`: @@ -320,7 +503,7 @@ record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -### 1.4: Test the Protected API +### 1.4: Test the protected API
πŸ§ͺ Test with curl @@ -343,14 +526,16 @@ curl -H "Authorization: Bearer " https://localhost:/weatherforecast --- -## Part 2: Configure Blazor Frontend for Authentication +## Part 2: Configure Blazor frontend for authentication + +> πŸ“ **Still in Phase 1** β€” This part completes the code implementation. You'll need the helper files from the skill folder. The Blazor Server app uses **Microsoft.Identity.Web** to: - Sign users in with OIDC - Acquire access tokens to call the API - Attach tokens to outgoing HTTP requests -### 2.1: Add Microsoft.Identity.Web Package +### 2.1: Add Microsoft.Identity.Web package ```powershell cd MyService.Web @@ -368,7 +553,7 @@ dotnet add package Microsoft.Identity.Web
-### 2.2: Configure Azure AD Settings +### 2.2: Configure Azure AD settings Add Azure AD configuration and downstream API scopes to `MyService.Web/appsettings.json`: @@ -438,23 +623,25 @@ builder.Services.Configure(OpenIdConnectDefaults.Authentic builder.Services.AddCascadingAuthenticationState(); -// 2) Blazor + consent handler +// 2) Blazor components builder.Services.AddRazorComponents().AddInteractiveServerComponents(); -builder.Services.AddServerSideBlazor().AddMicrosoftIdentityConsentHandler(); + +// 3) Blazor authentication challenge handler for incremental consent & Conditional Access +builder.Services.AddScoped(); builder.Services.AddOutputCache(); -// 3) Downstream API client with MicrosoftIdentityMessageHandler +// 4) Downstream API client with MicrosoftIdentityMessageHandler builder.Services.AddHttpClient(client => { - // Aspire service discovery: resolves "apiservice" at runtime + // Aspire service discovery: resolves "apiservice" at runtime client.BaseAddress = new("https+http://apiservice"); }) .AddMicrosoftIdentityMessageHandler(builder.Configuration.GetSection("WeatherApi")); var app = builder.Build(); -if (! app.Environment.IsDevelopment()) +if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseHsts(); @@ -470,7 +657,7 @@ app.MapStaticAssets(); app.MapRazorComponents() .AddInteractiveServerRenderMode(); -// 4) Login/Logout endpoints +// 5) Login/Logout endpoints with incremental consent support app.MapGroup("/authentication").MapLoginAndLogout(); app.MapDefaultEndpoints(); @@ -482,7 +669,8 @@ app.Run(); - **`AddMicrosoftIdentityWebApp`**: Configures OIDC authentication - **`EnableTokenAcquisitionToCallDownstreamApi`**: Enables token acquisition for downstream APIs -- **`AddMicrosoftIdentityMessageHandler`**: Attaches bearer tokens to HttpClient requests automatically. Reads scopes from the `WeatherApi` configuration section. +- **`AddScoped`**: Handles incremental consent and Conditional Access in Blazor Server +- **`AddMicrosoftIdentityMessageHandler`**: Attaches bearer tokens to HttpClient requests automatically - **`https+http://apiservice`**: Aspire service discovery resolves this to the actual API URL - **Middleware order**: `UseAuthentication()` β†’ `UseAuthorization()` β†’ endpoints @@ -493,7 +681,7 @@ app.Run();
βš™οΈ Alternative Configuration Approaches -#### Alternative Configuration Approaches +#### Alternative configuration approaches The `AddMicrosoftIdentityMessageHandler` extension supports multiple configuration patterns: @@ -538,29 +726,99 @@ builder.Services.AddHttpClient(client =>
-### 2.4: Add Login/Logout Endpoints +### 2.4: Copy helper files from skill folder + +The authentication implementation requires two helper files. **Copy these from the skill folder** rather than creating them manually: + +```powershell +# From your solution root, copy the helper files +$skillPath = ".github/skills/entra-id-aspire-authentication" +Copy-Item "$skillPath/LoginLogoutEndpointRouteBuilderExtensions.cs" "MyService.Web/" +Copy-Item "$skillPath/BlazorAuthenticationChallengeHandler.cs" "MyService.Web/" +``` + +> πŸ’‘ **Tip:** These files are in the `Microsoft.Identity.Web` namespace, so they're available once you reference the package. Eventually they will +> ship in the Microsoft.Identity.Web NuGet packge. + +
+πŸ“„ View LoginLogoutEndpointRouteBuilderExtensions.cs -**Create `MyService.Web/LoginLogoutEndpointRouteBuilderExtensions.cs`:** +This enhanced version supports **incremental consent** and **Conditional Access** via query parameters: ```csharp using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; -using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; -namespace MyService.Web; +namespace Microsoft.Identity.Web; -internal static class LoginLogoutEndpointRouteBuilderExtensions +/// +/// Extension methods for mapping login and logout endpoints that support +/// incremental consent and Conditional Access scenarios. +/// +public static class LoginLogoutEndpointRouteBuilderExtensions { - internal static IEndpointConventionBuilder MapLoginAndLogout(this IEndpointRouteBuilder endpoints) + /// + /// Maps login and logout endpoints under the current route group. + /// The login endpoint supports incremental consent via scope, loginHint, domainHint, and claims parameters. + /// + public static IEndpointConventionBuilder MapLoginAndLogout(this IEndpointRouteBuilder endpoints) { var group = endpoints.MapGroup(""); - group.MapGet("/login", (string? returnUrl) => TypedResults.Challenge(GetAuthProperties(returnUrl))) - .AllowAnonymous(); + // Enhanced login endpoint that supports incremental consent and Conditional Access + group.MapGet("/login", ( + string? returnUrl, + string? scope, + string? loginHint, + string? domainHint, + string? claims) => + { + var properties = GetAuthProperties(returnUrl); + + // Add scopes if provided (for incremental consent) + if (!string.IsNullOrEmpty(scope)) + { + var scopes = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries); + properties.SetParameter(OpenIdConnectParameterNames.Scope, scopes); + } + + // Add login hint (pre-fills username) + if (!string.IsNullOrEmpty(loginHint)) + { + properties.SetParameter(OpenIdConnectParameterNames.LoginHint, loginHint); + } + + // Add domain hint (skips home realm discovery) + if (!string.IsNullOrEmpty(domainHint)) + { + properties.SetParameter(OpenIdConnectParameterNames.DomainHint, domainHint); + } + + // Add claims challenge (for Conditional Access / step-up auth) + if (!string.IsNullOrEmpty(claims)) + { + properties.Items["claims"] = claims; + } - group.MapPost("/logout", ([FromForm] string? returnUrl) => TypedResults.SignOut(GetAuthProperties(returnUrl), - [CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme])); + return TypedResults.Challenge(properties, [OpenIdConnectDefaults.AuthenticationScheme]); + }) + .AllowAnonymous(); + + group.MapPost("/logout", async (HttpContext context) => + { + string? returnUrl = null; + if (context.Request.HasFormContentType) + { + var form = await context.Request.ReadFormAsync(); + returnUrl = form["ReturnUrl"]; + } + + return TypedResults.SignOut(GetAuthProperties(returnUrl), + [CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]); + }) + .DisableAntiforgery(); return group; } @@ -569,7 +827,7 @@ internal static class LoginLogoutEndpointRouteBuilderExtensions { const string pathBase = "/"; if (string.IsNullOrEmpty(returnUrl)) returnUrl = pathBase; - else if (returnUrl.StartsWith("//", StringComparison.Ordinal)) returnUrl = pathBase; // Prevent protocol-relative redirects + else if (returnUrl.StartsWith("//", StringComparison.Ordinal)) returnUrl = pathBase; else if (!Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)) returnUrl = new Uri(returnUrl, UriKind.Absolute).PathAndQuery; else if (returnUrl[0] != '/') returnUrl = $"{pathBase}{returnUrl}"; return new AuthenticationProperties { RedirectUri = returnUrl }; @@ -577,74 +835,387 @@ internal static class LoginLogoutEndpointRouteBuilderExtensions } ``` -**Features:** -- `GET /authentication/login`: Initiates OIDC challenge -- `POST /authentication/logout`: Signs out of both cookie and OIDC schemes -- Prevents open redirects with URL validation +**Key features:** +- `scope`: Request additional scopes (incremental consent) +- `loginHint`: Pre-fill username field +- `domainHint`: Skip home realm discovery (`organizations` or `consumers`) +- `claims`: Pass Conditional Access claims challenge -### 2.5: Add Blazor UI Components +
-πŸ“„ Create LogInOrOut.razor component +πŸ“„ View BlazorAuthenticationChallengeHandler.cs -**Create `MyService.Web/Components/Layout/LogInOrOut.razor`:** +This handler manages authentication challenges in Blazor Server components: -```razor -@implements IDisposable -@inject NavigationManager Navigation - - +```csharp +using System.Security.Claims; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; + +namespace Microsoft.Identity.Web; + +/// +/// Handles authentication challenges for Blazor Server components. +/// Provides functionality for incremental consent and Conditional Access scenarios. +/// +public class BlazorAuthenticationChallengeHandler( + NavigationManager navigation, + AuthenticationStateProvider authenticationStateProvider, + IConfiguration configuration) +{ + private const string MsaTenantId = "9188040d-6c67-4c5b-b112-36a304b66dad"; -@code { - private string? currentUrl; + /// + /// Gets the current user's authentication state. + /// + public async Task GetUserAsync() + { + var authState = await authenticationStateProvider.GetAuthenticationStateAsync(); + return authState.User; + } - protected override void OnInitialized() + /// + /// Checks if the current user is authenticated. + /// + public async Task IsAuthenticatedAsync() { - currentUrl = Navigation.Uri; - Navigation.LocationChanged += OnLocationChanged; + var user = await GetUserAsync(); + return user.Identity?.IsAuthenticated == true; } - private void OnLocationChanged(object? sender, LocationChangedEventArgs e) + /// + /// Handles exceptions that may require user re-authentication. + /// Returns true if a challenge was initiated, false otherwise. + /// + public async Task HandleExceptionAsync(Exception exception) { - currentUrl = Navigation.Uri; - StateHasChanged(); + var challengeException = exception as MicrosoftIdentityWebChallengeUserException + ?? exception.InnerException as MicrosoftIdentityWebChallengeUserException; + + if (challengeException != null) + { + var user = await GetUserAsync(); + ChallengeUser(user, challengeException.Scopes, challengeException.MsalUiRequiredException?.Claims); + return true; + } + + return false; } - public void Dispose() => Navigation.LocationChanged -= OnLocationChanged; + /// + /// Initiates a challenge to authenticate the user or request additional consent. + /// + public void ChallengeUser(ClaimsPrincipal user, string[]? scopes = null, string? claims = null) + { + var currentUri = navigation.Uri; + + // Build scopes string (add OIDC scopes) + var allScopes = (scopes ?? []) + .Union(["openid", "offline_access", "profile"]) + .Distinct(); + var scopeString = Uri.EscapeDataString(string.Join(" ", allScopes)); + + // Get login hint from user claims + var loginHint = Uri.EscapeDataString(GetLoginHint(user)); + + // Get domain hint + var domainHint = Uri.EscapeDataString(GetDomainHint(user)); + + // Build the challenge URL + var challengeUrl = $"/authentication/login?returnUrl={Uri.EscapeDataString(currentUri)}" + + $"&scope={scopeString}" + + $"&loginHint={loginHint}" + + $"&domainHint={domainHint}"; + + // Add claims if present (for Conditional Access) + if (!string.IsNullOrEmpty(claims)) + { + challengeUrl += $"&claims={Uri.EscapeDataString(claims)}"; + } + + navigation.NavigateTo(challengeUrl, forceLoad: true); + } + + /// + /// Initiates a challenge with scopes from configuration. + /// + public async Task ChallengeUserWithConfiguredScopesAsync(string configurationSection) + { + var user = await GetUserAsync(); + var scopes = configuration.GetSection(configurationSection).Get(); + ChallengeUser(user, scopes); + } + + private static string GetLoginHint(ClaimsPrincipal user) + { + return user.FindFirst("preferred_username")?.Value ?? + user.FindFirst("login_hint")?.Value ?? + string.Empty; + } + + private static string GetDomainHint(ClaimsPrincipal user) + { + var tenantId = user.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid")?.Value ?? + user.FindFirst("tid")?.Value; + + if (string.IsNullOrEmpty(tenantId)) + return "organizations"; + + // MSA tenant + if (tenantId == MsaTenantId) + return "consumers"; + + return "organizations"; + } } ``` +**Key methods:** +- `IsAuthenticatedAsync()`: Check if user is signed in before API calls +- `HandleExceptionAsync(ex)`: Catches `MicrosoftIdentityWebChallengeUserException` and redirects for re-auth +- `ChallengeUser()`: Manually trigger authentication with specific scopes/claims +- `ChallengeUserWithConfiguredScopesAsync()`: Challenge with scopes from config section + +
+ +### 2.5: Add Blazor UI components + +> ⚠️ **CRITICAL: This step is frequently forgotten!** Without the UserInfo component, users have **no way to log in**. + +
+πŸ“„ Create UserInfo.razor component (THE LOGIN BUTTON) + +**Create `MyService.Web/Components/UserInfo.razor`:** + +```razor +@using Microsoft.AspNetCore.Components.Authorization + + + + Hello, @context.User.Identity?.Name + + + Login + + +``` + **Key Features:** - ``: Renders different UI based on auth state -- Tracks `currentUrl` for proper post-login/logout redirection -- Implements `IDisposable` to clean up event handlers +- Shows username when authenticated +- Login link redirects to OIDC flow +- Logout form posts to sign-out endpoint
-**Add to Navigation:** Include `` in your `NavMenu.razor` or `MainLayout.razor`. +**Add to Layout:** Include `` in your `MainLayout.razor`: + +```razor +@inherits LayoutComponentBase + +
+ + +
+
+ @* <-- THE LOGIN BUTTON *@ +
+ +
+ @Body +
+
+
+``` + +### 2.6: Update Routes.razor for AuthorizeRouteView + +Replace `RouteView` with `AuthorizeRouteView` in `Components/Routes.razor`: + +```razor +@using Microsoft.AspNetCore.Components.Authorization + + + + + +

You are not authorized to view this page.

+ Login +
+
+ +
+
+``` + +### 2.7: Handle exceptions on pages calling APIs + +> ⚠️ **This is NOT optional** β€” Blazor Server requires explicit exception handling for Conditional Access and consent. + +When calling APIs, Conditional Access policies or consent requirements can trigger `MicrosoftIdentityWebChallengeUserException`. You **MUST** handle this on **every page that calls a downstream API** unless your app is pre-authrorized and you have requested all the scopes ahead of time (in the Program.cs), which is possible if you call only one downstream API. + +**Example: Weather.razor with proper exception handling:** + +```razor +@page "/weather" +@attribute [Authorize] + +@using Microsoft.AspNetCore.Authorization +@using Microsoft.Identity.Web + +@inject WeatherApiClient WeatherApi +@inject BlazorAuthenticationChallengeHandler ChallengeHandler + +Weather + +

Weather

+ +@if (!string.IsNullOrEmpty(errorMessage)) +{ +
@errorMessage
+} +else if (forecasts == null) +{ +

Loading...

+} +else +{ + + + + + + + + + + @foreach (var forecast in forecasts) + { + + + + + + } + +
DateTemp. (C)Summary
@forecast.Date.ToShortDateString()@forecast.TemperatureC@forecast.Summary
+} + +@code { + private WeatherForecast[]? forecasts; + private string? errorMessage; + + protected override async Task OnInitializedAsync() + { + // Check authentication before making API calls + if (!await ChallengeHandler.IsAuthenticatedAsync()) + { + // Not authenticated - redirect to login with required scopes + await ChallengeHandler.ChallengeUserWithConfiguredScopesAsync("WeatherApi:Scopes"); + return; + } + + try + { + forecasts = await WeatherApi.GetWeatherAsync(); + } + catch (Exception ex) + { + // Handle incremental consent / Conditional Access + if (!await ChallengeHandler.HandleExceptionAsync(ex)) + { + // Not a challenge exception - show error to user + errorMessage = $"Error loading weather data: {ex.Message}"; + } + // If HandleExceptionAsync returned true, user is being redirected + } + } +} +``` + +
+🧠 Why this pattern? + +1. **`IsAuthenticatedAsync()`** checks if user is signed in before making API calls +2. **`HandleExceptionAsync()`** catches `MicrosoftIdentityWebChallengeUserException` (or as InnerException) +3. If it **is** a challenge exception β†’ redirects user to re-authenticate with required claims/scopes +4. If it is **NOT** a challenge exception β†’ returns false so you can handle the error + +**Why isn't this automatic?** Blazor Server's circuit-based architecture requires explicit handling. The handler re-challenges the user by navigating to the login endpoint with the required claims/scopes. + +
+ +### 2.8: Store client secret in user secrets + +> ⚠️ **Never commit secrets to source control!** + +```powershell +cd MyService.Web +dotnet user-secrets init +dotnet user-secrets set "AzureAd:ClientCredentials:0:ClientSecret" "" +``` + +Then update `appsettings.json` to reference user secrets (remove the hardcoded secret): + +```jsonc +{ + "AzureAd": { + "ClientCredentials": [ + { + // Secret is stored in user-secrets, not here + // For more options see https://aka.ms/ms-id-web/credentials + "SourceType": "ClientSecret" + } + ] + } +} +``` + +Alternatively, Microsoft.Identity.Web offers all kind of client credentials. See [Client credentials](../authentication/credentials/credentials-README.md) + +--- + +## Implementation checklist + +Use this checklist to verify all steps are complete: + +### API project +- [ ] Added `Microsoft.Identity.Web` package +- [ ] Updated `appsettings.json` with `AzureAd` section +- [ ] Updated `Program.cs` with `AddMicrosoftIdentityWebApi` +- [ ] Added `.RequireAuthorization()` to protected endpoints + +### Web/Blazor project +- [ ] Added `Microsoft.Identity.Web` package +- [ ] Updated `appsettings.json` with `AzureAd` and `WeatherApi` sections +- [ ] Updated `Program.cs` with OIDC, token acquisition +- [ ] Added `AddScoped()` +- [ ] Copied `LoginLogoutEndpointRouteBuilderExtensions.cs` from skill folder +- [ ] Copied `BlazorAuthenticationChallengeHandler.cs` from skill folder +- [ ] Created `Components/UserInfo.razor` (**THE LOGIN BUTTON**) +- [ ] Updated `MainLayout.razor` to include `` +- [ ] Updated `Routes.razor` with `AuthorizeRouteView` +- [ ] Added try/catch with `ChallengeHandler` on **every page calling APIs** +- [ ] Stored client secret in user-secrets + +### Verification +- [ ] `dotnet build` succeeds +- [ ] App registrations created (via provisioning skill or Azure Portal) +- [ ] `appsettings.json` has real GUIDs (no placeholders) --- -## Part 3: Testing and Troubleshooting +## Part 3: Testing and troubleshooting -### 3.1: Run the Application +### 3.1: Run the application ```powershell # From solution root @@ -655,14 +1226,14 @@ dotnet build dotnet run --project .\MyService.AppHost\MyService.AppHost.csproj ``` -### 3.2: Test Flow +### 3.2: Test flow 1. Open browser β†’ Blazor Web UI (check Aspire dashboard for URL) 2. Click **Login** β†’ Sign in with Azure AD 3. Navigate to **Weather** page 4. Verify weather data loads (from protected API) -### 3.3: Common Issues +### 3.3: Common issues | Issue | Solution | |-------|----------| @@ -672,8 +1243,11 @@ dotnet run --project .\MyService.AppHost\MyService.AppHost.csproj | **Service discovery fails** | Check `AppHost.cs` references both projects and they're running | | **AADSTS65001** | Admin consent required - grant consent in Azure Portal | | **CORS errors** | Add CORS policy in API `Program.cs` if needed | +| **No login button** | Ensure `UserInfo.razor` exists and is included in `MainLayout.razor` | +| **404 on `/MicrosoftIdentity/Account/Challenge`** | Use `BlazorAuthenticationChallengeHandler` instead of old `MicrosoftIdentityConsentHandler` | +| **Consent loop** | Ensure try/catch with `HandleExceptionAsync` is on all API-calling pages | -### 3.4: Enable MSAL Logging +### 3.4: Enable MSAL logging
πŸ” Debug authentication with MSAL logs @@ -722,7 +1296,7 @@ dbug: Microsoft.Identity.Web.MicrosoftIdentityMessageHandler[0]
-### 3.5: Inspect Tokens +### 3.5: Inspect tokens
🎫 Decode and verify JWT tokens @@ -738,9 +1312,9 @@ To debug token issues, decode your JWT at [jwt.ms](https://jwt.ms) and verify: --- -## Part 4: Common Scenarios +## Part 4: Common scenarios -### 4.1: Protect Blazor Pages +### 4.1: Protect Blazor pages Add `[Authorize]` to pages requiring authentication: @@ -763,7 +1337,7 @@ builder.Services.AddAuthorization(options => @attribute [Authorize(Policy = "AdminOnly")] ``` -### 4.2: Scope Validation in the API +### 4.2: Scope validation in the API To ensure the API only accepts tokens with specific scopes: @@ -790,7 +1364,7 @@ builder.Services.AddAuthorization(options => }); ``` -### 4.3: Use App-Only Tokens (Service-to-Service) +### 4.3: Use app-only tokens (service-to-service) For daemon scenarios or service-to-service calls without a user context: @@ -807,7 +1381,7 @@ builder.Services.AddHttpClient(client => }); ``` -### 4.4: Override Options Per Request +### 4.4: Override options per request Override default options on a per-request basis using the `WithAuthenticationOptions` extension method: @@ -838,7 +1412,7 @@ public class WeatherApiClient } ``` -### 4.5: Use Federated identity credentials with Managed Identity (Production) +### 4.5: Use federated identity credentials with Managed Identity (production) For production deployments in Azure, use managed identity instead of client secrets: @@ -868,22 +1442,30 @@ For production deployments in Azure, use managed identity instead of client secr 3. Acquire a new token with the additional claims 4. Retry the request with the new token -For scenarios where you need to handle consent in the UI: +For Blazor Server, use the `BlazorAuthenticationChallengeHandler` to handle consent and Conditional Access in the UI: -```csharp -// In Weather.razor or API client -try -{ - forecasts = await WeatherApi.GetAsync(); -} -catch (MicrosoftIdentityWebChallengeUserException ex) -{ - // Re-challenge user with additional claims - ConsentHandler.HandleException(ex); +```razor +@inject BlazorAuthenticationChallengeHandler ChallengeHandler + +@code { + try + { + forecasts = await WeatherApi.GetWeatherAsync(); + } + catch (Exception ex) + { + // Handles MicrosoftIdentityWebChallengeUserException and redirects for re-auth + if (!await ChallengeHandler.HandleExceptionAsync(ex)) + { + errorMessage = $"Error: {ex.Message}"; + } + } } ``` -### 4.7: Multi-Tenant API +> πŸ“š See [Section 2.7](#27-handle-exceptions-on-pages-calling-apis) for the complete pattern. + +### 4.7: Multi-tenant API To accept tokens from any Azure AD tenant: @@ -897,7 +1479,7 @@ To accept tokens from any Azure AD tenant: } ``` -### 4.8: Call Downstream APIs from the API (On-Behalf-Of) +### 4.8: Call downstream APIs from the API (on-behalf-of) If your API needs to call another downstream API on behalf of the user: @@ -931,7 +1513,7 @@ app.MapGet("/me", async (IDownstreamApi downstreamApi) => πŸ“š [Calling Downstream APIs](../calling-downstream-apis/calling-downstream-apis-README.md) -### 4.9: Composing with Other Handlers +### 4.9: Composing with other handlers Chain multiple handlers in the pipeline: @@ -983,15 +1565,22 @@ builder.Services.AddHttpClient(client => --- -## πŸ€– AI Coding Assistant Skill +## πŸ€– AI coding assistant skills + +This guide has companion **AI Skills** for GitHub Copilot, Claude, and other AI coding assistants. The skills help automate both phases of the implementation: -A condensed version of this guide is available as an **AI Skill** for GitHub Copilot, Claude, and other AI coding assistants. The skill helps AI assistants implement this authentication pattern in your Aspire projects. +| Skill | Purpose | Location | +|-------|---------|----------| +| **entra-id-aspire-authentication** | Phase 1: Add authentication code | [SKILL.md](../../.github/skills/entra-id-aspire-authentication/SKILL.md) | +| **entra-id-aspire-provisioning** | Phase 2: Create app registrations | [SKILL.md](../../.github/skills/entra-id-aspire-provisioning/SKILL.md) | -πŸ“ **Location:** [.github/skills/entra-id-aspire-authentication/SKILL.md](../../.github/skills/entra-id-aspire-authentication/SKILL.md) +The authentication skill folder also contains **ready-to-copy helper files**: +- `BlazorAuthenticationChallengeHandler.cs` - Handles incremental consent and Conditional Access +- `LoginLogoutEndpointRouteBuilderExtensions.cs` - Enhanced login/logout endpoints See the [Skills README](../../.github/skills/README.md) for installation instructions. --- -**Last Updated:** January 2025 -**Solution:** MyService (.NET 10, Aspire, Microsoft.Identity.Web) +**Last Updated:** February 2026 +**Solution:** MyService (.NET Aspire, Microsoft.Identity.Web)