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 client ID that selects a user-assigned managed identity for federation.
    /// </summary>
    internal static string? ValidateManagedIdentityClientId(string? clientId)
    {
        if (string.IsNullOrEmpty(clientId))
        {
            return NoValueError;
        }
 
        return IsGuid(clientId)
            ? null
            : "Expected the Client ID of a user-assigned managed identity as a GUID, not its Azure resource ID.";
    }
 
    /// <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/'.";
    }
 
    /// <summary>
    /// Validates an application ID URI, which must be an absolute URI without a query string or fragment.
    /// </summary>
    internal static string? ValidateApplicationIdUri(string? uri)
    {
        if (string.IsNullOrEmpty(uri))
        {
            return NoValueError;
        }
 
        // Microsoft Graph limits identifier URIs to 255 characters and rejects ones that contain whitespace. A rooted path such as
        // "/orders" parses as an absolute file:// URI on Linux and macOS but not on Windows, so file URIs are rejected explicitly to
        // give the same answer on every platform.
        if (uri.Length <= 255 &&
            !uri.Any(char.IsWhiteSpace) &&
            !uri.Contains('?') &&
            !uri.Contains('#') &&
            Uri.TryCreate(uri, UriKind.Absolute, out var parsed) &&
            !parsed.IsFile)
        {
            return null;
        }
 
        return "Expected an absolute URI of at most 255 characters with no whitespace, query string or fragment, such as 'api://contoso-orders' or 'https://contoso.onmicrosoft.com/orders'.";
    }
 
    /// <summary>
    /// Validates the value of a delegated permission (scope) or application permission (app role), which appears in the token's
    /// <c>scp</c> or <c>roles</c> claim.
    /// </summary>
    /// <remarks>
    /// The rule is Microsoft Graph's for <c>permissionScope.value</c>, which app role values follow as well:
    /// at most 120 characters from the ranges 0-9, A-Z and a-z and from the punctuation <c>:!#$%&amp;'()*+,-./;&lt;=&gt;?@[]^_`{|}~</c>,
    /// and it can't begin with a period. A space is not allowed, which also keeps the space-separated <c>scope</c> of a consent grant unambiguous.
    /// See https://learn.microsoft.com/graph/api/resources/permissionscope.
    /// </remarks>
    internal static string? ValidateScopeName(string? name)
    {
        if (string.IsNullOrEmpty(name))
        {
            return NoValueError;
        }
 
        if (name.Length <= 120 && name[0] != '.' && name.All(static c => char.IsAsciiLetterOrDigit(c) || ":!#$%&'()*+,-./;<=>?@[]^_`{|}~".Contains(c)))
        {
            return null;
        }
 
        return "Expected at most 120 ASCII letters, digits or punctuation (but no spaces), not beginning with '.', such as 'access_as_user'.";
    }
    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('.');
}