File: EntraIdValidation.cs
Web Access
Project: src\src\Aspire.Hosting.Azure.EntraId\Aspire.Hosting.Azure.EntraId.csproj (Aspire.Hosting.Azure.EntraId)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
namespace Aspire.Hosting.Azure;
 
/// <summary>
/// Validates the identifiers that describe an Entra ID app registration.
/// </summary>
/// <remarks>
/// Each method returns <see langword="null"/> for a valid value, or a sentence explaining what was expected.
/// The sentence never repeats the value, because values can come from parameters, and callers that know
/// the value is safe to show (for example, a string literal from the AppHost) add it themselves.
/// </remarks>
internal static class EntraIdValidation
{
    // The placeholder IDs that Microsoft Learn uses throughout the Entra documentation. They are shown in
    // error messages as examples of the expected format, and they identify no real tenant or application.
    internal const string ExampleTenantId = "aaaabbbb-0000-cccc-1111-dddd2222eeee";
    internal const string ExampleClientId = "00001111-aaaa-2222-bbbb-3333cccc4444";
 
    private const string NoValueError = "No value was provided.";
 
    /// <summary>
    /// Validates the ID of the app's home tenant, which must be a GUID or a domain name.
    /// </summary>
    internal static string? ValidateTenantId(string? tenantId)
    {
        if (string.IsNullOrEmpty(tenantId))
        {
            return NoValueError;
        }
 
        // The keywords are valid in Microsoft.Identity.Web's TenantId setting, but they choose who can sign in
        // rather than naming a tenant. WithSignInAudience emits them, so accepting one here would hide the home
        // tenant, which multi-tenant apps still need to acquire tokens as themselves.
        if (GetSignInAudienceForKeyword(tenantId) is { } audience)
        {
            return $"The keywords 'organizations', 'common' and 'consumers' choose who can sign in; they do not identify a tenant. " +
                $"Use the ID of the tenant where the app is registered, and call WithSignInAudience(EntraIdSignInAudience.{audience}) instead.";
        }
 
        if (IsGuid(tenantId) || IsDomainName(tenantId))
        {
            return null;
        }
 
        return $"Expected the directory (tenant) ID shown on the app registration's Overview page, such as '{ExampleTenantId}', " +
            "or a domain name such as 'contoso.onmicrosoft.com'.";
    }
 
    /// <summary>
    /// Validates the application (client) ID, which must be a GUID.
    /// </summary>
    internal static string? ValidateClientId(string? clientId)
    {
        if (string.IsNullOrEmpty(clientId))
        {
            return NoValueError;
        }
 
        if (IsGuid(clientId))
        {
            return null;
        }
 
        return $"Expected the application (client) ID shown on the app registration's Overview page, such as '{ExampleClientId}'.";
    }
 
    /// <summary>
    /// Validates the Entra ID instance, which must be an absolute HTTPS URL.
    /// </summary>
    internal static string? ValidateInstance(string? instance)
    {
        if (string.IsNullOrEmpty(instance))
        {
            return NoValueError;
        }
 
        // Microsoft.Identity.Web appends the tenant and "/v2.0" to the instance to build the authority, so a
        // query string or fragment would end up in the middle of the authority URL.
        if (Uri.TryCreate(instance, UriKind.Absolute, out var uri) &&
            uri.Scheme == Uri.UriSchemeHttps &&
            uri.Query.Length == 0 &&
            uri.Fragment.Length == 0)
        {
            return null;
        }
 
        return "Expected an absolute HTTPS URL with no query string or fragment, such as 'https://login.microsoftonline.com/'.";
    }
 
    private static EntraIdSignInAudience? GetSignInAudienceForKeyword(string tenantId)
    {
        if (string.Equals(tenantId, "organizations", StringComparison.OrdinalIgnoreCase))
        {
            return EntraIdSignInAudience.AzureADMultipleOrgs;
        }
 
        if (string.Equals(tenantId, "common", StringComparison.OrdinalIgnoreCase))
        {
            return EntraIdSignInAudience.AzureADandPersonalMicrosoftAccount;
        }
 
        if (string.Equals(tenantId, "consumers", StringComparison.OrdinalIgnoreCase))
        {
            return EntraIdSignInAudience.PersonalMicrosoftAccount;
        }
 
        return null;
    }
 
    // The portal shows IDs in the hyphenated "D" format, such as "00001111-aaaa-2222-bbbb-3333cccc4444".
    // The length check rejects surrounding whitespace, which Guid parsing would otherwise ignore even though
    // the untrimmed value is what reaches the application's configuration.
    private static bool IsGuid(string value) => value.Length == 36 && Guid.TryParseExact(value, "D", out _);
 
    // A tenant can also be named by one of its verified domains, such as "contoso.onmicrosoft.com". Requiring a
    // dot rejects single-label placeholders such as "my-tenant" that are syntactically valid host names.
    private static bool IsDomainName(string value) => Uri.CheckHostName(value) == UriHostNameType.Dns && value.Contains('.');
}