File: EntraIdApi.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>
/// The kind of permission that an application requests from an API.
/// </summary>
public enum EntraIdPermissionType
{
    /// <summary>
    /// A delegated permission (an OAuth 2.0 scope). The application calls the API on behalf of a signed-in user.
    /// </summary>
    Delegated,
 
    /// <summary>
    /// An application permission (an app role of the API's service principal). The application calls the API as itself, with no user.
    /// </summary>
    Application
}
 
/// <summary>
/// Identifies an API that is not modeled as an Aspire resource, such as Microsoft Graph, by its well-known application ID.
/// </summary>
/// <remarks>
/// <para>
/// The well-known APIs know the IDs of their most common permissions, so permissions can be requested by name.
/// Permissions that are not in that list can be requested by supplying the permission's ID with
/// <see cref="EntraIdApiPermission.Delegated(string, Guid)"/> or <see cref="EntraIdApiPermission.Application(string, Guid)"/>.
/// To look up an ID, read the <c>oauth2PermissionScopes</c> (delegated) or <c>appRoles</c> (application) of the API's service principal
/// in your tenant, for example with <c>az ad sp show --id 00000003-0000-0000-c000-000000000000</c>.
/// </para>
/// </remarks>
public sealed class EntraIdApi
{
    private static readonly Dictionary<string, Guid> s_graphDelegated = new(StringComparer.OrdinalIgnoreCase)
    {
        ["openid"] = new("37f7f235-527c-4136-accd-4a02d197296e"),
        ["profile"] = new("14dad69e-099b-42c9-810b-d002981feec1"),
        ["email"] = new("64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0"),
        ["offline_access"] = new("7427e0e9-2fba-42fe-b0c0-848c9e6a8182"),
        ["User.Read"] = new("e1fe6dd8-ba31-4d61-89e7-88639da4683d"),
        ["User.ReadBasic.All"] = new("b340eb25-3456-403f-be2f-af7a0d370277"),
        ["User.Read.All"] = new("a154be20-db9c-4678-8ab7-66f6cc099a59"),
        ["Directory.Read.All"] = new("06da0dbc-49e2-44d2-8312-53f166ab848a"),
        ["Group.Read.All"] = new("5f8c59db-677d-491f-a6b8-5f174b11ec1d"),
        ["GroupMember.Read.All"] = new("bc024368-1153-4739-b217-4326f2e966d0"),
        ["Application.Read.All"] = new("c79f8feb-a9db-4090-85f9-90d820caa0eb"),
        ["Organization.Read.All"] = new("4908d5b9-3fb2-4b1e-9336-1888b7937185"),
        ["Mail.Read"] = new("570282fd-fa5c-430d-a7fd-fc8dc98a9dca"),
        ["Calendars.Read"] = new("465a38f9-76ea-45b9-9f34-9e8b0d4b0b42"),
        ["Files.Read"] = new("10465720-29dd-4523-a11a-6a75c743c9d9"),
        ["Files.Read.All"] = new("df85f4d6-205c-4ac5-a5ea-6bf408dba283"),
        ["Sites.Read.All"] = new("205e70e5-aba6-4c52-a976-6d2d46c48043")
    };
 
    private static readonly Dictionary<string, Guid> s_graphApplication = new(StringComparer.OrdinalIgnoreCase)
    {
        ["User.ReadBasic.All"] = new("97235f07-e226-4f63-ace3-39588e11d3a1"),
        ["User.Read.All"] = new("df021288-bdef-4463-88db-98f22de89214"),
        ["Directory.Read.All"] = new("7ab1d382-f21e-4acd-a863-ba3e13f7da61"),
        ["Group.Read.All"] = new("5b567255-7703-4780-807c-7be8301ae99b"),
        ["GroupMember.Read.All"] = new("98830695-27a2-44f7-8c18-0c3ebc9698f6"),
        ["Application.Read.All"] = new("9a5d68dd-52b0-4cc2-bd40-abcf44ac3a30"),
        ["Organization.Read.All"] = new("498476ce-e0fe-48b0-b801-37ba7e2685c6"),
        ["Mail.Read"] = new("810c84a8-4a9e-49e6-bf7d-12d183f40d01"),
        ["Calendars.Read"] = new("798ee544-9d2d-430c-a058-570e29e34338"),
        ["Files.Read.All"] = new("01d4889c-1287-42c6-ac1f-5d1e02578ef6"),
        ["Sites.Read.All"] = new("332a536c-c7ef-4017-ab91-336970924f0d")
    };
 
    private static readonly Dictionary<string, Guid> s_armDelegated = new(StringComparer.OrdinalIgnoreCase)
    {
        ["user_impersonation"] = new("41094075-9dad-400e-a0bd-54e686782033")
    };
 
    private static readonly Dictionary<string, Guid> s_storageDelegated = new(StringComparer.OrdinalIgnoreCase)
    {
        ["user_impersonation"] = new("03e0da56-190b-40ad-a80c-ea378c433f7f")
    };
 
    private static readonly Dictionary<string, Guid> s_noPermissions = new(StringComparer.OrdinalIgnoreCase);
 
    private readonly Dictionary<string, Guid> _delegatedPermissions;
    private readonly Dictionary<string, Guid> _applicationPermissions;
    private readonly bool _hasCatalog;
 
    private EntraIdApi(
        string displayName,
        string applicationId,
        string resourceUri,
        string? baseUrl,
        Dictionary<string, Guid> delegatedPermissions,
        Dictionary<string, Guid> applicationPermissions,
        bool hasCatalog)
    {
        DisplayName = displayName;
        ApplicationId = applicationId;
        ResourceUri = resourceUri;
        BaseUrl = baseUrl;
        _delegatedPermissions = delegatedPermissions;
        _applicationPermissions = applicationPermissions;
        _hasCatalog = hasCatalog;
    }
 
    /// <summary>
    /// Gets Microsoft Graph (<c>https://graph.microsoft.com</c>).
    /// </summary>
    public static EntraIdApi MicrosoftGraph { get; } = new(
        "Microsoft Graph", "00000003-0000-0000-c000-000000000000", "https://graph.microsoft.com", "https://graph.microsoft.com/v1.0",
        s_graphDelegated, s_graphApplication, hasCatalog: true);
 
    /// <summary>
    /// Gets Azure Resource Manager (<c>https://management.azure.com</c>), whose delegated permission is <c>user_impersonation</c>.
    /// </summary>
    public static EntraIdApi AzureResourceManager { get; } = new(
        "Azure Resource Manager", "797f4846-ba00-4fd7-ba43-dac1f8f63013", "https://management.azure.com", "https://management.azure.com",
        s_armDelegated, s_noPermissions, hasCatalog: true);
 
    /// <summary>
    /// Gets Azure Storage (<c>https://storage.azure.com</c>), whose delegated permission is <c>user_impersonation</c>.
    /// </summary>
    public static EntraIdApi AzureStorage { get; } = new(
        "Azure Storage", "e406a681-f3d4-42a8-90b6-c2b029497af1", "https://storage.azure.com", null,
        s_storageDelegated, s_noPermissions, hasCatalog: true);
 
    /// <summary>
    /// Gets the display name used in diagnostics.
    /// </summary>
    public string DisplayName { get; }
 
    /// <summary>
    /// Gets the API's application (client) ID, the same in every tenant for a first-party Microsoft API.
    /// </summary>
    public string ApplicationId { get; }
 
    // The prefix of the API's scopes in token requests, such as "https://graph.microsoft.com/User.Read". An API
    // identified by its application ID only is assumed to use the default application ID URI, api://{appId}.
    internal string ResourceUri { get; }
 
    // The API's root URL for Microsoft.Identity.Web's DownstreamApis settings, when it is the same for every caller.
    internal string? BaseUrl { get; }
 
    // Microsoft Graph lists the OpenID Connect scopes among its delegated permissions, but they are requested at sign-in and don't belong
    // to a resource, so they have no resource-qualified form. Another API can define a scope with one of these names, which is its own.
    internal bool IsOpenIdScope(string name) =>
        string.Equals(ApplicationId, MicrosoftGraph.ApplicationId, StringComparison.OrdinalIgnoreCase) &&
        (name.Equals("openid", StringComparison.OrdinalIgnoreCase) ||
         name.Equals("profile", StringComparison.OrdinalIgnoreCase) ||
         name.Equals("email", StringComparison.OrdinalIgnoreCase) ||
         name.Equals("offline_access", StringComparison.OrdinalIgnoreCase));
 
    /// <summary>
    /// Identifies another API, whose service principal must exist in the tenant, by its application ID.
    /// </summary>
    /// <param name="displayName">A name for the API used in diagnostics and as the name of its <c>DownstreamApis</c> entry (its letters and digits).</param>
    /// <param name="applicationId">The API's application (client) ID, as a GUID.</param>
    /// <param name="resourceUri">
    /// The API's application ID URI, the prefix of its scopes in token requests, such as <c>api://contoso-orders</c> or
    /// <c>https://contoso.onmicrosoft.com/orders</c>. It is shown as "Application ID URI" on the API's registration. It is
    /// not derived from <paramref name="applicationId"/> because an API can use any URI.
    /// </param>
    /// <param name="baseUrl">
    /// The API's root URL, such as <c>https://graph.microsoft.us</c>, which Aspire adds to the <c>DownstreamApis</c> entry as <c>BaseUrl</c>.
    /// When <see langword="null"/>, no <c>BaseUrl</c> is set and the caller must supply the address itself.
    /// </param>
    /// <returns>The API. Its permissions must be requested with explicit IDs.</returns>
    /// <exception cref="ArgumentException">
    /// <paramref name="applicationId"/> is not a GUID, <paramref name="resourceUri"/> is not a valid application ID URI, or
    /// <paramref name="baseUrl"/> is not an absolute HTTP or HTTPS URL.
    /// </exception>
    public static EntraIdApi FromApplicationId(string displayName, string applicationId, string resourceUri, string? baseUrl = null)
    {
        ArgumentException.ThrowIfNullOrEmpty(displayName);
        ArgumentException.ThrowIfNullOrEmpty(applicationId);
        ArgumentException.ThrowIfNullOrEmpty(resourceUri);
 
        if (EntraIdValidation.ValidateClientId(applicationId) is { } error)
        {
            throw new ArgumentException($"'{applicationId}' is not a valid application ID. {error}", nameof(applicationId));
        }
 
        if (EntraIdValidation.ValidateApplicationIdUri(resourceUri) is { } uriError)
        {
            throw new ArgumentException($"'{resourceUri}' is not a valid application ID URI. {uriError}", nameof(resourceUri));
        }
 
        if (baseUrl is not null && !(Uri.TryCreate(baseUrl, UriKind.Absolute, out var url) && url.Scheme is "http" or "https"))
        {
            throw new ArgumentException($"'{baseUrl}' is not an absolute HTTP or HTTPS URL.", nameof(baseUrl));
        }
 
        // Keep the URI as given: a trailing slash is part of the resource identifier, and tokens for it carry that audience.
        // See https://learn.microsoft.com/entra/identity-platform/scopes-oidc#trailing-slash-and-default.
        return new(displayName, Guid.Parse(applicationId).ToString("D"), resourceUri, baseUrl, s_noPermissions, s_noPermissions, hasCatalog: false);
    }
 
    // A permission value is appended to the resource identifier: "https://graph.microsoft.com/User.Read". When the identifier ends
    // with a slash that slash is the separator, as in "https://management.azure.com/user_impersonation". The ".default" scope is
    // different: it always follows a slash, which gives "{identifier}//.default" for an identifier with a trailing slash.
    internal static string CreateScope(string resourceUri, string scope) =>
        resourceUri.EndsWith('/') && scope != ".default" ? $"{resourceUri}{scope}" : $"{resourceUri}/{scope}";
 
    internal EntraIdResolvedPermission Resolve(EntraIdApiPermission permission)
    {
        ArgumentNullException.ThrowIfNull(permission);
 
        var known = permission.Type == EntraIdPermissionType.Delegated ? _delegatedPermissions : _applicationPermissions;
        var knownId = known.TryGetValue(permission.Name, out var id) ? id : (Guid?)null;
        var canonicalName = knownId is not null ? known.Keys.First(k => string.Equals(k, permission.Name, StringComparison.OrdinalIgnoreCase)) : permission.Name;
 
        if (permission.Id is { } explicitId)
        {
            // A name from the catalog with a different ID is almost certainly a typo, and would silently request another permission.
            if (knownId is { } catalogId && catalogId != explicitId)
            {
                throw new ArgumentException(
                    $"The {FormatType(permission.Type)} permission '{permission.Name}' of {DisplayName} has the ID '{catalogId:D}', not '{explicitId:D}'.",
                    nameof(permission));
            }
 
            // The reverse mistake: a name that isn't in the catalog, with the ID of a permission that is. The consent would then name
            // one permission while the declaration requests another.
            if (knownId is null && known.FirstOrDefault(p => p.Value == explicitId) is { Key: { } otherName })
            {
                throw new ArgumentException(
                    $"The ID '{explicitId:D}' is the {FormatType(permission.Type)} permission '{otherName}' of {DisplayName}, not '{permission.Name}'.",
                    nameof(permission));
            }
 
            return EntraIdResolvedPermission.Fixed(permission.Type, canonicalName, explicitId);
        }
 
        if (knownId is { } resolvedId)
        {
            return EntraIdResolvedPermission.Fixed(permission.Type, canonicalName, resolvedId);
        }
 
        var message = _hasCatalog
            ? $"The {FormatType(permission.Type)} permission '{permission.Name}' is not one of the permissions that Aspire knows for {DisplayName}: " +
              $"{(known.Count == 0 ? "none" : string.Join(", ", known.Keys.Order(StringComparer.OrdinalIgnoreCase)))}. " +
              "Pass the permission's ID as well to request another permission."
            : $"{DisplayName} has no well-known permissions. Pass the ID of the {FormatType(permission.Type)} permission '{permission.Name}' as well.";
        throw new ArgumentException(message, nameof(permission));
    }
 
    private static string FormatType(EntraIdPermissionType type) => type == EntraIdPermissionType.Delegated ? "delegated" : "application";
}
 
/// <summary>
/// A permission, identified by name and optionally by ID, that an application requests from an API.
/// </summary>
/// <remarks>
/// Delegated permissions are scopes that the API exposes and the application uses on behalf of a signed-in user.
/// Application permissions are app roles that the API defines and the application uses as itself. They are different
/// permissions, even when they share a name, and each needs its own consent.
/// For a permission that isn't in an API's built-in list, Aspire can't check that the name and the ID belong together. The declaration
/// uses the ID, while a delegated consent grant names the permission, so a name that doesn't match the ID would grant a permission that
/// was never declared. Take both from the API's service principal.
/// </remarks>
public sealed class EntraIdApiPermission
{
    private EntraIdApiPermission(EntraIdPermissionType type, string name, Guid? id)
    {
        Type = type;
        Name = name;
        Id = id;
    }
 
    /// <summary>
    /// Gets the kind of permission.
    /// </summary>
    public EntraIdPermissionType Type { get; }
 
    /// <summary>
    /// Gets the permission's name, such as <c>User.Read</c>.
    /// </summary>
    public string Name { get; }
 
    /// <summary>
    /// Gets the permission's ID, or <see langword="null"/> when it is resolved from the API's well-known permissions.
    /// </summary>
    public Guid? Id { get; }
 
    /// <summary>
    /// Requests a delegated permission by name, using the ID that Aspire knows for the API.
    /// </summary>
    /// <param name="name">The permission's name, such as <c>User.Read</c>.</param>
    /// <returns>The permission.</returns>
    public static EntraIdApiPermission Delegated(string name)
    {
        ValidateName(name);
        return new(EntraIdPermissionType.Delegated, name, null);
    }
 
    /// <summary>
    /// Requests a delegated permission by name and ID.
    /// </summary>
    /// <param name="name">The permission's name, such as <c>Mail.Send</c>.</param>
    /// <param name="id">The permission's ID, defined by the API's service principal.</param>
    /// <returns>The permission.</returns>
    public static EntraIdApiPermission Delegated(string name, Guid id)
    {
        ValidateName(name);
        return new(EntraIdPermissionType.Delegated, name, ValidateId(id));
    }
 
    /// <summary>
    /// Requests an application permission by name, using the ID that Aspire knows for the API.
    /// </summary>
    /// <param name="name">The permission's name, such as <c>User.Read.All</c>.</param>
    /// <returns>The permission.</returns>
    public static EntraIdApiPermission Application(string name)
    {
        ValidateName(name);
        return new(EntraIdPermissionType.Application, name, null);
    }
 
    /// <summary>
    /// Requests an application permission by name and ID.
    /// </summary>
    /// <param name="name">The permission's name, such as <c>Mail.Send</c>.</param>
    /// <param name="id">The permission's ID, defined by the API's service principal.</param>
    /// <returns>The permission.</returns>
    public static EntraIdApiPermission Application(string name, Guid id)
    {
        ValidateName(name);
        return new(EntraIdPermissionType.Application, name, ValidateId(id));
    }
 
    // The names of granted delegated permissions are written to one space-separated string, so a name that contains a space would
    // consent to a second, undeclared permission. Names must follow Microsoft Graph's rule for scope and app role values.
    private static void ValidateName(string name)
    {
        ArgumentException.ThrowIfNullOrEmpty(name);
 
        if (EntraIdValidation.ValidateScopeName(name) is { } error)
        {
            throw new ArgumentException($"'{name}' is not a valid permission name. {error}", nameof(name));
        }
    }
 
    private static Guid ValidateId(Guid id) => id == Guid.Empty
        ? throw new ArgumentException("The permission ID must not be empty.", nameof(id))
        : id;
}
 
/// <summary>
/// Describes a delegated permission scope that an API registration created by Aspire exposes.
/// </summary>
/// <remarks>
/// The scope's ID is stable across deployments: unless <see cref="Id"/> is set, it derives from the registration's name and the
/// scope's <see cref="Name"/>. Microsoft Graph does not allow a scope to be removed, or its name to change, while it is enabled.
/// To retire a scope, set <see cref="IsEnabled"/> to <see langword="false"/>, deploy, and then remove the scope in a later deployment.
/// </remarks>
public sealed class EntraIdScope
{
    internal EntraIdScope(string name, string description)
    {
        Name = name;
        Description = description;
        DisplayName = name;
    }
 
    /// <summary>
    /// Gets the scope's name, the value that appears in the token's <c>scp</c> claim, such as <c>access_as_user</c>.
    /// </summary>
    public string Name { get; }
 
    /// <summary>
    /// Gets or sets what the scope allows, shown on the admin consent page.
    /// </summary>
    public string Description { get; set; }
 
    /// <summary>
    /// Gets or sets the scope's title, shown on the admin consent page. Defaults to <see cref="Name"/>.
    /// </summary>
    public string DisplayName { get; set; }
 
    /// <summary>
    /// Gets or sets the scope's title shown on the consent page for users. When <see langword="null"/>, <see cref="DisplayName"/> is used.
    /// </summary>
    public string? UserConsentDisplayName { get; set; }
 
    /// <summary>
    /// Gets or sets what the scope allows, shown on the consent page for users. When <see langword="null"/>, <see cref="Description"/> is used.
    /// </summary>
    public string? UserConsentDescription { get; set; }
 
    /// <summary>
    /// Gets or sets whether only an administrator can consent to the scope. Defaults to <see langword="false"/>, which lets users consent.
    /// </summary>
    public bool RequiresAdminConsent { get; set; }
 
    /// <summary>
    /// Gets or sets whether clients can request the scope. Defaults to <see langword="true"/>.
    /// </summary>
    public bool IsEnabled { get; set; } = true;
 
    /// <summary>
    /// Gets or sets the scope's ID, which must stay the same across deployments.
    /// </summary>
    /// <remarks>
    /// Aspire derives a stable ID for scopes of registrations it creates. Set the ID explicitly to adopt a scope that already exists,
    /// or for a scope of an existing registration, whose ID cannot be derived.
    /// </remarks>
    public Guid? Id { get; set; }
}
 
/// <summary>
/// A permission of an API that an application declares or has consented, with the ID that identifies it.
/// </summary>
/// <remarks>
/// A first-party API's permission has a fixed ID. A delegated permission of a registration in the AppHost refers to the scope itself and
/// reads its ID when the template is generated, because <see cref="EntraIdScope.Id"/> can still be set after the permission is declared and
/// a copied ID would then disagree with the one that the API's own template writes.
/// </remarks>
internal readonly record struct EntraIdResolvedPermission
{
    private readonly Guid? _fixedId;
    private readonly EntraIdApplicationResource? _owner;
    private readonly EntraIdScope? _scope;
 
    private EntraIdResolvedPermission(EntraIdPermissionType type, string name, Guid? fixedId, EntraIdApplicationResource? owner, EntraIdScope? scope)
    {
        Type = type;
        Name = name;
        _fixedId = fixedId;
        _owner = owner;
        _scope = scope;
    }
 
    public EntraIdPermissionType Type { get; }
 
    public string Name { get; }
 
    public Guid Id => _fixedId ?? _owner!.GetScopeId(_scope!);
 
    // The scope of a registration in the AppHost, whose settings can still change after the permission is declared.
    public EntraIdScope? Scope => _scope;
 
    public static EntraIdResolvedPermission Fixed(EntraIdPermissionType type, string name, Guid id) => new(type, name, id, null, null);
 
    public static EntraIdResolvedPermission ForScope(EntraIdApplicationResource owner, EntraIdScope scope) =>
        new(EntraIdPermissionType.Delegated, scope.Name, null, owner, scope);
}