-
Notifications
You must be signed in to change notification settings - Fork 254
Move boilerplate code skills to IdWeb, and add Aspire DevApp demonstrating Blazor authentication components #3721
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+1,787
−0
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
c1d56ed
Initial plan
Copilot 225d976
Add Blazor authentication components to Microsoft.Identity.Web
Copilot 9ea6021
Add unit tests for Blazor authentication components
Copilot 22c63cd
Remove placeholder tests per code review feedback
Copilot c91634d
Add Aspire DevApp for Blazor authentication demonstration
Copilot 722ab31
Add comprehensive README for Aspire DevApp
Copilot 99204c9
Merge branch 'master' into copilot/integrate-blazor-authentication
jmprieur 3f4560a
Adding an Aspire app (aspire new)
jmprieur b61b024
Adding the devapp with auth.
jmprieur 4f4591e
Merge branch 'master' into copilot/integrate-blazor-authentication
jmprieur 94e78cb
Merge branch 'master' into copilot/integrate-blazor-authentication
jmprieur 4ec9db8
Merge branch 'master' into copilot/integrate-blazor-authentication
jmprieur 7064f37
Merge branch 'master' into copilot/integrate-blazor-authentication
jmprieur File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
137 changes: 137 additions & 0 deletions
137
src/Microsoft.Identity.Web/Blazor/BlazorAuthenticationChallengeHandler.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT License. | ||
|
|
||
| using System; | ||
| using System.Diagnostics.CodeAnalysis; | ||
| using System.Linq; | ||
| using System.Security.Claims; | ||
| using System.Threading.Tasks; | ||
| using Microsoft.AspNetCore.Components; | ||
| using Microsoft.AspNetCore.Components.Authorization; | ||
| using Microsoft.Extensions.Configuration; | ||
|
|
||
| namespace Microsoft.Identity.Web; | ||
|
|
||
| /// <summary> | ||
| /// Handles authentication challenges for Blazor Server components. | ||
| /// Provides functionality for incremental consent and Conditional Access scenarios. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// This handler is designed specifically for Blazor Server scenarios where authentication | ||
| /// challenges need to be initiated from component code. It supports incremental consent | ||
| /// (requesting additional scopes) and Conditional Access (handling step-up authentication). | ||
| /// Use this in combination with <see cref="LoginLogoutEndpointRouteBuilderExtensions.MapLoginAndLogout"/> | ||
| /// to enable seamless authentication flows in Blazor Server applications. | ||
| /// </remarks> | ||
| public class BlazorAuthenticationChallengeHandler( | ||
| NavigationManager navigation, | ||
| AuthenticationStateProvider authenticationStateProvider, | ||
| IConfiguration configuration) | ||
| { | ||
| private const string MsaTenantId = "9188040d-6c67-4c5b-b112-36a304b66dad"; | ||
|
|
||
| /// <summary> | ||
| /// Gets the current user's authentication state. | ||
| /// </summary> | ||
| public async Task<ClaimsPrincipal> GetUserAsync() | ||
| { | ||
| var authState = await authenticationStateProvider.GetAuthenticationStateAsync(); | ||
| return authState.User; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Checks if the current user is authenticated. | ||
| /// </summary> | ||
| public async Task<bool> IsAuthenticatedAsync() | ||
| { | ||
| var user = await GetUserAsync(); | ||
| return user.Identity?.IsAuthenticated == true; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Handles exceptions that may require user re-authentication. | ||
| /// Returns true if a challenge was initiated, false otherwise. | ||
| /// </summary> | ||
| public async Task<bool> 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; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Initiates a challenge to authenticate the user or request additional consent. | ||
| /// </summary> | ||
| 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); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Initiates a challenge with scopes from configuration. | ||
| /// </summary> | ||
| [RequiresUnreferencedCode("Binding strongly typed objects from configuration values may require generating dynamic code at runtime.")] | ||
| [RequiresDynamicCode("Binding strongly typed objects from configuration values may require generating dynamic code at runtime.")] | ||
| public async Task ChallengeUserWithConfiguredScopesAsync(string configurationSection) | ||
bgavrilMS marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| { | ||
| var user = await GetUserAsync(); | ||
| var scopes = configuration.GetSection(configurationSection).Get<string[]>(); | ||
| 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)) | ||
jmprieur marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return "organizations"; | ||
|
|
||
| // MSA tenant | ||
| if (tenantId == MsaTenantId) | ||
| return "consumers"; | ||
|
|
||
| return "organizations"; | ||
| } | ||
| } | ||
108 changes: 108 additions & 0 deletions
108
src/Microsoft.Identity.Web/Blazor/LoginLogoutEndpointRouteBuilderExtensions.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT License. | ||
|
|
||
| using System; | ||
| using System.Diagnostics.CodeAnalysis; | ||
| using System.Threading.Tasks; | ||
| using Microsoft.AspNetCore.Authentication; | ||
| using Microsoft.AspNetCore.Authentication.Cookies; | ||
| using Microsoft.AspNetCore.Authentication.OpenIdConnect; | ||
| using Microsoft.AspNetCore.Builder; | ||
| using Microsoft.AspNetCore.Http; | ||
| using Microsoft.AspNetCore.Routing; | ||
| using Microsoft.IdentityModel.Protocols.OpenIdConnect; | ||
|
|
||
| namespace Microsoft.Identity.Web; | ||
bgavrilMS marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /// <summary> | ||
| /// Extension methods for mapping login and logout endpoints that support | ||
| /// incremental consent and Conditional Access scenarios. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// These extension methods are designed for Blazor Server scenarios to provide | ||
| /// dedicated login and logout endpoints with support for incremental consent | ||
| /// and Conditional Access. The login endpoint accepts query parameters for scopes, | ||
| /// loginHint, domainHint, and claims to enable advanced authentication scenarios. | ||
| /// Use in conjunction with <see cref="BlazorAuthenticationChallengeHandler"/> for | ||
| /// a complete authentication solution in Blazor Server applications. | ||
| /// </remarks> | ||
| public static class LoginLogoutEndpointRouteBuilderExtensions | ||
jennyf19 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| { | ||
| /// <summary> | ||
| /// Maps login and logout endpoints under the current route group. | ||
| /// The login endpoint supports incremental consent via scope, loginHint, domainHint, and claims parameters. | ||
| /// </summary> | ||
| /// <param name="endpoints">The endpoint route builder.</param> | ||
| /// <returns>The endpoint convention builder for further configuration.</returns> | ||
| [RequiresUnreferencedCode("Minimal APIs perform reflection on delegate types which may be trimmed if not directly referenced.")] | ||
| [RequiresDynamicCode("Minimal APIs require dynamic code generation for delegate binding.")] | ||
| 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); | ||
jennyf19 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // 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(); | ||
jennyf19 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return group; | ||
| } | ||
|
|
||
| private static AuthenticationProperties GetAuthProperties(string? returnUrl) | ||
| { | ||
| const string pathBase = "/"; | ||
| if (string.IsNullOrEmpty(returnUrl)) returnUrl = pathBase; | ||
jennyf19 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 }; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
9 changes: 9 additions & 0 deletions
9
src/Microsoft.Identity.Web/PublicAPI/net10.0/PublicAPI.Unshipped.txt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,10 @@ | ||
| #nullable enable | ||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler | ||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.BlazorAuthenticationChallengeHandler(Microsoft.AspNetCore.Components.NavigationManager! navigation, Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider! authenticationStateProvider, Microsoft.Extensions.Configuration.IConfiguration! configuration) -> void | ||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.ChallengeUser(System.Security.Claims.ClaimsPrincipal! user, string![]? scopes = null, string? claims = null) -> void | ||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.ChallengeUserWithConfiguredScopesAsync(string! configurationSection) -> System.Threading.Tasks.Task! | ||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.GetUserAsync() -> System.Threading.Tasks.Task<System.Security.Claims.ClaimsPrincipal!>! | ||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.HandleExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task<bool>! | ||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.IsAuthenticatedAsync() -> System.Threading.Tasks.Task<bool>! | ||
| Microsoft.Identity.Web.LoginLogoutEndpointRouteBuilderExtensions | ||
| static Microsoft.Identity.Web.LoginLogoutEndpointRouteBuilderExtensions.MapLoginAndLogout(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! |
10 changes: 10 additions & 0 deletions
10
src/Microsoft.Identity.Web/PublicAPI/net8.0/PublicAPI.Unshipped.txt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,11 @@ | ||
| #nullable enable | ||
|
|
||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler | ||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.BlazorAuthenticationChallengeHandler(Microsoft.AspNetCore.Components.NavigationManager! navigation, Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider! authenticationStateProvider, Microsoft.Extensions.Configuration.IConfiguration! configuration) -> void | ||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.ChallengeUser(System.Security.Claims.ClaimsPrincipal! user, string![]? scopes = null, string? claims = null) -> void | ||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.ChallengeUserWithConfiguredScopesAsync(string! configurationSection) -> System.Threading.Tasks.Task! | ||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.GetUserAsync() -> System.Threading.Tasks.Task<System.Security.Claims.ClaimsPrincipal!>! | ||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.HandleExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task<bool>! | ||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.IsAuthenticatedAsync() -> System.Threading.Tasks.Task<bool>! | ||
| Microsoft.Identity.Web.LoginLogoutEndpointRouteBuilderExtensions | ||
| static Microsoft.Identity.Web.LoginLogoutEndpointRouteBuilderExtensions.MapLoginAndLogout(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! |
10 changes: 10 additions & 0 deletions
10
src/Microsoft.Identity.Web/PublicAPI/net9.0/PublicAPI.Unshipped.txt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,11 @@ | ||
| #nullable enable | ||
|
|
||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler | ||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.BlazorAuthenticationChallengeHandler(Microsoft.AspNetCore.Components.NavigationManager! navigation, Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider! authenticationStateProvider, Microsoft.Extensions.Configuration.IConfiguration! configuration) -> void | ||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.ChallengeUser(System.Security.Claims.ClaimsPrincipal! user, string![]? scopes = null, string? claims = null) -> void | ||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.ChallengeUserWithConfiguredScopesAsync(string! configurationSection) -> System.Threading.Tasks.Task! | ||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.GetUserAsync() -> System.Threading.Tasks.Task<System.Security.Claims.ClaimsPrincipal!>! | ||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.HandleExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task<bool>! | ||
| Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.IsAuthenticatedAsync() -> System.Threading.Tasks.Task<bool>! | ||
| Microsoft.Identity.Web.LoginLogoutEndpointRouteBuilderExtensions | ||
| static Microsoft.Identity.Web.LoginLogoutEndpointRouteBuilderExtensions.MapLoginAndLogout(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! |
18 changes: 18 additions & 0 deletions
18
...rCallsWebApi/AspireBlazorCallsWebApi.ApiService/AspireBlazorCallsWebApi.ApiService.csproj
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| <Project Sdk="Microsoft.NET.Sdk.Web"> | ||
|
|
||
| <PropertyGroup> | ||
| <TargetFrameworks>net10.0</TargetFrameworks> | ||
| <ImplicitUsings>enable</ImplicitUsings> | ||
| <Nullable>enable</Nullable> | ||
| </PropertyGroup> | ||
|
|
||
| <ItemGroup> | ||
| <ProjectReference Include="..\AspireBlazorCallsWebApi.ServiceDefaults\AspireBlazorCallsWebApi.ServiceDefaults.csproj" /> | ||
| <ProjectReference Include="..\..\..\..\src\Microsoft.Identity.Web\Microsoft.Identity.Web.csproj" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup> | ||
| <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" /> | ||
| </ItemGroup> | ||
|
|
||
| </Project> |
6 changes: 6 additions & 0 deletions
6
...zorCallsWebApi/AspireBlazorCallsWebApi.ApiService/AspireBlazorCallsWebApi.ApiService.http
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| @ApiService_HostAddress = http://localhost:5503 | ||
|
|
||
| GET {{ApiService_HostAddress}}/weatherforecast/ | ||
| Accept: application/json | ||
|
|
||
| ### |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.