Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions Microsoft.Identity.Web.sln
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,16 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{FF3B93A1
build\tsaConfig.json = build\tsaConfig.json
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AspireBlazorWebAppCallsWebApi", "AspireBlazorWebAppCallsWebApi", "{688187B8-8AEF-4C80-948B-92BCCDE86D76}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireBlazorCallsWebApi.ServiceDefaults", "tests\DevApps\AspireBlazorCallsWebApi\AspireBlazorCallsWebApi.ServiceDefaults\AspireBlazorCallsWebApi.ServiceDefaults.csproj", "{5006003B-0921-A2D0-99E5-8B33420E5A9B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireBlazorCallsWebApi.ApiService", "tests\DevApps\AspireBlazorCallsWebApi\AspireBlazorCallsWebApi.ApiService\AspireBlazorCallsWebApi.ApiService.csproj", "{106919C1-549A-AEAE-9925-E9E50B81BD23}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireBlazorCallsWebApi.Web", "tests\DevApps\AspireBlazorCallsWebApi\AspireBlazorCallsWebApi.Web\AspireBlazorCallsWebApi.Web.csproj", "{5373EB99-4B37-B3B3-8280-2A1EDB79F198}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireBlazorCallsWebApi.AppHost", "tests\DevApps\AspireBlazorCallsWebApi\AspireBlazorCallsWebApi.AppHost\AspireBlazorCallsWebApi.AppHost.csproj", "{CF9A0AD1-477A-AC6E-E94E-3E6D5D8F07BE}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -456,6 +466,22 @@ Global
{A61CEEDE-6F2C-0710-E008-B5F6F25D87D7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A61CEEDE-6F2C-0710-E008-B5F6F25D87D7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A61CEEDE-6F2C-0710-E008-B5F6F25D87D7}.Release|Any CPU.Build.0 = Release|Any CPU
{5006003B-0921-A2D0-99E5-8B33420E5A9B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5006003B-0921-A2D0-99E5-8B33420E5A9B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5006003B-0921-A2D0-99E5-8B33420E5A9B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5006003B-0921-A2D0-99E5-8B33420E5A9B}.Release|Any CPU.Build.0 = Release|Any CPU
{106919C1-549A-AEAE-9925-E9E50B81BD23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{106919C1-549A-AEAE-9925-E9E50B81BD23}.Debug|Any CPU.Build.0 = Debug|Any CPU
{106919C1-549A-AEAE-9925-E9E50B81BD23}.Release|Any CPU.ActiveCfg = Release|Any CPU
{106919C1-549A-AEAE-9925-E9E50B81BD23}.Release|Any CPU.Build.0 = Release|Any CPU
{5373EB99-4B37-B3B3-8280-2A1EDB79F198}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5373EB99-4B37-B3B3-8280-2A1EDB79F198}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5373EB99-4B37-B3B3-8280-2A1EDB79F198}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5373EB99-4B37-B3B3-8280-2A1EDB79F198}.Release|Any CPU.Build.0 = Release|Any CPU
{CF9A0AD1-477A-AC6E-E94E-3E6D5D8F07BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CF9A0AD1-477A-AC6E-E94E-3E6D5D8F07BE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CF9A0AD1-477A-AC6E-E94E-3E6D5D8F07BE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CF9A0AD1-477A-AC6E-E94E-3E6D5D8F07BE}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -538,6 +564,11 @@ Global
{06818CF6-16AD-4184-9264-B593B8F2AA25} = {7786D2DD-9EE4-42E1-B587-740A2E15C41D}
{3ECC1B78-A458-726F-D7B8-AB74733CCCDC} = {06818CF6-16AD-4184-9264-B593B8F2AA25}
{A61CEEDE-6F2C-0710-E008-B5F6F25D87D7} = {06818CF6-16AD-4184-9264-B593B8F2AA25}
{688187B8-8AEF-4C80-948B-92BCCDE86D76} = {7786D2DD-9EE4-42E1-B587-740A2E15C41D}
{5006003B-0921-A2D0-99E5-8B33420E5A9B} = {688187B8-8AEF-4C80-948B-92BCCDE86D76}
{106919C1-549A-AEAE-9925-E9E50B81BD23} = {688187B8-8AEF-4C80-948B-92BCCDE86D76}
{5373EB99-4B37-B3B3-8280-2A1EDB79F198} = {688187B8-8AEF-4C80-948B-92BCCDE86D76}
{CF9A0AD1-477A-AC6E-E94E-3E6D5D8F07BE} = {688187B8-8AEF-4C80-948B-92BCCDE86D76}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {104367F1-CE75-4F40-B32F-F14853973187}
Expand Down
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)
{
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))
return "organizations";

// MSA tenant
if (tenantId == MsaTenantId)
return "consumers";

return "organizations";
}
}
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;

/// <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
{
/// <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);

// 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 };
}
}
1 change: 1 addition & 0 deletions src/Microsoft.Identity.Web/Microsoft.Identity.Web.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
<Compile Remove="*.cs" />
<Compile Remove="AppServicesAuth\**" />
<Compile Remove="AzureSdkSupport\**" />
<Compile Remove="Blazor\**" />
<Compile Remove="DownstreamWebApiSupport\**" />
<Compile Remove="Resource\**" />
<Compile Remove="Policy\**" />
Expand Down
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!
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!
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!
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>
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

###
Loading
Loading