Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 3 additions & 0 deletions .github/skills/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;

namespace Microsoft.Identity.Web;

/// <summary>
/// Handles authentication challenges for Blazor Server components.
/// Provides functionality for incremental consent and Conditional Access scenarios.
/// </summary>
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>
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,89 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
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>
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>
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 };
}
}
Loading
Loading