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
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