You are not authorized to view this page.
+ Login +Weather
+ +@if (!string.IsNullOrEmpty(errorMessage)) +{ +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π 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://π 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://π 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) | + +Click to expand the 5-minute version
@@ -100,53 +269,65 @@ builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) .EnableTokenAcquisitionToCallDownstreamApi() .AddInMemoryTokenCaches(); +builder.Services.AddCascadingAuthenticationState(); +builder.Services.AddScopedMyService.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βοΈ Alternative Configuration Approaches
-#### Alternative Configuration Approaches +#### Alternative configuration approaches The `AddMicrosoftIdentityMessageHandler` extension supports multiple configuration patterns: @@ -538,29 +726,99 @@ builder.Services.AddHttpClientπ 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+ /// Checks if the current user is authenticated. + ///
+ public async Task+ /// Handles exceptions that may require user re-authentication. + /// Returns true if a challenge was initiated, false otherwise. + ///
+ public async Task+ /// 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π Create UserInfo.razor component (THE LOGIN BUTTON)
+ +**Create `MyService.Web/Components/UserInfo.razor`:** + +```razor +@using Microsoft.AspNetCore.Components.Authorization + +You are not authorized to view this page.
+ Login +Weather
+ +@if (!string.IsNullOrEmpty(errorMessage)) +{ +Loading...
+} +else +{ +| Date | +Temp. (C) | +Summary | +
|---|---|---|
| @forecast.Date.ToShortDateString() | +@forecast.TemperatureC | +@forecast.Summary | +