// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable ASPIREAZURE003 // The managed identity annotation is experimental.
using System.Collections.Immutable;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Aspire.Hosting;
/// <summary>
/// Provides extension methods for adding Microsoft Entra ID resources to the application model.
/// </summary>
public static class EntraIdResourceExtensions
{
/// <summary>
/// Adds a Microsoft Entra ID application registration resource to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
/// <param name="name">The name of the resource.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{EntraIdApplicationResource}"/>.</returns>
/// <remarks>
/// <para>
/// The Entra ID application resource injects configuration as environment variables
/// into consuming services. For .NET programs, the variables default to the <c>AzureAd</c> configuration section
/// that Microsoft.Identity.Web reads natively.
/// </para>
/// <para>
/// By default, Aspire provisions an application and service principal using the Microsoft Graph Bicep extension.
/// This requires Azure provisioning configuration and permission to create registrations in the subscription's
/// tenant. Call <c>AddExistingEntraApplication</c> to reference an existing registration without Azure or Graph access.
/// </para>
/// <example>
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// var entraApi = builder.AddEntraIdApplication("entra-api");
///
/// builder.AddDotnetProject("api", "../Api/Api.csproj")
/// .WithReference(entraApi);
/// </code>
/// </example>
/// </remarks>
/// <ats-returns>The application registration builder.</ats-returns>
/// <ats-remarks>
/// Provisions an application and service principal through Microsoft Graph Bicep. Requires Azure provisioning
/// configuration and permission to create registrations in the subscription's tenant.
/// Reference the registration from a workload to inject authentication configuration as environment variables.
/// To reference an existing registration without Azure or Graph access, use addExistingEntraApplication.
/// Workloads must configure their own authentication library.
/// </ats-remarks>
[AspireExport]
public static IResourceBuilder<EntraIdApplicationResource> AddEntraIdApplication(
this IDistributedApplicationBuilder builder,
[ResourceName] string name)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
builder.AddAzureProvisioning();
var resource = new EntraIdApplicationResource(name)
{
// Resource initialization and Azure environment initialization run concurrently. Keep a pending task
// available from the outset so ID resolution waits for provisioning rather than reading missing outputs.
ProvisioningTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously)
};
return ConfigureEntraIdResource(builder.AddResource(resource));
}
private static IResourceBuilder<EntraIdApplicationResource> ConfigureEntraIdResource(
IResourceBuilder<EntraIdApplicationResource> resourceBuilder)
{
return resourceBuilder
.WithIconName("ShieldKeyhole")
// The resource waits until its IDs are known, which takes a while when they come from parameters that the
// dashboard prompts for. Initialization then validates them and moves the resource to Running or FailedToStart.
.WithInitialState(new CustomResourceSnapshot
{
ResourceType = "EntraIdApplication",
State = KnownResourceStates.Waiting,
Properties = []
})
.OnInitializeResource(InitializeEntraIdResourceAsync);
}
private static async Task InitializeEntraIdResourceAsync(
EntraIdApplicationResource resource,
InitializeResourceEvent evt,
CancellationToken cancellationToken)
{
if (resource.IsExistingApplication)
{
await InitializeEntraIdResourceOnceAsync(resource, evt, cancellationToken).ConfigureAwait(false);
return;
}
// Azure retries replace the completed provisioning task without publishing another initialization event.
// Keep observing attempts for the resource's lifetime, including successful reprovisioning with new IDs.
using var lifetimeCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, evt.Services.GetRequiredService<IHostApplicationLifetime>().ApplicationStopping);
cancellationToken = lifetimeCts.Token;
Task? previousAttempt = null;
await foreach (var update in evt.Notifications.WatchAsync(cancellationToken).ConfigureAwait(false))
{
if (update.Resource != resource)
{
continue;
}
var attempt = resource.ProvisioningTaskCompletionSource?.Task;
if (attempt is null || ReferenceEquals(attempt, previousAttempt))
{
continue;
}
previousAttempt = attempt;
await InitializeEntraIdResourceOnceAsync(resource, evt, cancellationToken).ConfigureAwait(false);
}
}
private static async Task InitializeEntraIdResourceOnceAsync(
EntraIdApplicationResource resource,
InitializeResourceEvent evt,
CancellationToken cancellationToken)
{
var logger = evt.Logger;
if (resource.IsExistingApplication &&
((resource.TenantIdParameter is null && resource.TenantId is null) ||
(resource.ClientIdParameter is null && resource.ClientId is null)))
{
logger.LogError("The app registration is not fully configured. Call AddExistingEntraApplication with the registration's tenant ID and client ID.");
await PublishFailedToStartAsync(resource, evt).ConfigureAwait(false);
return;
}
// Check every setting before failing, so that a misconfigured resource reports all of its problems at once
// rather than one per restart of the AppHost.
string? tenantId;
string? clientId;
if (resource.IsExistingApplication)
{
tenantId = await ResolveIdAsync(resource.TenantIdParameter, resource.TenantId, "tenant ID", EntraIdValidation.ValidateTenantId, logger, cancellationToken).ConfigureAwait(false);
clientId = await ResolveIdAsync(resource.ClientIdParameter, resource.ClientId, "client ID", EntraIdValidation.ValidateClientId, logger, cancellationToken).ConfigureAwait(false);
}
else
{
try
{
tenantId = await resource.TenantIdOutputReference.GetValueAsync(cancellationToken).ConfigureAwait(false);
clientId = await resource.ClientIdOutputReference.GetValueAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (IsMissingAzureConfiguration(ex))
{
// The Azure provisioning controller already published the "Missing subscription configuration"
// state for this resource, like every other Azure resource. Don't overwrite it with FailedToStart.
return;
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Failed to provision the Entra ID application. If your tenant does not allow creating registrations, use AddExistingEntraApplication with an existing registration's tenant ID and client ID.");
await PublishFailedToStartAsync(resource, evt).ConfigureAwait(false);
return;
}
tenantId = await ResolveIdAsync(null, tenantId, "tenant ID", EntraIdValidation.ValidateTenantId, logger, cancellationToken).ConfigureAwait(false);
clientId = await ResolveIdAsync(null, clientId, "client ID", EntraIdValidation.ValidateClientId, logger, cancellationToken).ConfigureAwait(false);
}
var hasInvalidSetting = false;
if (EntraIdValidation.ValidateInstance(resource.Instance) is { } instanceError)
{
logger.LogError("The instance '{Instance}' is not valid. {Reason}", resource.Instance, instanceError);
hasInvalidSetting = true;
}
if (!Enum.IsDefined(resource.SignInAudience))
{
logger.LogError("The sign-in audience '{SignInAudience}' is not a valid {EnumType} value.", resource.SignInAudience, nameof(EntraIdSignInAudience));
hasInvalidSetting = true;
}
// A misconfigured credential would otherwise surface only when a referencing app starts and its environment
// variables are evaluated, which reports the problem on the wrong resource.
for (var i = 0; i < resource.ClientCredentials.Count; i++)
{
try
{
_ = resource.ClientCredentials[i].SourceType;
}
catch (InvalidOperationException ex)
{
logger.LogError("Client credential {Index} is not valid. {Reason}", i, ex.Message);
hasInvalidSetting = true;
}
}
if (hasInvalidSetting || tenantId is null || clientId is null)
{
await PublishFailedToStartAsync(resource, evt).ConfigureAwait(false);
return;
}
try
{
// Azure provisioning already publishes this event for created registrations.
if (resource.IsExistingApplication)
{
await evt.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, evt.Services), cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "A {EventName} subscriber failed.", nameof(BeforeResourceStartedEvent));
await PublishFailedToStartAsync(resource, evt).ConfigureAwait(false);
return;
}
// Apps that accept other tenants sign users in through a keyword such as "organizations" instead of the home
// tenant, so their discovery document lives under the keyword while the registration lives in the home tenant.
var signInTenant = resource.SignInTenantKeyword ?? tenantId;
var urls = ImmutableArray.CreateBuilder<UrlSnapshot>();
urls.Add(new UrlSnapshot("OpenID Config", EntraIdUrls.GetOpenIdConfigurationUrl(resource.Instance, signInTenant), IsInternal: false)
{
DisplayProperties = new UrlDisplayPropertiesSnapshot("OpenID configuration")
});
if (EntraIdUrls.GetPortalUrl(resource.Instance, tenantId, clientId) is { } portalUrl)
{
urls.Add(new UrlSnapshot("Azure Portal", portalUrl, IsInternal: false)
{
DisplayProperties = new UrlDisplayPropertiesSnapshot("App registration")
});
}
var properties = ImmutableArray.CreateBuilder<ResourcePropertySnapshot>();
properties.Add(CreateHighlightedProperty("entra.tenant.id", "Tenant ID", tenantId, isSensitive: resource.TenantIdParameter?.Secret ?? false, sortOrder: 0));
properties.Add(CreateHighlightedProperty("entra.client.id", "Client ID", clientId, isSensitive: resource.ClientIdParameter?.Secret ?? false, sortOrder: 1));
properties.Add(CreateHighlightedProperty("entra.signin.audience", "Sign-in audience", resource.SignInAudience.ToString(), isSensitive: false, sortOrder: 2));
properties.Add(CreateHighlightedProperty("entra.instance", "Instance", resource.Instance, isSensitive: false, sortOrder: 3));
if (resource.ClientCredentials.Count > 0)
{
var sourceTypes = string.Join(", ", resource.ClientCredentials.Select(c => c.SourceType));
properties.Add(CreateHighlightedProperty("entra.credentials", "Credentials", sourceTypes, isSensitive: false, sortOrder: 4));
}
// Replace integration-owned metadata on reprovisioning while preserving custom URLs and properties.
await evt.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
{
Urls = [.. snapshot.Urls.Where(url => url.Name is not "OpenID Config" and not "Azure Portal"), .. urls],
Properties = [.. snapshot.Properties.Where(property =>
property.Name != "entra.credentials" && !properties.Any(updated => updated.Name == property.Name)), .. properties],
State = KnownResourceStates.Running
}).ConfigureAwait(false);
}
// Gets an ID from its parameter, or the fixed value when there is no parameter, and validates it. Problems are
// logged to the resource's console and reported by returning null. Parameter values are not logged because the
// parameter may be secret; fixed values come from AppHost code and are logged to make the mistake easy to find.
private static async Task<string?> ResolveIdAsync(
ParameterResource? parameter,
string? value,
string setting,
Func<string?, string?> validate,
ILogger logger,
CancellationToken cancellationToken)
{
if (parameter is null)
{
if (validate(value) is { } error)
{
logger.LogError("The {Setting} '{Value}' is not valid. {Reason}", setting, value, error);
return null;
}
return value;
}
string? parameterValue;
try
{
parameterValue = await parameter.GetValueAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Failed to get the value of the {Setting} parameter '{ParameterName}'.", setting, parameter.Name);
return null;
}
if (validate(parameterValue) is { } parameterError)
{
logger.LogError("Parameter '{ParameterName}' does not contain a valid {Setting}. {Reason}", parameter.Name, setting, parameterError);
return null;
}
return parameterValue;
}
// MissingConfigurationException is internal to Aspire.Hosting.Azure, so match it by name rather than exposing it.
private static bool IsMissingAzureConfiguration(Exception ex)
=> ex.GetType().Name == "MissingConfigurationException" ||
ex is AggregateException aggregate && aggregate.InnerExceptions.Any(IsMissingAzureConfiguration);
private static Task PublishFailedToStartAsync(EntraIdApplicationResource resource, InitializeResourceEvent evt)
{
return evt.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with
{
State = KnownResourceStates.FailedToStart
});
}
// Unknown property names are hidden in the dashboard unless they are highlighted.
private static ResourcePropertySnapshot CreateHighlightedProperty(string name, string displayName, string value, bool isSensitive, int sortOrder)
{
return new(name, value)
{
DisplayName = displayName,
IsHighlighted = true,
IsSensitive = isSensitive,
SortOrder = sortOrder
};
}
/// <summary>
/// Adds a reference to an existing Microsoft Entra ID application registration without enabling Azure provisioning.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="tenantId">
/// A parameter containing the ID of the app's home tenant: the directory where the app is registered, shown as
/// "Directory (tenant) ID" on the app registration's Overview page.
/// </param>
/// <param name="clientId">
/// A parameter containing the application (client) ID, shown as "Application (client) ID" on the app registration's
/// Overview page.
/// </param>
/// <returns>A reference to the <see cref="IResourceBuilder{EntraIdApplicationResource}"/>.</returns>
/// <remarks>
/// <para>
/// Both the tenant ID and client ID are required to identify an existing Entra ID application.
/// Unlike ARM resources that can be identified by name and resource group, Entra ID app registrations
/// live in a specific tenant directory and are uniquely identified by their client ID within that tenant.
/// </para>
/// <para>
/// The parameter values are validated when the application starts. The tenant ID must be a GUID or a domain such as
/// <c>contoso.onmicrosoft.com</c>, and the client ID must be a GUID. When a value is missing or malformed, the resource
/// reports <c>FailedToStart</c> and logs the reason.
/// </para>
/// <para>
/// The registration is used only for configuration. Aspire does not deploy Bicep or query Microsoft Graph
/// for this resource. Use <see cref="AddEntraIdApplication"/> to create a registration instead.
/// </para>
/// <example>
/// <code lang="csharp">
/// var tenantId = builder.AddParameter("EntraTenantId");
/// var clientId = builder.AddParameter("EntraClientId");
/// var identity = builder.AddExistingEntraApplication("identity", tenantId, clientId);
/// builder.AddDotnetProject("api", "../Api/Api.csproj").WithReference(identity);
/// </code>
/// </example>
/// </remarks>
[AspireExportIgnore(Reason = "The addExistingEntraApplication export accepts literal or parameter IDs independently.")]
public static IResourceBuilder<EntraIdApplicationResource> AddExistingEntraApplication(
this IDistributedApplicationBuilder builder,
[ResourceName] string name,
IResourceBuilder<ParameterResource> tenantId,
IResourceBuilder<ParameterResource> clientId)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentNullException.ThrowIfNull(tenantId);
ArgumentNullException.ThrowIfNull(clientId);
var resource = new EntraIdApplicationResource(name)
{
TenantIdParameter = tenantId.Resource,
ClientIdParameter = clientId.Resource
};
return ConfigureExistingApplication(builder, resource);
}
/// <summary>
/// Adds a reference to an existing Microsoft Entra ID application registration without enabling Azure provisioning.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="tenantId">
/// The ID of the app's home tenant: the directory where the app is registered, shown as "Directory (tenant) ID" on the
/// app registration's Overview page. A domain such as <c>contoso.onmicrosoft.com</c> is also accepted.
/// </param>
/// <param name="clientId">
/// The application (client) ID, shown as "Application (client) ID" on the app registration's Overview page.
/// </param>
/// <returns>A reference to the <see cref="IResourceBuilder{EntraIdApplicationResource}"/>.</returns>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="tenantId"/> is empty, is not a GUID or domain name, or is one of the sign-in audience
/// keywords <c>organizations</c>, <c>common</c> or <c>consumers</c>; or when <paramref name="clientId"/> is empty
/// or is not a GUID.
/// </exception>
/// <remarks>
/// <para>
/// Both the tenant ID and client ID are required to identify an existing Entra ID application.
/// Unlike ARM resources that can be identified by name and resource group, Entra ID app registrations
/// live in a specific tenant directory and are uniquely identified by their client ID within that tenant.
/// </para>
/// <para>
/// To let accounts from other tenants sign in, keep the home tenant here and call
/// <see cref="WithSignInAudience"/>.
/// </para>
/// <para>
/// The registration is used only for configuration. Aspire does not deploy Bicep or query Microsoft Graph
/// for this resource. Use <see cref="AddEntraIdApplication"/> to create a registration instead.
/// </para>
/// <example>
/// <code lang="csharp">
/// var identity = builder.AddExistingEntraApplication(
/// "identity",
/// tenantId: "aaaabbbb-0000-cccc-1111-dddd2222eeee",
/// clientId: "00001111-aaaa-2222-bbbb-3333cccc4444");
/// builder.AddDotnetProject("api", "../Api/Api.csproj").WithReference(identity);
/// </code>
/// </example>
/// </remarks>
[AspireExportIgnore(Reason = "The addExistingEntraApplication export accepts literal or parameter IDs independently.")]
public static IResourceBuilder<EntraIdApplicationResource> AddExistingEntraApplication(
this IDistributedApplicationBuilder builder,
[ResourceName] string name,
string tenantId,
string clientId)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ValidateExistingApplicationIds(tenantId, clientId);
var resource = new EntraIdApplicationResource(name)
{
TenantId = tenantId,
ClientId = clientId
};
return ConfigureExistingApplication(builder, resource);
}
/// <summary>
/// References an existing registration without enabling Azure provisioning or querying Microsoft Graph.
/// </summary>
/// <param name="builder">The application builder.</param>
/// <param name="name">The resource name.</param>
/// <param name="tenantId">The registration's home tenant GUID or domain, as a literal or parameter. Sign-in keywords are not home tenant IDs.</param>
/// <param name="clientId">The registration's application client GUID, as a literal or parameter.</param>
/// <returns>The application registration builder.</returns>
/// <remarks>Literal IDs are validated immediately; parameter values are validated at startup. Other Azure resources retain their provisioning setup.</remarks>
[AspireExport("addExistingEntraApplication")]
internal static IResourceBuilder<EntraIdApplicationResource> AddExistingEntraApplicationForExport(
this IDistributedApplicationBuilder builder,
[ResourceName] string name,
[AspireUnion(typeof(string), typeof(IResourceBuilder<ParameterResource>))] object tenantId,
[AspireUnion(typeof(string), typeof(IResourceBuilder<ParameterResource>))] object clientId)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
var resource = new EntraIdApplicationResource(name);
SetExistingApplicationIds(resource, tenantId, clientId);
return ConfigureExistingApplication(builder, resource);
}
private static void SetExistingApplicationIds(EntraIdApplicationResource resource, object tenantId, object clientId)
{
ValidateExistingId(tenantId, EntraIdValidation.ValidateTenantId, nameof(tenantId));
ValidateExistingId(clientId, EntraIdValidation.ValidateClientId, nameof(clientId));
resource.TenantId = tenantId as string;
resource.TenantIdParameter = (tenantId as IResourceBuilder<ParameterResource>)?.Resource;
resource.ClientId = clientId as string;
resource.ClientIdParameter = (clientId as IResourceBuilder<ParameterResource>)?.Resource;
}
private static void ValidateExistingId(object value, Func<string, string?> validate, string parameterName)
{
ArgumentNullException.ThrowIfNull(value, parameterName);
switch (value)
{
case string literal:
ArgumentException.ThrowIfNullOrEmpty(literal, parameterName);
if (validate(literal) is { } error)
{
throw new ArgumentException(error, parameterName);
}
break;
case IResourceBuilder<ParameterResource>:
break;
default:
throw new ArgumentException("Expected a literal ID or a parameter resource.", parameterName);
}
}
/// <summary>
/// Configures this resource to reference an existing Microsoft Entra ID application registration.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="tenantId">A parameter containing the registration's home tenant ID.</param>
/// <param name="clientId">A parameter containing the registration's application (client) ID.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="InvalidOperationException">A local developer certificate is configured on the registration.</exception>
/// <remarks>
/// <para>
/// Parameter values are validated when the application starts. The tenant ID must be a GUID or a domain such as
/// <c>contoso.onmicrosoft.com</c>, and the client ID must be a GUID.
/// </para>
/// <para>
/// <see cref="AddEntraIdApplication"/> enables Azure provisioning before this method is called.
/// This method does not disable that setup, so deployment can still require Azure authentication and provisioning
/// configuration. To reference an existing registration without enabling Azure provisioning, use
/// <see cref="AddExistingEntraApplication(IDistributedApplicationBuilder, string, IResourceBuilder{ParameterResource}, IResourceBuilder{ParameterResource})"/>.
/// </para>
/// <example>
/// <code lang="csharp">
/// var identity = builder.AddEntraIdApplication("identity")
/// .AsExistingApplication(builder.AddParameter("TenantId"), builder.AddParameter("ClientId"));
/// </code>
/// </example>
/// </remarks>
[AspireExportIgnore(Reason = "The asExistingApplication export accepts literal or parameter IDs independently.")]
public static IResourceBuilder<EntraIdApplicationResource> AsExistingApplication(
this IResourceBuilder<EntraIdApplicationResource> builder,
IResourceBuilder<ParameterResource> tenantId,
IResourceBuilder<ParameterResource> clientId)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(tenantId);
ArgumentNullException.ThrowIfNull(clientId);
ConfigureExistingApplication(builder);
builder.Resource.TenantIdParameter = tenantId.Resource;
builder.Resource.ClientIdParameter = clientId.Resource;
return builder;
}
/// <summary>
/// Configures this resource to reference an existing Microsoft Entra ID application registration.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="tenantId">The registration's home tenant ID, as a GUID or a domain such as <c>contoso.onmicrosoft.com</c>.</param>
/// <param name="clientId">The registration's application (client) ID.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentException">A tenant ID or client ID is empty or invalid.</exception>
/// <exception cref="InvalidOperationException">A local developer certificate is configured on the registration.</exception>
/// <remarks>
/// <para>
/// Pass the home tenant ID, not a sign-in keyword such as <c>organizations</c>.
/// Use <see cref="WithSignInAudience"/> to allow accounts from other tenants to sign in.
/// </para>
/// <para>
/// <see cref="AddEntraIdApplication"/> enables Azure provisioning before this method is called.
/// This method does not disable that setup, so deployment can still require Azure authentication and provisioning
/// configuration. To reference an existing registration without enabling Azure provisioning, use
/// <see cref="AddExistingEntraApplication(IDistributedApplicationBuilder, string, string, string)"/>.
/// </para>
/// <example>
/// <code lang="csharp">
/// var identity = builder.AddEntraIdApplication("identity")
/// .AsExistingApplication(
/// tenantId: "aaaabbbb-0000-cccc-1111-dddd2222eeee",
/// clientId: "00001111-aaaa-2222-bbbb-3333cccc4444");
/// </code>
/// </example>
/// </remarks>
[AspireExportIgnore(Reason = "The asExistingApplication export accepts literal or parameter IDs independently.")]
public static IResourceBuilder<EntraIdApplicationResource> AsExistingApplication(
this IResourceBuilder<EntraIdApplicationResource> builder,
string tenantId,
string clientId)
{
ArgumentNullException.ThrowIfNull(builder);
ValidateExistingApplicationIds(tenantId, clientId);
ConfigureExistingApplication(builder);
builder.Resource.TenantId = tenantId;
builder.Resource.ClientId = clientId;
return builder;
}
/// <summary>
/// References an existing registration without undoing the Azure provisioning setup enabled when this resource was added.
/// </summary>
/// <param name="builder">The registration builder.</param>
/// <param name="tenantId">The registration's home tenant GUID or domain, as a literal or parameter.</param>
/// <param name="clientId">The registration's application client GUID, as a literal or parameter.</param>
/// <returns>The registration builder.</returns>
/// <remarks>
/// The registration itself is not provisioned or queried. Deployment can still require Azure authentication and configuration.
/// Use addExistingEntraApplication instead for a configuration-only AppHost without Azure setup.
/// </remarks>
[AspireExport("asExistingApplication")]
internal static IResourceBuilder<EntraIdApplicationResource> AsExistingApplicationForExport(
this IResourceBuilder<EntraIdApplicationResource> builder,
[AspireUnion(typeof(string), typeof(IResourceBuilder<ParameterResource>))] object tenantId,
[AspireUnion(typeof(string), typeof(IResourceBuilder<ParameterResource>))] object clientId)
{
ArgumentNullException.ThrowIfNull(builder);
// Reject incompatible registrations before touching any state, so a failed conversion leaves the IDs unchanged.
builder.Resource.EnsureCanBeExistingApplication();
SetExistingApplicationIds(builder.Resource, tenantId, clientId);
return ConfigureExistingApplication(builder);
}
private static void ValidateExistingApplicationIds(string tenantId, string clientId)
{
ArgumentException.ThrowIfNullOrEmpty(tenantId);
ArgumentException.ThrowIfNullOrEmpty(clientId);
if (EntraIdValidation.ValidateTenantId(tenantId) is { } tenantIdError)
{
throw new ArgumentException($"'{tenantId}' is not a valid tenant ID. {tenantIdError}", nameof(tenantId));
}
if (EntraIdValidation.ValidateClientId(clientId) is { } clientIdError)
{
throw new ArgumentException($"'{clientId}' is not a valid client ID. {clientIdError}", nameof(clientId));
}
}
private static IResourceBuilder<EntraIdApplicationResource> ConfigureExistingApplication(
IDistributedApplicationBuilder builder,
EntraIdApplicationResource resource)
{
return ConfigureExistingApplication(ConfigureEntraIdResource(builder.AddResource(resource)));
}
private static IResourceBuilder<EntraIdApplicationResource> ConfigureExistingApplication(
IResourceBuilder<EntraIdApplicationResource> builder)
{
builder.Resource.SetExistingApplication();
return builder.ExcludeFromManifest();
}
/// <summary>
/// Creates and provisions a short-lived certificate credential for local development.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="InvalidOperationException">The registration is an existing application, which Aspire never modifies.</exception>
/// <remarks>
/// <para>
/// Each application gets a dedicated certificate in the AppHost's local store. Certificates last two days;
/// on a subsequent run, a certificate with one day or less remaining is replaced by overwriting its PFX file.
/// Only the public certificate is provisioned in Entra. Referencing applications receive the private
/// credential as a secret <c>Base64EncodedValue</c> in Microsoft.Identity.Web's <c>ClientCredentials</c> configuration.
/// Aspire replaces the registration's entire <c>keyCredentials</c> collection with the selected certificate.
/// Existing certificate credentials are not retained, so older sessions must restart after renewal.
/// </para>
/// <para>
/// Development and production must use separate Entra registrations. Never register developer certificates
/// on a production application. Different resource groups do not isolate tenant-scoped registrations:
/// use distinct registration names or tenant-resource suffixes. This method has no effect when publishing;
/// omitting a credential from publish does not revoke a key previously registered against the same application.
/// </para>
/// <para>
/// Renewal is checked at AppHost startup, not in the background. Restart long-running development sessions
/// before the certificate expires. Use <c>PublishWithFederatedManagedIdentityCredential</c> for deployed workloads.
/// </para>
/// </remarks>
/// <example>
/// <code lang="csharp">
/// // Use different Azure:TenantResourceSuffix values for development and production.
/// var entra = builder.AddEntraIdApplication("entra-web")
/// .RunWithLocalDeveloperCertificateCredential()
/// .PublishWithFederatedManagedIdentityCredential();
///
/// builder.AddDotnetProject("web", "../Web/Web.csproj").WithReference(entra);
/// </code>
/// </example>
[AspireExport]
public static IResourceBuilder<EntraIdApplicationResource> RunWithLocalDeveloperCertificateCredential(
this IResourceBuilder<EntraIdApplicationResource> builder)
{
ArgumentNullException.ThrowIfNull(builder);
if (builder.Resource.IsExistingApplication)
{
throw new InvalidOperationException("Local developer certificates can only be provisioned for registrations created by Aspire. Existing registrations remain caller-managed.");
}
if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode ||
builder.Resource.ClientCredentials.OfType<EntraIdLocalDeveloperCertificateCredential>().Any())
{
return builder;
}
builder.ApplicationBuilder.Services.TryAddSingleton(TimeProvider.System);
builder.ApplicationBuilder.Services.TryAddSingleton<LocalDeveloperCertificateStore>();
builder.Resource.ClientCredentials.Add(new EntraIdLocalDeveloperCertificateCredential(builder.Resource, builder.ApplicationBuilder.ExecutionContext));
return builder;
}
/// <summary>
/// Adds a client secret credential to this Entra ID application.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="clientSecret">A secret parameter containing the client secret.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="clientSecret"/> is not marked as a secret parameter.
/// </exception>
/// <remarks>
/// <para>
/// This adds an entry to the <c>ClientCredentials</c> array in the Microsoft.Identity.Web
/// configuration with <c>SourceType</c> set to <c>"ClientSecret"</c>.
/// </para>
/// </remarks>
[AspireExport]
public static IResourceBuilder<EntraIdApplicationResource> WithClientSecret(
this IResourceBuilder<EntraIdApplicationResource> builder,
IResourceBuilder<ParameterResource> clientSecret)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(clientSecret);
if (!clientSecret.Resource.Secret)
{
throw new ArgumentException("The client secret parameter must be marked as secret. Use AddParameter with secret: true when creating the parameter.", nameof(clientSecret));
}
builder.Resource.ClientCredentials.Add(new EntraIdClientSecretCredential
{
ClientSecret = clientSecret.Resource
});
return builder;
}
/// <summary>
/// Configures the app to authenticate as this Entra ID application using a managed identity that you specify by client ID,
/// without a client secret or certificate.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="managedIdentityClientId">
/// The client ID of the user-assigned managed identity that the consuming app runs as. This is not the identity's
/// object (principal) ID or its Azure resource ID.
/// </param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="managedIdentityClientId"/> is empty or is not a GUID.
/// </exception>
/// <remarks>
/// <para>
/// This only configures the consuming app: it adds a <c>SignedAssertionFromManagedIdentity</c> entry to the
/// <c>ClientCredentials</c> array of the Microsoft.Identity.Web configuration. Aspire does not create or assign the
/// managed identity, and does not add a federated identity credential to the registration. It applies in run mode
/// as well as when publishing.
/// </para>
/// <para>
/// Use this method for an existing registration (<c>AddExistingEntraApplication</c>), which Aspire never modifies,
/// or for a managed identity that Aspire does not manage. Before the app can get a token, the registration must
/// already trust the identity: add a federated identity credential to it whose issuer is the tenant's v2.0 endpoint
/// (<c>https://login.microsoftonline.com/{tenant}/v2.0</c> in the Azure public cloud), whose subject is the identity's
/// object (principal) ID, and whose audience is the token exchange audience of the cloud
/// (<c>api://AzureADTokenExchange</c> in the Azure public cloud). Otherwise, token requests fail with AADSTS700213.
/// </para>
/// <para>
/// For a registration that Aspire creates, use
/// <see cref="PublishWithFederatedManagedIdentityCredential(IResourceBuilder{EntraIdApplicationResource})"/>
/// instead, which also creates the trust. Only user-assigned managed identities are supported.
/// See <see href="https://learn.microsoft.com/entra/workload-id/workload-identity-federation-config-app-trust-managed-identity"/>.
/// </para>
/// </remarks>
/// <example>
/// <code lang="csharp">
/// var entraWeb = builder.AddExistingEntraApplication("entra-web", tenantId, clientId)
/// .WithFederatedManagedIdentityCredential("00001111-aaaa-2222-bbbb-3333cccc4444");
/// </code>
/// </example>
[AspireExportIgnore(Reason = "The withFederatedManagedIdentityCredential export also accepts typed token exchange options.")]
public static IResourceBuilder<EntraIdApplicationResource> WithFederatedManagedIdentityCredential(
this IResourceBuilder<EntraIdApplicationResource> builder,
string managedIdentityClientId)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(managedIdentityClientId);
if (EntraIdValidation.ValidateManagedIdentityClientId(managedIdentityClientId) is { } error)
{
throw new ArgumentException(error, nameof(managedIdentityClientId));
}
builder.Resource.ClientCredentials.Add(new EntraIdFederatedIdentityCredential(managedIdentityClientId));
return builder;
}
/// <summary>
/// Configures a pre-existing managed identity trust for token acquisition, without provisioning or assigning an identity.
/// </summary>
/// <param name="builder">The registration builder.</param>
/// <param name="managedIdentityClientId">The client GUID of a user-assigned managed identity that the registration already trusts.</param>
/// <param name="options">Optional token exchange settings.</param>
/// <returns>The registration builder.</returns>
/// <remarks>Applies in run and publish modes. This only emits consumer configuration; it does not create federation trust.</remarks>
[AspireExport("withFederatedManagedIdentityCredential")]
internal static IResourceBuilder<EntraIdApplicationResource> WithFederatedManagedIdentityCredentialForExport(
this IResourceBuilder<EntraIdApplicationResource> builder,
string managedIdentityClientId,
EntraIdTokenExchangeOptions? options = null)
{
ArgumentNullException.ThrowIfNull(builder);
return builder.WithCredential(new EntraIdFederatedIdentityCredential(managedIdentityClientId)
{
TokenExchangeUrl = options?.TokenExchangeUrl,
TokenExchangeAuthority = options?.TokenExchangeAuthority
});
}
/// <summary>
/// When published, authenticates this Entra ID application as each referencing resource's own user-assigned managed
/// identity, without a client secret or certificate.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <returns>The resource builder for chaining.</returns>
/// <remarks>
/// <para>
/// A single call covers every resource that references this application, including resources that reference it after
/// this call, and the application trusts each resource's own identity. Use
/// <see cref="PublishWithFederatedManagedIdentityCredential{T}(IResourceBuilder{EntraIdApplicationResource}, IResourceBuilder{T})"/>
/// to trust a single resource instead. The method adds one credential entry to the application's configuration, and
/// Aspire creates one federated identity credential on the registration for each trusted identity.
/// </para>
/// <para>
/// Every resource that references this application with <c>WithReference</c> is given a managed identity when published,
/// and the application trusts it. The identity is the one the resource already has, for example from
/// <c>WithAzureUserAssignedIdentity</c> or because it references other Azure resources; otherwise the Azure environment
/// creates one. For an application that Aspire creates, Aspire adds a federated identity credential to the registration
/// for each identity: the issuer is the tenant's v2.0 endpoint, the subject is the identity's object (principal) ID, and
/// the audience is the token exchange audience of the application's cloud. The resource receives the identity's client ID
/// as <c>ManagedIdentityClientId</c>.
/// </para>
/// <para>
/// This is only for registrations that Aspire creates. For an existing registration (<c>AddExistingEntraApplication</c>),
/// which Aspire never modifies, this method throws; use
/// <see cref="WithFederatedManagedIdentityCredential(IResourceBuilder{EntraIdApplicationResource}, string)"/> instead.
/// </para>
/// <para>
/// Managed identities exist only in Azure, so the credential is not used when running locally. Add another
/// credential, such as a client secret, for local development.
/// Supported clouds are Azure public, Azure US Government and Azure China. Only user-assigned managed identities are supported.
/// See <see href="https://learn.microsoft.com/entra/workload-id/workload-identity-federation-config-app-trust-managed-identity"/>.
/// </para>
/// </remarks>
/// <example>
/// <code lang="csharp">
/// var entra = builder.AddEntraIdApplication("entra-web")
/// .PublishWithFederatedManagedIdentityCredential();
///
/// builder.AddDotnetProject("web", "../Web/Web.csproj")
/// .WithReference(entra);
/// </code>
/// </example>
[AspireExportIgnore(Reason = "The publishWithFederatedManagedIdentityCredential export selects consumer, compute resource, or explicit identity using a union.")]
public static IResourceBuilder<EntraIdApplicationResource> PublishWithFederatedManagedIdentityCredential(
this IResourceBuilder<EntraIdApplicationResource> builder)
{
ArgumentNullException.ThrowIfNull(builder);
ThrowIfExistingApplication(builder);
// Managed identities exist only in Azure, and the identity may not be part of the model when running, so the trust is
// a publish-time concern. Adding it when running would make the registration's Bicep reference an identity that
// is never provisioned.
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
return builder;
}
builder.Resource.ClientCredentials.Add(new EntraIdFederatedIdentityCredential());
foreach (var consumer in builder.Resource.Consumers)
{
EnsureManagedIdentityRequired(consumer);
}
return builder;
}
/// <summary>
/// When published, authenticates this Entra ID application as a compute resource's user-assigned managed identity,
/// without a client secret or certificate.
/// </summary>
/// <typeparam name="T">The type of the compute resource.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="computeResource">
/// The compute resource to trust. It must reference this application with <c>WithReference</c>.
/// Only this resource receives the managed identity credential.
/// </param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when <paramref name="computeResource"/> does not reference this application.
/// </exception>
/// <remarks>
/// Only <paramref name="computeResource"/>'s identity is trusted, and only it receives the managed identity credential.
/// The resource's identity is resolved after the Azure environment has prepared it, as described in
/// <see cref="PublishWithFederatedManagedIdentityCredential(IResourceBuilder{EntraIdApplicationResource})"/>.
/// </remarks>
/// <example>
/// <code lang="csharp">
/// var entra = builder.AddEntraIdApplication("entra-web");
/// var web = builder.AddDotnetProject("web", "../Web/Web.csproj")
/// .WithReference(entra);
///
/// entra.PublishWithFederatedManagedIdentityCredential(web);
/// </code>
/// </example>
[AspireExportIgnore(Reason = "The publishWithFederatedManagedIdentityCredential export selects consumer, compute resource, or explicit identity using a union.")]
public static IResourceBuilder<EntraIdApplicationResource> PublishWithFederatedManagedIdentityCredential<T>(
this IResourceBuilder<EntraIdApplicationResource> builder,
IResourceBuilder<T> computeResource)
where T : IComputeResource
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(computeResource);
ThrowIfExistingApplication(builder);
// Managed identities exist only in Azure, and the identity may not be part of the model when running, so the trust is
// a publish-time concern. Adding it when running would make the registration's Bicep reference an identity that
// is never provisioned.
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
return builder;
}
builder.Resource.ClientCredentials.Add(new EntraIdFederatedIdentityCredential
{
ComputeTarget = computeResource.Resource
});
EnsureManagedIdentityRequired(computeResource.Resource);
return builder;
}
/// <summary>
/// When published, authenticates this Entra ID application as a specific user-assigned managed identity,
/// without a client secret or certificate.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="identity">
/// The user-assigned managed identity to trust. Every resource that references this application must use it,
/// for example with <c>WithAzureUserAssignedIdentity</c>.
/// </param>
/// <returns>The resource builder for chaining.</returns>
/// <remarks>
/// The federated identity credential uses the identity's principal ID as the subject and consumers receive its client ID.
/// Throws for an existing registration, which Aspire never modifies.
/// The credential is not used when running locally.
/// </remarks>
/// <example>
/// <code lang="csharp">
/// var identity = builder.AddAzureUserAssignedIdentity("web-identity");
/// var entra = builder.AddEntraIdApplication("entra-web")
/// .PublishWithFederatedManagedIdentityCredential(identity);
///
/// builder.AddDotnetProject("web", "../Web/Web.csproj")
/// .WithAzureUserAssignedIdentity(identity)
/// .WithReference(entra);
/// </code>
/// </example>
[AspireExportIgnore(Reason = "The publishWithFederatedManagedIdentityCredential export selects consumer, compute resource, or explicit identity using a union.")]
public static IResourceBuilder<EntraIdApplicationResource> PublishWithFederatedManagedIdentityCredential(
this IResourceBuilder<EntraIdApplicationResource> builder,
IResourceBuilder<AzureUserAssignedIdentityResource> identity)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(identity);
ThrowIfExistingApplication(builder);
// Managed identities exist only in Azure, and the identity may not be part of the model when running, so the trust is
// a publish-time concern. Adding it when running would make the registration's Bicep reference an identity that
// is never provisioned.
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
return builder;
}
builder.Resource.ClientCredentials.Add(new EntraIdFederatedIdentityCredential
{
ManagedIdentity = identity.Resource
});
return builder;
}
/// <summary>
/// Publishes managed identity federation trust for a registration created by Aspire.
/// </summary>
/// <param name="builder">The registration builder.</param>
/// <param name="identity">A compute resource whose identity to trust, an explicit user-assigned identity, or no value to trust every consumer's own identity.</param>
/// <returns>The registration builder.</returns>
/// <remarks>
/// Not used during local run. Throws for existing registrations. A selected compute resource must reference this registration.
/// With an explicit identity, each consumer must use that identity. Only Azure public, US Government and China clouds are supported.
/// </remarks>
[AspireExport("publishWithFederatedManagedIdentityCredential")]
internal static IResourceBuilder<EntraIdApplicationResource> PublishWithFederatedManagedIdentityCredentialForExport(
this IResourceBuilder<EntraIdApplicationResource> builder,
[AspireUnion(typeof(IResourceBuilder<IComputeResource>), typeof(IResourceBuilder<AzureUserAssignedIdentityResource>))] object? identity = null)
{
ArgumentNullException.ThrowIfNull(builder);
return identity switch
{
null => builder.PublishWithFederatedManagedIdentityCredential(),
IResourceBuilder<AzureUserAssignedIdentityResource> managedIdentity => builder.PublishWithFederatedManagedIdentityCredential(managedIdentity),
IResourceBuilder<IComputeResource> compute => builder.PublishWithFederatedManagedIdentityCredential(compute),
_ => throw new ArgumentException("Expected a compute resource or a user-assigned managed identity.", nameof(identity))
};
}
// Aspire never modifies an existing registration, so it cannot create the federated credential that makes the
// registration trust a managed identity. Fail here rather than let the app deploy and then fail to get a token.
private static void ThrowIfExistingApplication(IResourceBuilder<EntraIdApplicationResource> builder)
{
if (builder.Resource.IsExistingApplication)
{
throw new InvalidOperationException(
$"Entra ID application '{builder.Resource.Name}' is an existing registration, and Aspire does not modify existing registrations, " +
"so it cannot add the federated credential that makes the registration trust a managed identity. " +
"Add that credential to the registration yourself, then call WithFederatedManagedIdentityCredential with the identity's client ID.");
}
}
// Referencing the application needs an identity the way referencing an Azure resource needs a role assignment,
// so ask the Azure resource preparer to give the resource one even though it has no role assignments.
private static void EnsureManagedIdentityRequired(IResource consumer)
{
if (!consumer.HasAnnotationOfType<ManagedIdentityRequiredAnnotation>())
{
consumer.Annotations.Add(new ManagedIdentityRequiredAnnotation());
}
}
/// <summary>
/// Adds a certificate credential from Azure Key Vault to this Entra ID application.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="keyVaultUrl">The URL of the Key Vault (e.g., <c>"https://myvault.vault.azure.net"</c>).</param>
/// <param name="certificateName">The name of the certificate in Key Vault.</param>
/// <returns>The resource builder for chaining.</returns>
[AspireExport]
public static IResourceBuilder<EntraIdApplicationResource> WithCertificateFromKeyVault(
this IResourceBuilder<EntraIdApplicationResource> builder,
string keyVaultUrl,
string certificateName)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(keyVaultUrl);
ArgumentException.ThrowIfNullOrEmpty(certificateName);
builder.Resource.ClientCredentials.Add(new EntraIdKeyVaultCertificateCredential
{
KeyVaultUrl = keyVaultUrl,
CertificateNameInKeyVault = certificateName
});
return builder;
}
/// <summary>
/// Adds a certificate credential from the certificate store by thumbprint.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="storePath">The certificate store path (e.g., <c>"CurrentUser/My"</c>).</param>
/// <param name="thumbprint">The certificate thumbprint.</param>
/// <returns>The resource builder for chaining.</returns>
[AspireExport]
public static IResourceBuilder<EntraIdApplicationResource> WithCertificateThumbprint(
this IResourceBuilder<EntraIdApplicationResource> builder,
string storePath,
string thumbprint)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(storePath);
ArgumentException.ThrowIfNullOrEmpty(thumbprint);
builder.Resource.ClientCredentials.Add(new EntraIdStoreCertificateCredential
{
StorePath = storePath,
Thumbprint = thumbprint
});
return builder;
}
/// <summary>
/// Adds a certificate credential from the certificate store by distinguished name.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="storePath">The certificate store path (e.g., <c>"CurrentUser/My"</c>).</param>
/// <param name="distinguishedName">The certificate distinguished name (e.g., <c>"CN=MyCert"</c>).</param>
/// <returns>The resource builder for chaining.</returns>
[AspireExport]
public static IResourceBuilder<EntraIdApplicationResource> WithCertificateDistinguishedName(
this IResourceBuilder<EntraIdApplicationResource> builder,
string storePath,
string distinguishedName)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(storePath);
ArgumentException.ThrowIfNullOrEmpty(distinguishedName);
builder.Resource.ClientCredentials.Add(new EntraIdStoreCertificateCredential
{
StorePath = storePath,
DistinguishedName = distinguishedName
});
return builder;
}
/// <summary>
/// Appends a certificate file credential. The file path is on the consuming resource, not the AppHost.
/// </summary>
/// <param name="builder">The registration builder.</param>
/// <param name="filePath">The path to the certificate file on the consumer.</param>
/// <param name="password">An optional secret parameter for a password-protected certificate.</param>
/// <returns>The registration builder.</returns>
/// <remarks>The password must be marked secret and remains a deferred parameter. File credentials are not recommended for production.</remarks>
[AspireExport("withCertificateFromFile")]
internal static IResourceBuilder<EntraIdApplicationResource> WithCertificateFromFileForExport(
this IResourceBuilder<EntraIdApplicationResource> builder,
string filePath,
IResourceBuilder<ParameterResource>? password = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(filePath);
return builder.WithCredential(new EntraIdFileCertificateCredential
{
FilePath = filePath,
Password = password?.Resource
});
}
/// <summary>
/// Appends a signed assertion file credential, such as an AKS workload identity token.
/// </summary>
/// <param name="builder">The registration builder.</param>
/// <param name="filePath">The token file path on the consumer. Omit to use the consumer's AZURE_FEDERATED_TOKEN_FILE setting.</param>
/// <returns>The registration builder.</returns>
[AspireExport("withSignedAssertionFile")]
internal static IResourceBuilder<EntraIdApplicationResource> WithSignedAssertionFileForExport(
this IResourceBuilder<EntraIdApplicationResource> builder,
string? filePath = null)
{
ArgumentNullException.ThrowIfNull(builder);
if (filePath is not null)
{
ArgumentException.ThrowIfNullOrEmpty(filePath);
}
return builder.WithCredential(new EntraIdSignedAssertionFileCredential { FilePath = filePath });
}
/// <summary>
/// Adds a raw credential entry for advanced scenarios not covered by convenience methods.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="credential">A fully configured <see cref="EntraIdClientCredential"/>.</param>
/// <returns>The resource builder for chaining.</returns>
[AspireExportIgnore(Reason = "EntraIdClientCredential is a mutable C# hierarchy. Use the typed credential exports instead.")]
public static IResourceBuilder<EntraIdApplicationResource> WithCredential(
this IResourceBuilder<EntraIdApplicationResource> builder,
EntraIdClientCredential credential)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(credential);
builder.Resource.ClientCredentials.Add(credential);
return builder;
}
/// <summary>
/// Configures the Entra ID instance URL. Defaults to <c>https://login.microsoftonline.com/</c>.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="instance">
/// The Entra ID instance URL, such as <c>https://login.microsoftonline.us/</c> for Azure Government or
/// <c>https://contoso.ciamlogin.com/</c> for an external tenant.
/// </param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="instance"/> is empty or is not an absolute HTTPS URL without a query string or fragment.
/// </exception>
[AspireExport]
public static IResourceBuilder<EntraIdApplicationResource> WithInstance(
this IResourceBuilder<EntraIdApplicationResource> builder,
string instance)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(instance);
if (EntraIdValidation.ValidateInstance(instance) is { } error)
{
throw new ArgumentException($"'{instance}' is not a valid Entra ID instance. {error}", nameof(instance));
}
builder.Resource.Instance = instance;
return builder;
}
/// <summary>
/// Configures which accounts can sign in to this application.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="signInAudience">
/// The accounts that can sign in. It must match the "Supported account types" setting of the app registration.
/// </param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="signInAudience"/> is not a defined <see cref="EntraIdSignInAudience"/> value.
/// </exception>
/// <remarks>
/// <para>
/// By default only accounts in the app's home tenant can sign in, and referencing services receive the home tenant
/// as <c>TenantId</c>. For the other audiences they receive the keyword that Microsoft.Identity.Web uses to accept
/// those accounts (<c>organizations</c>, <c>common</c> or <c>consumers</c>) as <c>TenantId</c>, and the home tenant
/// as <c>AppHomeTenantId</c>, which Microsoft.Identity.Web uses when the app acquires tokens as itself.
/// </para>
/// <para>
/// Users from any tenant that the audience allows can then sign in, so check the tenant ID (<c>tid</c>) claim of
/// incoming tokens before giving access to tenant-specific data. For more information, see
/// <a href="https://learn.microsoft.com/entra/identity-platform/claims-validation">Secure applications and APIs by validating claims</a>.
/// </para>
/// <example>
/// Let work or school accounts from any organization sign in:
/// <code lang="csharp">
/// var entraWeb = builder.AddExistingEntraApplication("entra-web", tenantId, clientId)
/// .WithSignInAudience(EntraIdSignInAudience.AzureADMultipleOrgs);
/// </code>
/// </example>
/// </remarks>
/// <ats-remarks>
/// The audience must match the registration's supported account types. The default uses the home tenant as TenantId.
/// Other audiences use organizations, common, or consumers as TenantId and also emit AppHomeTenantId.
/// Validate the tenant ID (tid) claim before allowing access to tenant-specific data.
/// See https://learn.microsoft.com/entra/identity-platform/claims-validation.
/// </ats-remarks>
[AspireExport]
public static IResourceBuilder<EntraIdApplicationResource> WithSignInAudience(
this IResourceBuilder<EntraIdApplicationResource> builder,
EntraIdSignInAudience signInAudience)
{
ArgumentNullException.ThrowIfNull(builder);
if (!Enum.IsDefined(signInAudience))
{
throw new ArgumentOutOfRangeException(nameof(signInAudience), signInAudience, $"The value is not a defined {nameof(EntraIdSignInAudience)} value.");
}
builder.Resource.SignInAudience = signInAudience;
return builder;
}
/// <summary>
/// Adds web redirect URIs to a registration created by Aspire.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="redirectUris">The redirect URIs to register for browser sign-in and sign-out callbacks.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="redirectUris"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when a redirect URI is empty.</exception>
/// <remarks>
/// URIs are written directly into the generated infrastructure; endpoints are not discovered automatically.
/// Existing registrations are not modified.
/// </remarks>
/// <example>
/// <code lang="csharp">
/// var entraWeb = builder.AddEntraIdApplication("entra-web")
/// .WithRedirectUris("https://localhost:7123/signin-oidc");
/// </code>
/// </example>
[AspireExportIgnore(Reason = "The withRedirectUris export accepts a literal, parameter, or endpoint with optional callback paths.")]
public static IResourceBuilder<EntraIdApplicationResource> WithRedirectUris(
this IResourceBuilder<EntraIdApplicationResource> builder,
params string[] redirectUris)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(redirectUris);
foreach (var redirectUri in redirectUris)
{
ArgumentException.ThrowIfNullOrWhiteSpace(redirectUri, nameof(redirectUris));
}
foreach (var redirectUri in redirectUris)
{
builder.Resource.AddRedirectUri(redirectUri);
}
return builder;
}
/// <summary>
/// Adds parameters whose values supply web redirect URIs to a registration created by Aspire.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="redirectUris">Parameters whose values are the redirect URIs. Each value may contain several URIs separated by commas.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/>, <paramref name="redirectUris"/>, or one of its elements is null.</exception>
/// <remarks>
/// <para>
/// Use this overload when the externally visible callback URIs (for example, behind a gateway, reverse proxy,
/// or custom domain) are supplied per environment at deployment time. The values are split on commas in the
/// generated infrastructure, so a URI supplied through a parameter can't contain a comma and entries must not
/// have surrounding whitespace. Literal URIs can be mixed with parameter-backed URIs by also calling
/// <see cref="WithRedirectUris(IResourceBuilder{EntraIdApplicationResource}, string[])"/>; URIs are registered in call order.
/// </para>
/// <para>
/// Endpoints are not discovered automatically. Existing registrations are not modified.
/// </para>
/// </remarks>
/// <example>
/// <code lang="csharp">
/// var signIn = builder.AddParameter("web-signin-redirect-uri");
/// var signOut = builder.AddParameter("web-signout-redirect-uri");
/// var entraWeb = builder.AddEntraIdApplication("entra-web")
/// .WithRedirectUris(signIn, signOut);
/// </code>
/// </example>
[AspireExportIgnore(Reason = "The withRedirectUris export accepts a literal, parameter, or endpoint with optional callback paths.")]
public static IResourceBuilder<EntraIdApplicationResource> WithRedirectUris(
this IResourceBuilder<EntraIdApplicationResource> builder,
params IResourceBuilder<ParameterResource>[] redirectUris)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(redirectUris);
foreach (var redirectUri in redirectUris)
{
ArgumentNullException.ThrowIfNull(redirectUri, nameof(redirectUris));
}
foreach (var redirectUri in redirectUris)
{
builder.Resource.AddRedirectUri(redirectUri.Resource);
}
return builder;
}
/// <summary>
/// Adds redirect URIs for a resource's endpoint to a registration created by Aspire.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="endpoint">The endpoint that serves the callbacks.</param>
/// <param name="callbackPaths">The callback paths on that endpoint, such as <c>/signin-oidc</c>.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when an argument is null.</exception>
/// <exception cref="ArgumentException">Thrown when no path is provided or a path doesn't start with <c>/</c>.</exception>
/// <remarks>
/// <para>
/// The scheme, host, and port come from the endpoint so they follow local endpoint allocation. When running locally the
/// allocated URL is used. When publishing or deploying, the host name comes from the resource's compute environment and
/// the scheme is <c>https</c>.
/// </para>
/// <para>
/// The callback path can't be discovered from the endpoint: it's configured by the consuming application, so it is
/// supplied here and must match that configuration. When the externally visible URL differs from the endpoint
/// (gateway, reverse proxy, or custom domain), supply the full URL with <c>WithRedirectUris</c> instead.
/// Existing registrations are not modified.
/// </para>
/// <para>
/// When running locally, the URL shown as the endpoint's primary URL is used, so a friendly <c>*.dev.localhost</c> name
/// is registered when the dashboard uses one. That URL is read when this registration's URL callback runs, so a
/// <c>WithUrlForEndpoint</c> or <c>WithUrls</c> callback added after this call that changes the endpoint's host isn't
/// reflected. Add such callbacks before calling this method.
/// </para>
/// </remarks>
/// <example>
/// <code lang="csharp">
/// var web = builder.AddDotnetProject("web", "../Web/Web.csproj").WithExternalHttpEndpoints();
/// var entraWeb = builder.AddEntraIdApplication("entra-web")
/// .WithRedirectUris(web.GetEndpoint("https"), "/signin-oidc", "/signout-callback-oidc");
/// </code>
/// </example>
[AspireExportIgnore(Reason = "The withRedirectUris export accepts a literal, parameter, or endpoint with optional callback paths.")]
public static IResourceBuilder<EntraIdApplicationResource> WithRedirectUris(
this IResourceBuilder<EntraIdApplicationResource> builder,
EndpointReference endpoint,
params string[] callbackPaths)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(endpoint);
ArgumentNullException.ThrowIfNull(callbackPaths);
if (callbackPaths.Length == 0)
{
throw new ArgumentException("At least one callback path is required.", nameof(callbackPaths));
}
foreach (var path in callbackPaths)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path, nameof(callbackPaths));
if (!path.StartsWith('/'))
{
throw new ArgumentException($"The callback path '{path}' must start with '/'.", nameof(callbackPaths));
}
}
foreach (var path in callbackPaths)
{
builder.Resource.AddRedirectUri(new EntraIdEndpointRedirectUri(endpoint, path, builder.ApplicationBuilder.ExecutionContext.IsPublishMode));
}
return builder;
}
/// <summary>
/// Adds redirect URIs for a host supplied by a parameter to a registration created by Aspire.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="host">A parameter whose value is the host name (optionally with a port) that serves the callbacks, such as <c>app.contoso.com</c>. Don't include a scheme or path.</param>
/// <param name="callbackPath">The first callback path on that host, such as <c>/signin-oidc</c>.</param>
/// <param name="additionalCallbackPaths">Further callback paths on that host.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when an argument is null.</exception>
/// <exception cref="ArgumentException">Thrown when a path is empty or doesn't start with <c>/</c>.</exception>
/// <remarks>
/// <para>
/// Use this overload when the externally visible host (for example, a custom domain or gateway) is supplied per
/// environment at deployment time but the callback paths are fixed by the application. Each URI is
/// <c>https://{host}{path}</c>, so the scheme is always <c>https</c>.
/// </para>
/// <para>
/// The first path is a separate parameter so that a call with only a parameter still binds to
/// <see cref="WithRedirectUris(IResourceBuilder{EntraIdApplicationResource}, IResourceBuilder{ParameterResource}[])"/>.
/// Existing registrations are not modified.
/// </para>
/// </remarks>
/// <example>
/// <code lang="csharp">
/// var host = builder.AddParameter("web-host");
/// var entraWeb = builder.AddEntraIdApplication("entra-web")
/// .WithRedirectUris(host, "/signin-oidc", "/signout-callback-oidc");
/// </code>
/// </example>
[AspireExportIgnore(Reason = "The withRedirectUris export accepts a literal, parameter, or endpoint with optional callback paths.")]
public static IResourceBuilder<EntraIdApplicationResource> WithRedirectUris(
this IResourceBuilder<EntraIdApplicationResource> builder,
IResourceBuilder<ParameterResource> host,
string callbackPath,
params string[] additionalCallbackPaths)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(additionalCallbackPaths);
string[] callbackPaths = [callbackPath, .. additionalCallbackPaths];
foreach (var path in callbackPaths)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path, nameof(callbackPath));
if (!path.StartsWith('/'))
{
throw new ArgumentException($"The callback path '{path}' must start with '/'.", nameof(callbackPath));
}
}
foreach (var path in callbackPaths)
{
builder.Resource.AddRedirectUri(new EntraIdHostRedirectUri(host.Resource, path));
}
return builder;
}
/// <summary>
/// Appends web redirect URIs supplied as a literal, a parameter, or an endpoint.
/// </summary>
/// <param name="builder">The registration builder.</param>
/// <param name="redirectUri">A full URI, a parameter containing comma-separated full URIs, or an endpoint. With callback paths, a parameter supplies a host instead.</param>
/// <param name="callbackPaths">Callback paths starting with '/'. Required for an endpoint; with a host parameter, produces HTTPS URIs. Omit for full URIs.</param>
/// <returns>The registration builder.</returns>
/// <remarks>
/// Call repeatedly to append values in order. Endpoint URLs remain deferred: run uses the allocated primary URL;
/// publish/deploy uses the compute environment host. Parameter URI lists are split on commas without trimming.
/// Existing registrations are not modified.
/// </remarks>
[AspireExport("withRedirectUris")]
internal static IResourceBuilder<EntraIdApplicationResource> WithRedirectUrisForExport(
this IResourceBuilder<EntraIdApplicationResource> builder,
[AspireUnion(typeof(string), typeof(IResourceBuilder<ParameterResource>), typeof(EndpointReference))] object redirectUri,
string[]? callbackPaths = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(redirectUri);
return redirectUri switch
{
string literal when callbackPaths is null => builder.WithRedirectUris(literal),
IResourceBuilder<ParameterResource> parameter when callbackPaths is null => builder.WithRedirectUris(parameter),
IResourceBuilder<ParameterResource> host when callbackPaths is { Length: > 0 } => builder.WithRedirectUris(host, callbackPaths[0], callbackPaths[1..]),
EndpointReference endpoint when callbackPaths is not null => builder.WithRedirectUris(endpoint, callbackPaths),
string => throw new ArgumentException("Callback paths require an endpoint or a host parameter, not a literal URI.", nameof(callbackPaths)),
IResourceBuilder<ParameterResource> or EndpointReference => throw new ArgumentException("At least one callback path is required for a host or endpoint.", nameof(callbackPaths)),
_ => throw new ArgumentException("Expected a literal URI, a parameter resource, or an endpoint.", nameof(redirectUri))
};
}
/// <summary>
/// Adds explicit owners to the application registration created by Aspire.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="ownerObjectIds">The object IDs (GUIDs) of the Microsoft Entra users or service principals to add as owners.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="ownerObjectIds"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when an owner ID is not a GUID.</exception>
/// <remarks>
/// <para>
/// Owners can manage the application registration, so grant ownership deliberately. By default, Aspire sets no owners
/// and Microsoft Entra's default ownership behavior applies. The owners are not necessarily the identity that runs the deployment.
/// </para>
/// <para>
/// Owners are added with append semantics: existing owners are never removed. The deploying identity needs permission to
/// manage application owners (for example the <c>Application.ReadWrite.All</c> Microsoft Graph permission or a directory
/// role such as Application Administrator).
/// </para>
/// <para>
/// Application and service principal ownership are separate; use <see cref="WithServicePrincipalOwners(IResourceBuilder{EntraIdApplicationResource}, string[])"/>
/// for the enterprise application. This setting only affects registrations created by Aspire; existing registrations are not modified.
/// </para>
/// </remarks>
/// <example>
/// <code lang="csharp">
/// var entraApi = builder.AddEntraIdApplication("entra-api")
/// .WithApplicationOwners("00000000-0000-0000-0000-000000000001");
/// </code>
/// </example>
[AspireExportIgnore(Reason = "The withApplicationOwners export accepts literal or parameter owner IDs.")]
public static IResourceBuilder<EntraIdApplicationResource> WithApplicationOwners(
this IResourceBuilder<EntraIdApplicationResource> builder,
params string[] ownerObjectIds)
{
ArgumentNullException.ThrowIfNull(builder);
AddOwners(builder.Resource.ApplicationOwners, ownerObjectIds, nameof(ownerObjectIds));
return builder;
}
/// <summary>
/// Adds explicit owners, supplied by parameters, to the application registration created by Aspire.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="ownerObjectIds">Parameters whose values are the object IDs of the users or service principals to add as owners.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="ownerObjectIds"/> is null.</exception>
/// <remarks>
/// See <see cref="WithApplicationOwners(IResourceBuilder{EntraIdApplicationResource}, string[])"/> for permission and append semantics.
/// </remarks>
[AspireExportIgnore(Reason = "The withApplicationOwners export accepts literal or parameter owner IDs.")]
public static IResourceBuilder<EntraIdApplicationResource> WithApplicationOwners(
this IResourceBuilder<EntraIdApplicationResource> builder,
params IResourceBuilder<ParameterResource>[] ownerObjectIds)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(ownerObjectIds);
builder.Resource.ApplicationOwners.AddRange(ownerObjectIds.Select(p => (object)p.Resource));
return builder;
}
/// <summary>
/// Appends an application owner supplied as a literal directory object GUID or a parameter.
/// </summary>
/// <param name="builder">The registration builder.</param>
/// <param name="ownerObjectId">The directory object ID of a user or service principal.</param>
/// <returns>The registration builder.</returns>
/// <remarks>Call repeatedly for multiple owners. Existing owners are not removed and existing registrations are not modified.</remarks>
[AspireExport("withApplicationOwners")]
internal static IResourceBuilder<EntraIdApplicationResource> WithApplicationOwnersForExport(
this IResourceBuilder<EntraIdApplicationResource> builder,
[AspireUnion(typeof(string), typeof(IResourceBuilder<ParameterResource>))] object ownerObjectId)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(ownerObjectId);
return ownerObjectId switch
{
string literal => builder.WithApplicationOwners(literal),
IResourceBuilder<ParameterResource> parameter => builder.WithApplicationOwners(parameter),
_ => throw new ArgumentException("Expected a directory object GUID or a parameter resource.", nameof(ownerObjectId))
};
}
/// <summary>
/// Adds explicit owners to the service principal (enterprise application) created by Aspire.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="ownerObjectIds">The object IDs (GUIDs) of the Microsoft Entra users or service principals to add as owners.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="ownerObjectIds"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when an owner ID is not a GUID.</exception>
/// <remarks>
/// Service principal ownership is independent of application registration ownership and has the same
/// append semantics and permission requirements as <see cref="WithApplicationOwners(IResourceBuilder{EntraIdApplicationResource}, string[])"/>.
/// This setting only affects registrations created by Aspire.
/// </remarks>
[AspireExportIgnore(Reason = "The withServicePrincipalOwners export accepts literal or parameter owner IDs.")]
public static IResourceBuilder<EntraIdApplicationResource> WithServicePrincipalOwners(
this IResourceBuilder<EntraIdApplicationResource> builder,
params string[] ownerObjectIds)
{
ArgumentNullException.ThrowIfNull(builder);
AddOwners(builder.Resource.ServicePrincipalOwners, ownerObjectIds, nameof(ownerObjectIds));
return builder;
}
/// <summary>
/// Adds explicit owners, supplied by parameters, to the service principal (enterprise application) created by Aspire.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="ownerObjectIds">Parameters whose values are the object IDs of the users or service principals to add as owners.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="ownerObjectIds"/> is null.</exception>
[AspireExportIgnore(Reason = "The withServicePrincipalOwners export accepts literal or parameter owner IDs.")]
public static IResourceBuilder<EntraIdApplicationResource> WithServicePrincipalOwners(
this IResourceBuilder<EntraIdApplicationResource> builder,
params IResourceBuilder<ParameterResource>[] ownerObjectIds)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(ownerObjectIds);
builder.Resource.ServicePrincipalOwners.AddRange(ownerObjectIds.Select(p => (object)p.Resource));
return builder;
}
/// <summary>
/// Appends a service principal owner supplied as a literal directory object GUID or a parameter.
/// </summary>
/// <param name="builder">The registration builder.</param>
/// <param name="ownerObjectId">The directory object ID of a user or service principal.</param>
/// <returns>The registration builder.</returns>
/// <remarks>Service principal ownership is independent of application ownership. Existing registrations are not modified.</remarks>
[AspireExport("withServicePrincipalOwners")]
internal static IResourceBuilder<EntraIdApplicationResource> WithServicePrincipalOwnersForExport(
this IResourceBuilder<EntraIdApplicationResource> builder,
[AspireUnion(typeof(string), typeof(IResourceBuilder<ParameterResource>))] object ownerObjectId)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(ownerObjectId);
return ownerObjectId switch
{
string literal => builder.WithServicePrincipalOwners(literal),
IResourceBuilder<ParameterResource> parameter => builder.WithServicePrincipalOwners(parameter),
_ => throw new ArgumentException("Expected a directory object GUID or a parameter resource.", nameof(ownerObjectId))
};
}
/// <summary>
/// Adds explicit owners to both the application registration and the service principal created by Aspire.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="ownerObjectIds">The object IDs (GUIDs) of the Microsoft Entra users or service principals to add as owners.</param>
/// <returns>The resource builder for chaining.</returns>
/// <remarks>
/// Equivalent to calling <see cref="WithApplicationOwners(IResourceBuilder{EntraIdApplicationResource}, string[])"/> and
/// <see cref="WithServicePrincipalOwners(IResourceBuilder{EntraIdApplicationResource}, string[])"/> with the same IDs.
/// </remarks>
[AspireExportIgnore(Reason = "The withOwners export accepts literal or parameter owner IDs.")]
public static IResourceBuilder<EntraIdApplicationResource> WithOwners(
this IResourceBuilder<EntraIdApplicationResource> builder,
params string[] ownerObjectIds)
{
return builder.WithApplicationOwners(ownerObjectIds).WithServicePrincipalOwners(ownerObjectIds);
}
/// <summary>
/// Adds explicit owners, supplied by parameters, to both the application registration and the service principal created by Aspire.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="ownerObjectIds">Parameters whose values are the object IDs of the users or service principals to add as owners.</param>
/// <returns>The resource builder for chaining.</returns>
[AspireExportIgnore(Reason = "The withOwners export accepts literal or parameter owner IDs.")]
public static IResourceBuilder<EntraIdApplicationResource> WithOwners(
this IResourceBuilder<EntraIdApplicationResource> builder,
params IResourceBuilder<ParameterResource>[] ownerObjectIds)
{
return builder.WithApplicationOwners(ownerObjectIds).WithServicePrincipalOwners(ownerObjectIds);
}
/// <summary>
/// Appends an owner to both the application registration and service principal.
/// </summary>
/// <param name="builder">The registration builder.</param>
/// <param name="ownerObjectId">The directory object GUID of a user or service principal, as a literal or parameter.</param>
/// <returns>The registration builder.</returns>
/// <remarks>Owners can manage the registration. Call repeatedly for multiple owners. Existing registrations are not modified.</remarks>
[AspireExport("withOwners")]
internal static IResourceBuilder<EntraIdApplicationResource> WithOwnersForExport(
this IResourceBuilder<EntraIdApplicationResource> builder,
[AspireUnion(typeof(string), typeof(IResourceBuilder<ParameterResource>))] object ownerObjectId)
{
return builder.WithApplicationOwnersForExport(ownerObjectId).WithServicePrincipalOwnersForExport(ownerObjectId);
}
private static void AddOwners(List<object> target, string[] ownerObjectIds, string paramName)
{
ArgumentNullException.ThrowIfNull(ownerObjectIds, paramName);
// Normalize to the canonical lowercase "D" format first: Guid.TryParse also accepts compact ("N") and
// braced ("B") spellings, which Graph would treat as the same owner.
var normalized = new List<string>(ownerObjectIds.Length);
foreach (var id in ownerObjectIds)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id, paramName);
if (!Guid.TryParse(id, out var guid))
{
throw new ArgumentException($"'{id}' is not a valid directory object ID. Owner IDs must be GUIDs.", paramName);
}
normalized.Add(guid.ToString("D"));
}
// Duplicate owner IDs would be rejected by Microsoft Graph, so skip ones that are already present.
foreach (var id in normalized)
{
if (!target.Any(o => o is string s && string.Equals(s, id, StringComparison.Ordinal)))
{
target.Add(id);
}
}
}
/// <summary>
/// Declares an application role on the registration created by Aspire.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="role">The application role to declare.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> or <paramref name="role"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when a role with the same value or ID is already declared.</exception>
/// <exception cref="InvalidOperationException">Thrown when the resource references an existing registration.</exception>
/// <remarks>
/// <para>
/// Application roles are different from delegated scopes and from Azure RBAC roles. This only declares the role;
/// it does not assign it to any user, group, or application, and it does not grant consent.
/// </para>
/// <para>
/// Role IDs stay the same across deployments. Changing a role's value, or its ID, creates a different role in
/// the directory; assignments of the previous role are not migrated.
/// </para>
/// <para>
/// Whenever at least one role is declared, the complete <c>appRoles</c> collection is sent and Microsoft Graph replaces what is
/// stored. Include every role to keep, including roles that were added outside the AppHost: a role that is left out fails the
/// deployment if it is still enabled and is deleted if it is already disabled.
/// </para>
/// <para>
/// Existing registrations are never modified, so this method cannot be used with <c>AddExistingEntraApplication</c>.
/// </para>
/// </remarks>
/// <example>
/// <code lang="csharp">
/// var entraApi = builder.AddEntraIdApplication("entra-api")
/// .WithAppRole(new EntraIdAppRole("User", "User", "Can use the API."))
/// .WithAppRole(new EntraIdAppRole("Admin", "Administrator", "Can administer the API.", ["User", "Application"]));
/// </code>
/// </example>
[AspireExportIgnore(Reason = "EntraIdAppRole is a C# type with a constructor. The withAppRole export takes its values as arguments.")]
public static IResourceBuilder<EntraIdApplicationResource> WithAppRole(
this IResourceBuilder<EntraIdApplicationResource> builder,
EntraIdAppRole role)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(role);
if (builder.Resource.IsExistingApplication)
{
throw new InvalidOperationException("Application roles can only be declared on registrations created by Aspire. Existing registrations are not modified.");
}
var roles = builder.Resource.AppRoles;
if (roles.Any(r => string.Equals(r.Value, role.Value, StringComparison.Ordinal)))
{
throw new ArgumentException($"An application role with the value '{role.Value}' is already declared.", nameof(role));
}
var id = role.GetStableId(builder.Resource.Name);
if (roles.Any(r => r.GetStableId(builder.Resource.Name) == id))
{
throw new ArgumentException($"The application role ID '{id}' is already used by another role.", nameof(role));
}
builder.Resource.AddAppRole(role);
return builder;
}
/// <summary>
/// Declares an application role on a registration created by Aspire.
/// </summary>
/// <param name="builder">The registration builder.</param>
/// <param name="value">The value emitted in the roles claim, for example Admin. It must be unique for the application, at most 120 characters, must not start with a period, and may contain only ASCII letters, digits and the punctuation that Microsoft Graph allows.</param>
/// <param name="displayName">The display name shown in the Microsoft Entra admin center.</param>
/// <param name="description">The description shown in assignment and consent experiences.</param>
/// <param name="allowedMemberTypes">The member types the role can be assigned to, passed through to Microsoft Graph (for example User or Application). Defaults to User.</param>
/// <param name="id">An optional role GUID. When omitted, a stable ID is derived from the registration's resource name and value.</param>
/// <param name="isEnabled">Whether the role is enabled. Defaults to true. To retire a role, deploy it with false and leave it declared; disabling is not access revocation because existing assignments still receive the role in tokens.</param>
/// <returns>The registration builder.</returns>
/// <remarks>
/// Application roles are separate from delegated scopes and from Azure RBAC roles. This only declares the role; it does not assign it or grant consent.
/// Role IDs stay the same across deployments, and changing a role's value or ID creates a different role. Whenever at least one role is declared,
/// the complete appRoles collection is sent, so include every role to keep. Microsoft Graph rejects deleting an enabled role.
/// Existing registrations are never modified, so this throws for registrations created with addExistingEntraApplication.
/// </remarks>
[AspireExport("withAppRole")]
internal static IResourceBuilder<EntraIdApplicationResource> WithAppRoleForExport(
this IResourceBuilder<EntraIdApplicationResource> builder,
string value,
string displayName,
string description,
string[]? allowedMemberTypes = null,
string? id = null,
bool? isEnabled = null)
{
ArgumentNullException.ThrowIfNull(builder);
Guid? roleId = null;
if (id is not null)
{
roleId = Guid.TryParse(id, out var parsed)
? parsed
: throw new ArgumentException($"'{id}' is not a valid role ID. Role IDs must be GUIDs.", nameof(id));
}
return builder.WithAppRole(new EntraIdAppRole(value, displayName, description, allowedMemberTypes, roleId, isEnabled ?? true));
}
/// <summary>
/// Enables ID-token issuance through implicit and hybrid flows for a registration created by Aspire.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> is null.</exception>
/// <remarks>
/// <para>
/// ID-token issuance through implicit and hybrid flows is disabled by default. Enable it when the application's
/// OpenID Connect sign-in flow requests an ID token directly from the authorization endpoint. This does not enable
/// implicit access-token issuance and is not required for authorization-code-only flows.
/// For new production applications, prefer authorization code flow with PKCE.
/// </para>
/// <para>
/// This setting only affects registrations created by Aspire. Existing registrations are not modified;
/// enable "ID tokens (used for implicit and hybrid flows)" in their Authentication settings instead.
/// </para>
/// <example>
/// <code lang="csharp">
/// var entraWeb = builder.AddEntraIdApplication("entra-web")
/// .WithIdTokenIssuance()
/// .WithRedirectUris("https://localhost:7123/signin-oidc");
/// </code>
/// </example>
/// </remarks>
/// <ats-remarks>
/// Enables ID-token issuance only, not implicit access-token issuance. Not needed for authorization-code-only flows;
/// prefer authorization code flow with PKCE for new production applications.
/// Only affects registrations created by Aspire. Existing registrations must be configured separately.
/// </ats-remarks>
[AspireExport]
public static IResourceBuilder<EntraIdApplicationResource> WithIdTokenIssuance(
this IResourceBuilder<EntraIdApplicationResource> builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Resource.EnableIdTokenIssuance = true;
return builder;
}
/// <summary>
/// Adds a client capability (e.g., <c>"cp1"</c> for Continuous Access Evaluation).
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="capability">The capability identifier.</param>
/// <returns>The resource builder for chaining.</returns>
[AspireExport]
public static IResourceBuilder<EntraIdApplicationResource> WithClientCapability(
this IResourceBuilder<EntraIdApplicationResource> builder,
string capability)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(capability);
builder.Resource.ClientCapabilities.Add(capability);
return builder;
}
/// <summary>
/// Enables ACL-based authorization for daemon-to-API scenarios.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <returns>The resource builder for chaining.</returns>
[AspireExport]
public static IResourceBuilder<EntraIdApplicationResource> WithAllowWebApiToBeAuthorizedByACL(
this IResourceBuilder<EntraIdApplicationResource> builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Resource.AllowWebApiToBeAuthorizedByACL = true;
return builder;
}
/// <summary>
/// Sends the <c>x5c</c> claim (the public key of the certificate) with token requests.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <returns>The resource builder for chaining.</returns>
/// <remarks>
/// Sending <c>x5c</c> enables certificate rollover without redeploying the application. It only applies
/// to certificate-based credentials and increases the size of each token request, so it is opt-in.
/// </remarks>
[AspireExport]
public static IResourceBuilder<EntraIdApplicationResource> WithSendX5C(
this IResourceBuilder<EntraIdApplicationResource> builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Resource.SendX5C = true;
return builder;
}
/// <summary>
/// Adds an extra query parameter to send to the identity provider.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="key">The query parameter key.</param>
/// <param name="value">The query parameter value.</param>
/// <returns>The resource builder for chaining.</returns>
[AspireExport]
public static IResourceBuilder<EntraIdApplicationResource> WithExtraQueryParameter(
this IResourceBuilder<EntraIdApplicationResource> builder,
string key,
string value)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(key);
ArgumentNullException.ThrowIfNull(value);
builder.Resource.ExtraQueryParameters[key] = value;
return builder;
}
/// <summary>
/// Adds an accepted audience for this application.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="audience">The audience value (e.g., <c>api://<client-id></c>).</param>
/// <returns>The resource builder for chaining.</returns>
[AspireExport]
public static IResourceBuilder<EntraIdApplicationResource> WithAudience(
this IResourceBuilder<EntraIdApplicationResource> builder,
string audience)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(audience);
builder.Resource.Audiences.Add(audience);
return builder;
}
/// <summary>
/// Exposes the registration as an API identified by the default application ID URI, <c>api://{clientId}</c>.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <returns>The resource builder for chaining.</returns>
/// <remarks>
/// <para>
/// The client ID exists only after Microsoft Graph creates the registration, so the URI is set by a second update of the
/// registration in the same deployment. <c>api://{clientId}</c> contains the application ID, a form that the tenant's default
/// identifier URI policy accepts, so it needs no verified domain. Adding a scope with
/// <see cref="WithScope"/> uses this URI unless <see cref="WithApplicationIdUri(IResourceBuilder{EntraIdApplicationResource}, string)"/> sets another one.
/// </para>
/// <para>
/// This setting only affects registrations created by Aspire. For an existing registration, call
/// <see cref="WithApplicationIdUri(IResourceBuilder{EntraIdApplicationResource}, string)"/> with the URI it already has.
/// </para>
/// </remarks>
[AspireExportIgnore(Reason = "The withApplicationIdUri export accepts an optional URI.")]
public static IResourceBuilder<EntraIdApplicationResource> WithApplicationIdUri(
this IResourceBuilder<EntraIdApplicationResource> builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Resource.UsesDefaultApplicationIdUri = true;
// A custom URI would take precedence over the default. For an existing registration the URI is configuration only and stays.
if (!builder.Resource.IsExistingApplication)
{
builder.Resource.ApplicationIdUri = null;
}
return builder;
}
/// <summary>
/// Exposes the registration as an API identified by a custom application ID URI.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="applicationIdUri">The URI, such as <c>api://contoso-orders</c> or <c>https://contoso.onmicrosoft.com/orders</c>.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentException"><paramref name="applicationIdUri"/> is not an absolute URI without a query string or fragment.</exception>
/// <remarks>
/// <para>
/// For a registration created by Aspire, the URI is written to the registration. The URI must be unique in the tenant. A tenant's
/// default policy requires a new URI to contain a verified domain, the tenant ID or the application ID, but it doesn't apply
/// when the registration's <c>requestedAccessTokenVersion</c> is 2, which Aspire always sets, so a name such as <c>api://contoso-orders</c>
/// is normally accepted. A tenant can configure a stricter policy, and a deployment fails when the policy rejects the URI. See
/// <see href="https://aka.ms/identifier-uri-formatting-error"/>.
/// </para>
/// <para>
/// For an existing registration, the URI is only configuration: Aspire does not modify the registration, and
/// <see cref="EntraIdApplicationResource.GetScopeUri(string)"/> uses the URI to build scope names for callers.
/// </para>
/// </remarks>
[AspireExportIgnore(Reason = "The withApplicationIdUri export accepts an optional URI.")]
public static IResourceBuilder<EntraIdApplicationResource> WithApplicationIdUri(
this IResourceBuilder<EntraIdApplicationResource> builder,
string applicationIdUri)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(applicationIdUri);
if (EntraIdValidation.ValidateApplicationIdUri(applicationIdUri) is { } error)
{
throw new ArgumentException($"'{applicationIdUri}' is not a valid application ID URI. {error}", nameof(applicationIdUri));
}
builder.Resource.ApplicationIdUri = applicationIdUri;
builder.Resource.UsesDefaultApplicationIdUri = false;
return builder;
}
/// <summary>
/// Exposes the registration as an API identified by an application ID URI.
/// </summary>
/// <param name="builder">The registration builder.</param>
/// <param name="applicationIdUri">A custom URI, such as api://contoso-orders or https://contoso.onmicrosoft.com/orders. Omit to use the default api://{clientId}.</param>
/// <returns>The registration builder.</returns>
/// <remarks>
/// For a registration created by Aspire, the URI is written to the registration and must be unique in the tenant. The default api://{clientId}
/// form is set by a second update of the registration, after Microsoft Graph creates it, and is accepted by the tenant's default policy.
/// A custom URI fails deployment if the tenant's policy rejects it. For an existing registration, the URI is configuration only: Aspire does not
/// modify the registration, and getScopeUri uses the URI to build scope names for callers.
/// </remarks>
[AspireExport("withApplicationIdUri")]
internal static IResourceBuilder<EntraIdApplicationResource> WithApplicationIdUriForExport(
this IResourceBuilder<EntraIdApplicationResource> builder,
string? applicationIdUri = null)
{
ArgumentNullException.ThrowIfNull(builder);
return applicationIdUri is null
? builder.WithApplicationIdUri()
: builder.WithApplicationIdUri(applicationIdUri);
}
/// <summary>
/// Exposes a delegated permission scope that clients can request to call this registration as an API.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="name">The scope's name, which appears in the token's <c>scp</c> claim, such as <c>access_as_user</c>.</param>
/// <param name="description">What the scope allows, shown on the consent page.</param>
/// <param name="configure">An optional callback that sets the scope's other options.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentException">
/// The name is invalid or already used by another scope, or the explicit <see cref="EntraIdScope.Id"/> is already used by another scope.
/// </exception>
/// <remarks>
/// <para>
/// Scopes are declared explicitly. Aspire does not derive them from the permissions that callers request.
/// Unless an application ID URI is set with <see cref="WithApplicationIdUri(IResourceBuilder{EntraIdApplicationResource}, string)"/>,
/// the registration uses <c>api://{clientId}</c>.
/// </para>
/// <para>
/// The scope's ID stays the same across deployments. Microsoft Graph does not let a deployment remove an enabled scope or change its name.
/// To retire a scope, set <see cref="EntraIdScope.IsEnabled"/> to <see langword="false"/> and deploy, and remove the scope in a later deployment.
/// </para>
/// <para>
/// For an existing registration, the scope is only configuration, which lets callers declare permissions to it.
/// Set <see cref="EntraIdScope.Id"/> to the ID of the scope that the registration defines.
/// </para>
/// <example>
/// <code lang="csharp">
/// var entraApi = builder.AddEntraIdApplication("entra-api")
/// .WithScope("access_as_user", "Allows the app to call the API on behalf of the signed-in user.");
/// </code>
/// </example>
/// </remarks>
[AspireExportIgnore(Reason = "The withScope export takes the scope options as a DTO instead of a callback.")]
public static IResourceBuilder<EntraIdApplicationResource> WithScope(
this IResourceBuilder<EntraIdApplicationResource> builder,
string name,
string description,
Action<EntraIdScope>? configure = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(description);
if (EntraIdValidation.ValidateScopeName(name) is { } error)
{
throw new ArgumentException($"'{name}' is not a valid scope name. {error}", nameof(name));
}
var resource = builder.Resource;
if (resource.Scopes.Any(s => string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase)))
{
throw new ArgumentException($"'{resource.Name}' already exposes a scope named '{name}'.", nameof(name));
}
var scope = new EntraIdScope(name, description);
configure?.Invoke(scope);
if (scope.Id is { } id && resource.Scopes.Any(s => s.Id == id))
{
throw new ArgumentException($"'{resource.Name}' already exposes a scope with the ID '{id:D}'.", nameof(configure));
}
resource.AddScope(scope);
return builder;
}
/// <summary>
/// Exposes a delegated permission scope that clients can request to call this registration as an API.
/// </summary>
/// <param name="builder">The registration builder.</param>
/// <param name="name">The scope's name, which appears in the token's scp claim, such as access_as_user.</param>
/// <param name="description">What the scope allows, shown on the consent page.</param>
/// <param name="options">Optional settings for the scope.</param>
/// <returns>The registration builder.</returns>
/// <remarks>
/// Scopes are declared explicitly. Unless an application ID URI is set with withApplicationIdUri, the registration uses api://{clientId}.
/// The scope's ID stays the same across deployments. Microsoft Graph does not let a deployment remove an enabled scope or change its name:
/// to retire a scope, deploy it with isEnabled set to false and remove it in a later deployment.
/// For an existing registration the scope is configuration only, which lets callers declare permissions to it. Set its id to the ID the registration defines.
/// </remarks>
[AspireExport("withScope")]
internal static IResourceBuilder<EntraIdApplicationResource> WithScopeForExport(
this IResourceBuilder<EntraIdApplicationResource> builder,
string name,
string description,
EntraIdScopeOptions? options = null)
{
ArgumentNullException.ThrowIfNull(builder);
return builder.WithScope(name, description, scope =>
{
if (options is null)
{
return;
}
if (options.DisplayName is not null)
{
scope.DisplayName = options.DisplayName;
}
scope.UserConsentDisplayName = options.UserConsentDisplayName;
scope.UserConsentDescription = options.UserConsentDescription;
scope.RequiresAdminConsent = options.RequiresAdminConsent ?? false;
scope.IsEnabled = options.IsEnabled ?? true;
if (options.Id is not null)
{
scope.Id = Guid.TryParse(options.Id, out var id)
? id
: throw new ArgumentException($"'{options.Id}' is not a valid scope ID. Scope IDs must be GUIDs.", nameof(options));
}
});
}
/// <summary>
/// Gets an expression for the full URI of one of this registration's scopes, such as api://{clientId}/access_as_user.
/// </summary>
/// <param name="builder">The registration builder.</param>
/// <param name="scopeName">The name of a scope added with withScope.</param>
/// <returns>An expression that resolves when the registration's application ID URI is known.</returns>
/// <remarks>
/// Clients request the full URI as the token scope. Throws when the registration has no such scope, or is an existing registration without an application ID URI.
/// </remarks>
[AspireExport("getScopeUri")]
internal static ReferenceExpression GetScopeUriForExport(
this IResourceBuilder<EntraIdApplicationResource> builder,
string scopeName)
{
ArgumentNullException.ThrowIfNull(builder);
return builder.Resource.GetScopeUri(scopeName);
}
/// <summary>
/// Pre-authorizes a client application to request some of this registration's scopes without any consent prompt.
/// </summary>
/// <param name="builder">The registration that exposes the scopes, which must be created by Aspire.</param>
/// <param name="client">The client application to pre-authorize.</param>
/// <param name="scopeNames">The names of the scopes, added with <see cref="WithScope"/>, that the client may request.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentException">A scope is not exposed or is disabled, or no scope was given.</exception>
/// <exception cref="InvalidOperationException">
/// <paramref name="client"/> already declares permissions for this registration, or depends on it in another way. Pre-authorization needs the client's ID
/// and a declaration needs this registration's ID, so each registration would wait for the other.
/// </exception>
/// <remarks>
/// <para>
/// Pre-authorization is separate from the client's declaration of the permission (<see cref="WithApiPermissions(IResourceBuilder{EntraIdApplicationResource}, IResourceBuilder{EntraIdApplicationResource}, string[])"/>)
/// and from consent (<see cref="WithAdminConsent(IResourceBuilder{EntraIdApplicationResource}, IResourceBuilder{EntraIdApplicationResource}, string[])"/>).
/// It lets a trusted client skip the consent prompt, which is intended for first-party clients of the same organization.
/// </para>
/// <para>
/// Aspire deploys each registration as its own module. Pre-authorization makes this registration wait for the client's ID, while
/// the client's declaration waits for this registration's ID, so two registrations cannot use both. Use a declaration with
/// <c>WithAdminConsent</c> on the client, or pre-authorization alone. This setting only affects registrations created by Aspire.
/// </para>
/// </remarks>
[AspireExport]
public static IResourceBuilder<EntraIdApplicationResource> WithPreAuthorizedApplication(
this IResourceBuilder<EntraIdApplicationResource> builder,
IResourceBuilder<EntraIdApplicationResource> client,
params string[] scopeNames)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(scopeNames);
var scopes = FindScopes(builder.Resource, scopeNames, nameof(scopeNames));
EnsureNoDependencyCycle(builder.Resource, client.Resource);
var existing = builder.Resource.PreAuthorizedClients.FirstOrDefault(p => ReferenceEquals(p.Client, client.Resource));
if (existing is not null)
{
builder.Resource.PreAuthorizedClients.Remove(existing);
scopes = [.. existing.Scopes.Union(scopes)];
}
builder.Resource.PreAuthorizedClients.Add(new EntraIdPreAuthorizedClient(client.Resource, scopes));
return builder;
}
/// <summary>
/// Declares that this registration requires delegated permissions to scopes of another registration in the AppHost.
/// </summary>
/// <param name="builder">The calling registration, which must be created by Aspire.</param>
/// <param name="api">The registration that exposes the scopes.</param>
/// <param name="scopeNames">The names of the scopes, added with <see cref="WithScope"/> to <paramref name="api"/>.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentException">A scope is not exposed or is disabled, or no scope was given.</exception>
/// <exception cref="InvalidOperationException">
/// <paramref name="api"/> already pre-authorizes this registration, or depends on it in another way. See
/// <see cref="WithPreAuthorizedApplication"/>.
/// </exception>
/// <remarks>
/// <para>
/// This adds the scopes to the registration's <em>API permissions</em>, the list shown in the portal. It grants nothing:
/// users are prompted to consent the first time they sign in unless an administrator consents
/// (<see cref="WithAdminConsent(IResourceBuilder{EntraIdApplicationResource}, IResourceBuilder{EntraIdApplicationResource}, string[])"/>)
/// or <paramref name="api"/> pre-authorizes the caller (<see cref="WithPreAuthorizedApplication"/>).
/// </para>
/// <para>
/// A token request can cover the scopes of only one API. Request the scopes of each API in a separate token request.
/// </para>
/// <example>
/// <code lang="csharp">
/// var entraApi = builder.AddEntraIdApplication("entra-api")
/// .WithScope("access_as_user", "Allows the app to call the API on behalf of the signed-in user.");
///
/// var entraWeb = builder.AddEntraIdApplication("entra-web")
/// .WithApiPermissions(entraApi, "access_as_user");
/// </code>
/// </example>
/// </remarks>
[AspireExport]
public static IResourceBuilder<EntraIdApplicationResource> WithApiPermissions(
this IResourceBuilder<EntraIdApplicationResource> builder,
IResourceBuilder<EntraIdApplicationResource> api,
params string[] scopeNames)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(api);
ArgumentNullException.ThrowIfNull(scopeNames);
var apiResource = api.Resource;
if (ReferenceEquals(apiResource, builder.Resource))
{
throw new ArgumentException("A registration cannot require permissions to its own scopes.", nameof(api));
}
var scopes = FindScopes(apiResource, scopeNames, nameof(scopeNames));
EnsureNoDependencyCycle(builder.Resource, apiResource);
var resolved = scopes.Select(s => EntraIdResolvedPermission.ForScope(apiResource, s)).ToArray();
var access = GetOrAddApiAccess(builder.Resource, a => a.IsFor(apiResource), () => EntraIdApiAccess.ForApplication(apiResource));
foreach (var permission in resolved)
{
AddDistinct(access.Declared, permission);
}
return builder;
}
/// <summary>
/// Declares that this registration requires permissions to an API that is not an Aspire resource, such as Microsoft Graph.
/// </summary>
/// <param name="builder">The calling registration, which must be created by Aspire.</param>
/// <param name="api">The API, for example <see cref="EntraIdApi.MicrosoftGraph"/>.</param>
/// <param name="permissions">The permissions to require.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentException">A permission is unknown for the API and has no ID, or no permission was given.</exception>
/// <remarks>
/// <para>
/// This adds the permissions to the registration's <em>API permissions</em>, the list shown in the portal. It grants nothing, and it does
/// not grant tenant-wide consent as a side effect. Use
/// <see cref="WithAdminConsent(IResourceBuilder{EntraIdApplicationResource}, EntraIdApi, EntraIdApiPermission[])"/> to opt in to consent.
/// </para>
/// <para>
/// Delegated permissions (<see cref="EntraIdApiPermission.Delegated(string)"/>) let the application act for a signed-in user.
/// Application permissions (<see cref="EntraIdApiPermission.Application(string)"/>) let it act as itself and always need an administrator's
/// consent. The two kinds are separate permissions, even when they have the same name.
/// </para>
/// <example>
/// <code lang="csharp">
/// var entraWeb = builder.AddEntraIdApplication("entra-web")
/// .WithApiPermissions(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Delegated("User.Read"));
/// </code>
/// </example>
/// </remarks>
[AspireExportIgnore(Reason = "EntraIdApi and EntraIdApiPermission are C# types. The withExternalApiPermissions export takes DTOs.")]
public static IResourceBuilder<EntraIdApplicationResource> WithApiPermissions(
this IResourceBuilder<EntraIdApplicationResource> builder,
EntraIdApi api,
params EntraIdApiPermission[] permissions)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(api);
ArgumentNullException.ThrowIfNull(permissions);
if (permissions.Length == 0)
{
throw new ArgumentException("Specify at least one permission.", nameof(permissions));
}
var resolved = permissions.Select(api.Resolve).ToArray();
var access = GetOrAddApiAccess(builder.Resource, a => a.IsFor(api), () => EntraIdApiAccess.ForApi(api));
foreach (var permission in resolved)
{
AddDistinct(access.Declared, permission);
}
return builder;
}
/// <summary>
/// Declares permissions to a Microsoft first-party or other API that is not an Aspire resource, such as Microsoft Graph.
/// </summary>
/// <param name="builder">The calling registration.</param>
/// <param name="api">A well-known API, or an API identified by its application ID and application ID URI.</param>
/// <param name="permissions">The permissions to declare. Each is delegated unless its type is Application.</param>
/// <returns>The registration builder.</returns>
/// <remarks>
/// For a registration created by Aspire, this adds the permissions to the registration's API permissions list. It grants nothing;
/// use withExternalApiAdminConsent to opt in to consent. For an existing registration, nothing is deployed: the declaration only
/// configures the DownstreamApis entries that resources referencing the registration receive.
/// Delegated permissions let the application act for a signed-in user. Application permissions let it act as itself and always need administrator consent.
/// The two kinds are separate permissions, even when they share a name. Well-known APIs resolve common permission names to their IDs;
/// other permissions, and every permission of a custom API, need an explicit id.
/// </remarks>
[AspireExport("withExternalApiPermissions")]
internal static IResourceBuilder<EntraIdApplicationResource> WithExternalApiPermissionsForExport(
this IResourceBuilder<EntraIdApplicationResource> builder,
[AspireUnion(typeof(EntraIdWellKnownApi), typeof(EntraIdCustomApi))] object api,
EntraIdExternalApiPermission[] permissions)
{
ArgumentNullException.ThrowIfNull(builder);
return builder.WithApiPermissions(ResolveApi(api), ResolvePermissions(permissions));
}
/// <summary>
/// Grants tenant-wide consent to delegated permissions that this registration declared for another registration in the AppHost.
/// </summary>
/// <param name="builder">The calling registration, which must be created by Aspire.</param>
/// <param name="api">The registration that exposes the scopes.</param>
/// <param name="scopeNames">The names of scopes already declared with <see cref="WithApiPermissions(IResourceBuilder{EntraIdApplicationResource}, IResourceBuilder{EntraIdApplicationResource}, string[])"/>.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="ArgumentException">A scope is not exposed or is disabled, or no scope was given.</exception>
/// <exception cref="InvalidOperationException">A scope was not declared first.</exception>
/// <remarks>
/// <para>
/// This creates a delegated permission grant for all users (<c>consentType: AllPrincipals</c>) from this registration to
/// <paramref name="api"/>, so nobody is prompted to consent. The identity that deploys needs a directory role such as
/// Privileged Role Administrator, Cloud Application Administrator or Application Administrator, or a privileged
/// Microsoft Graph permission such as <c>DelegatedPermissionGrant.ReadWrite.All</c>. Deployment fails without it.
/// See <see href="https://learn.microsoft.com/entra/identity/enterprise-apps/grant-admin-consent#prerequisites"/>.
/// </para>
/// <para>
/// A grant is neither a declaration nor a pre-authorization. This only affects registrations created by Aspire.
/// </para>
/// <para>
/// The grant is declared in full: it is set to exactly the scopes passed to <c>WithAdminConsent</c> for this registration and
/// <paramref name="api"/>. A scope that was granted to the pair outside Aspire, or that is removed from the calls while others remain,
/// is revoked by the next deployment. When no call remains for the pair, nothing is sent and the existing grant stays.
/// </para>
/// </remarks>
[AspireExport]
public static IResourceBuilder<EntraIdApplicationResource> WithAdminConsent(
this IResourceBuilder<EntraIdApplicationResource> builder,
IResourceBuilder<EntraIdApplicationResource> api,
params string[] scopeNames)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(api);
ArgumentNullException.ThrowIfNull(scopeNames);
var apiResource = api.Resource;
var scopes = FindScopes(apiResource, scopeNames, nameof(scopeNames));
var access = builder.Resource.ApiAccess.FirstOrDefault(a => a.IsFor(apiResource));
foreach (var scope in scopes)
{
ConsentToDeclared(access, apiResource.Name, EntraIdResolvedPermission.ForScope(apiResource, scope));
}
return builder;
}
/// <summary>
/// Grants tenant-wide consent to permissions that this registration declared for an API that is not an Aspire resource.
/// </summary>
/// <param name="builder">The calling registration, which must be created by Aspire.</param>
/// <param name="api">The API, for example <see cref="EntraIdApi.MicrosoftGraph"/>.</param>
/// <param name="permissions">Permissions already declared with <see cref="WithApiPermissions(IResourceBuilder{EntraIdApplicationResource}, EntraIdApi, EntraIdApiPermission[])"/>.</param>
/// <returns>The resource builder for chaining.</returns>
/// <exception cref="InvalidOperationException">A permission was not declared first.</exception>
/// <remarks>
/// <para>
/// A delegated permission becomes a delegated permission grant for all users (<c>consentType: AllPrincipals</c>).
/// An application permission becomes an app role assignment to this registration's service principal, so the application can
/// call the API as itself without a user.
/// </para>
/// <para>
/// The delegated grant is declared in full: it is set to exactly the delegated permissions passed to <c>WithAdminConsent</c> for this
/// registration and <paramref name="api"/>. A permission that was granted to the pair outside Aspire, or that is removed from the calls while
/// others remain, is revoked by the next deployment. When no delegated call remains for the pair, nothing is sent and the existing grant stays.
/// Each application permission is a separate app role assignment: Aspire creates it, but doesn't delete it when its consent is removed
/// from the AppHost, so remove it in the Microsoft Entra admin center.
/// </para>
/// <para>
/// The identity that deploys needs one of the following, and deployment fails without it. For delegated permissions, a directory role such as
/// Privileged Role Administrator, Cloud Application Administrator or Application Administrator, or the
/// <c>DelegatedPermissionGrant.ReadWrite.All</c> Microsoft Graph permission. For Microsoft Graph application permissions, Cloud Application
/// Administrator and Application Administrator are not enough: use Privileged Role Administrator or a custom role, or the
/// <c>AppRoleAssignment.ReadWrite.All</c> Microsoft Graph permission when deploying as an application. See
/// <see href="https://learn.microsoft.com/entra/identity/enterprise-apps/grant-admin-consent#prerequisites"/>. Application permissions to broad
/// scopes such as <c>Directory.Read.All</c> give the application access to the whole tenant, so grant only what the application needs.
/// This only affects registrations created by Aspire.
/// </para>
/// <example>
/// <code lang="csharp">
/// var entraWeb = builder.AddEntraIdApplication("entra-web")
/// .WithApiPermissions(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Application("User.Read.All"))
/// .WithAdminConsent(EntraIdApi.MicrosoftGraph, EntraIdApiPermission.Application("User.Read.All"));
/// </code>
/// </example>
/// </remarks>
[AspireExportIgnore(Reason = "EntraIdApi and EntraIdApiPermission are C# types. The withExternalApiAdminConsent export takes DTOs.")]
public static IResourceBuilder<EntraIdApplicationResource> WithAdminConsent(
this IResourceBuilder<EntraIdApplicationResource> builder,
EntraIdApi api,
params EntraIdApiPermission[] permissions)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(api);
ArgumentNullException.ThrowIfNull(permissions);
if (permissions.Length == 0)
{
throw new ArgumentException("Specify at least one permission.", nameof(permissions));
}
var resolved = permissions.Select(api.Resolve).ToArray();
var access = builder.Resource.ApiAccess.FirstOrDefault(a => a.IsFor(api));
foreach (var permission in resolved)
{
ConsentToDeclared(access, api.DisplayName, permission);
}
return builder;
}
/// <summary>
/// Grants tenant-wide consent to permissions that this registration declared for an API that is not an Aspire resource.
/// </summary>
/// <param name="builder">The calling registration.</param>
/// <param name="api">A well-known API, or an API identified by its application ID and application ID URI.</param>
/// <param name="permissions">Permissions already declared with withExternalApiPermissions.</param>
/// <returns>The registration builder.</returns>
/// <remarks>
/// A delegated permission becomes a delegated permission grant for all users. An application permission becomes an app role assignment
/// to this registration's service principal. The deploying identity needs a directory role or Microsoft Graph permission that allows consent, and deployment fails without it.
/// Application permissions to broad scopes such as Directory.Read.All give the application access to the whole tenant, so grant only what the application needs.
/// Throws when a permission was not declared first. This only affects registrations created by Aspire; consent is not deployed for an existing registration.
/// Consent is not additive. The delegated grant is declared in full: it is set to exactly the delegated permissions passed to withExternalApiAdminConsent
/// for this registration and API, so a permission granted to the pair outside Aspire, or removed from the calls while others remain, is revoked by the next deployment.
/// When no delegated call remains for the pair, nothing is sent and the existing grant stays. Each application permission is a separate app role assignment:
/// Aspire creates it but does not delete it when its consent is removed from the AppHost, so remove it in the Microsoft Entra admin center.
/// </remarks>
[AspireExport("withExternalApiAdminConsent")]
internal static IResourceBuilder<EntraIdApplicationResource> WithExternalApiAdminConsentForExport(
this IResourceBuilder<EntraIdApplicationResource> builder,
[AspireUnion(typeof(EntraIdWellKnownApi), typeof(EntraIdCustomApi))] object api,
EntraIdExternalApiPermission[] permissions)
{
ArgumentNullException.ThrowIfNull(builder);
return builder.WithAdminConsent(ResolveApi(api), ResolvePermissions(permissions));
}
private static EntraIdApi ResolveApi(object api)
{
ArgumentNullException.ThrowIfNull(api);
return api switch
{
EntraIdWellKnownApi.MicrosoftGraph => EntraIdApi.MicrosoftGraph,
EntraIdWellKnownApi.AzureResourceManager => EntraIdApi.AzureResourceManager,
EntraIdWellKnownApi.AzureStorage => EntraIdApi.AzureStorage,
EntraIdCustomApi custom => EntraIdApi.FromApplicationId(custom.DisplayName, custom.ApplicationId, custom.ResourceUri, custom.BaseUrl),
_ => throw new ArgumentException("Expected a well-known API or a custom API.", nameof(api))
};
}
private static EntraIdApiPermission[] ResolvePermissions(EntraIdExternalApiPermission[] permissions)
{
ArgumentNullException.ThrowIfNull(permissions);
return [.. permissions.Select(ResolvePermission)];
static EntraIdApiPermission ResolvePermission(EntraIdExternalApiPermission permission)
{
ArgumentNullException.ThrowIfNull(permission);
Guid? id = null;
if (permission.Id is not null)
{
id = Guid.TryParse(permission.Id, out var parsed)
? parsed
: throw new ArgumentException($"'{permission.Id}' is not a valid permission ID. Permission IDs must be GUIDs.", nameof(permissions));
}
// The ATS DTO deserializer accepts numeric enum values, so an undefined value must be rejected rather than
// treated as an application permission, which would request app-only access.
return (permission.Type ?? EntraIdPermissionType.Delegated, id) switch
{
(EntraIdPermissionType.Delegated, null) => EntraIdApiPermission.Delegated(permission.Name),
(EntraIdPermissionType.Delegated, { } permissionId) => EntraIdApiPermission.Delegated(permission.Name, permissionId),
(EntraIdPermissionType.Application, null) => EntraIdApiPermission.Application(permission.Name),
(EntraIdPermissionType.Application, { } permissionId) => EntraIdApiPermission.Application(permission.Name, permissionId),
_ => throw new ArgumentOutOfRangeException(nameof(permissions), permission.Type, $"'{permission.Type}' is not a defined {nameof(EntraIdPermissionType)} value.")
};
}
}
private static EntraIdScope[] FindScopes(EntraIdApplicationResource api, string[] scopeNames, string parameterName)
{
if (scopeNames.Length == 0)
{
throw new ArgumentException("Specify at least one scope.", parameterName);
}
var scopes = new List<EntraIdScope>(scopeNames.Length);
foreach (var name in scopeNames)
{
ArgumentException.ThrowIfNullOrEmpty(name, parameterName);
var scope = api.Scopes.FirstOrDefault(s => string.Equals(s.Name, name, StringComparison.Ordinal))
?? throw new ArgumentException(
$"'{api.Name}' does not expose a scope named '{name}'. Add it with WithScope first. " +
$"Exposed scopes: {(api.Scopes.Count == 0 ? "none" : string.Join(", ", api.Scopes.Select(s => s.Name)))}.",
parameterName);
if (!scope.IsEnabled)
{
throw new ArgumentException($"The scope '{name}' of '{api.Name}' is disabled, so clients cannot request it.", parameterName);
}
if (!scopes.Contains(scope))
{
scopes.Add(scope);
}
}
return [.. scopes];
}
private static EntraIdApiAccess GetOrAddApiAccess(
EntraIdApplicationResource caller,
Func<EntraIdApiAccess, bool> matches,
Func<EntraIdApiAccess> create)
{
var access = caller.ApiAccess.FirstOrDefault(matches);
if (access is null)
{
access = create();
caller.ApiAccess.Add(access);
caller.EnsureApiAccessValidation();
}
return access;
}
private static void AddDistinct(List<EntraIdResolvedPermission> permissions, EntraIdResolvedPermission permission)
{
if (!permissions.Contains(permission))
{
permissions.Add(permission);
}
}
private static void ConsentToDeclared(EntraIdApiAccess? access, string apiName, EntraIdResolvedPermission permission)
{
if (access is null || !access.Declared.Contains(permission))
{
throw new InvalidOperationException(
$"The {permission.Type} permission '{permission.Name}' of '{apiName}' must be declared with WithApiPermissions before consent is granted. " +
"A grant for a permission that the registration does not declare shows up as an unconfigured permission in the portal.");
}
AddDistinct(access.Consented, permission);
}
// The client's module needs the API's outputs, and the API's module needs the client's output when it pre-authorizes the client.
// Two modules that wait for each other can never be deployed, so reject the model before it is built.
private static void EnsureNoDependencyCycle(EntraIdApplicationResource dependent, EntraIdApplicationResource dependency)
{
if (ReferenceEquals(dependent, dependency) || (!dependent.IsExistingApplication && DependsOn(dependency, dependent, [])))
{
throw new InvalidOperationException(
$"'{dependent.Name}' cannot depend on '{dependency.Name}' because '{dependency.Name}' already depends on '{dependent.Name}'. " +
"A registration's module needs the other's outputs: declared permissions need the API's client ID, and pre-authorization needs the client's client ID. " +
"Use either WithPreAuthorizedApplication or WithApiPermissions between the two registrations, not both.");
}
static bool DependsOn(EntraIdApplicationResource from, EntraIdApplicationResource target, HashSet<EntraIdApplicationResource> visited)
{
// An existing registration deploys nothing, so it waits for nothing.
if (from.IsExistingApplication || !visited.Add(from))
{
return false;
}
var dependencies = from.ApiAccess.Select(a => a.Application).OfType<EntraIdApplicationResource>()
.Concat(from.PreAuthorizedClients.Select(p => p.Client));
foreach (var dependency in dependencies)
{
if (ReferenceEquals(dependency, target) || DependsOn(dependency, target, visited))
{
return true;
}
}
return false;
}
}
/// <summary>
/// Injects Entra ID authentication configuration into a consuming resource.
/// </summary>
/// <typeparam name="T">The type of the destination resource.</typeparam>
/// <param name="builder">The resource that will receive the authentication configuration.</param>
/// <param name="source">The Entra ID application resource to reference.</param>
/// <param name="connectionName">
/// The environment variable prefix. When <see langword="null"/>, uses <c>"AzureAd"</c> for .NET program resources;
/// otherwise, uses the source resource's name encoded as a portable environment variable name and uppercased.
/// When empty, omits the prefix and its separator, producing <c>ClientId</c> for .NET program resources and <c>CLIENT_ID</c> for other resources.
/// For .NET program resources, <c>:</c> in the name is replaced by <c>__</c>.
/// </param>
/// <param name="downstreamApisPrefix">
/// The configuration prefix of the <c>DownstreamApis</c> entries for the APIs that the application declared permissions to
/// (see <c>WithApiPermissions</c>). When <see langword="null"/>, uses <c>"DownstreamApis"</c>.
/// For .NET program resources, <c>:</c> in the prefix is replaced by <c>__</c>. A .NET program's <c>DownstreamApis</c> section doesn't depend on
/// <paramref name="connectionName"/>, so when a resource references several registrations that declare permissions to the same API,
/// give all but one of them their own prefix, or the model is rejected.
/// </param>
/// <returns>The resource builder for chaining.</returns>
/// <remarks>
/// <para>
/// .NET program resources use <c>__</c> between keys and array indexes; other resources use <c>_</c>.
/// Non-.NET environment variable names, including custom connection names, use uppercase snake case,
/// such as <c>ENTRA_API_CLIENT_ID</c> and <c>ENTRA_API_CLIENT_CREDENTIALS_0_SOURCE_TYPE</c>.
/// Configuration values retain their original casing.
/// The default names, such as <c>AzureAd__TenantId</c> and <c>AzureAd__ClientId</c>, map to the
/// <c>AzureAd</c> configuration section in .NET and are compatible with Microsoft.Identity.Web.
/// </para>
/// <para>
/// The consuming service can then use Microsoft.Identity.Web's standard configuration:
/// </para>
/// <code lang="csharp">
/// builder.Services.AddAuthentication()
/// .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
/// </code>
/// <para>
/// When the application declared permissions to other APIs, the consumer also receives one entry per API under the
/// <paramref name="downstreamApisPrefix"/> prefix: <c>{prefix}__{name}__Scopes__{index}</c>. For an API registration in the AppHost,
/// <c>{name}</c> is the name of each resource that references that registration, like the name used for service discovery.
/// </para>
/// <example>
/// <code lang="csharp">
/// var entraApi = builder.AddExistingEntraApplication("entra-api",
/// tenantId: tenantId,
/// clientId: clientId);
///
/// builder.AddDotnetProject("api", "../Api/Api.csproj")
/// .WithReference(entraApi);
/// </code>
/// </example>
/// </remarks>
[AspireExportIgnore(Reason = "Polyglot AppHosts use the generic withReference dispatcher with an Entra ID application builder.")]
public static IResourceBuilder<T> WithReference<T>(
this IResourceBuilder<T> builder,
IResourceBuilder<EntraIdApplicationResource> source,
string? connectionName = null,
string? downstreamApisPrefix = null)
where T : IResourceWithEnvironment
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(source);
if (downstreamApisPrefix is not null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(downstreamApisPrefix);
}
// Inspect the resource itself so .NET defaults also apply when the builder's type has been erased.
#pragma warning disable ASPIREPROJECTS001
var isDotnetProgram = builder.Resource is IDotnetProgramResource;
#pragma warning restore ASPIREPROJECTS001
connectionName ??= isDotnetProgram ? "AzureAd" : EnvironmentVariableNameEncoder.Encode(source.Resource.Name);
downstreamApisPrefix ??= "DownstreamApis";
if (isDotnetProgram)
{
connectionName = connectionName.Replace(":", "__", StringComparison.Ordinal);
downstreamApisPrefix = downstreamApisPrefix.Replace(":", "__", StringComparison.Ordinal);
}
return WithReferenceCore(builder, source, connectionName, downstreamApisPrefix, isDotnetProgram);
}
/// <summary>
/// Injects Entra ID authentication configuration into a consuming resource and sets the prefix of its DownstreamApis entries.
/// </summary>
/// <param name="builder">The resource that receives the authentication configuration.</param>
/// <param name="source">The Entra ID application resource to reference.</param>
/// <param name="connectionName">The environment variable prefix. Defaults to AzureAd for .NET program resources and the registration's name otherwise.</param>
/// <param name="downstreamApisPrefix">The configuration prefix of the DownstreamApis entries. Defaults to DownstreamApis.</param>
/// <returns>The resource builder.</returns>
/// <remarks>
/// Behaves like withReference with the registration, which is enough for most references. Use this method when a resource references several registrations
/// that declare permissions to the same API: give all but one of them their own downstreamApisPrefix, or the model is rejected.
/// </remarks>
[AspireExport("withEntraIdReference")]
internal static IResourceBuilder<T> WithEntraIdReferenceForExport<T>(
this IResourceBuilder<T> builder,
IResourceBuilder<EntraIdApplicationResource> source,
string? connectionName = null,
string? downstreamApisPrefix = null)
where T : IResourceWithEnvironment
=> builder.WithReference(source, connectionName, downstreamApisPrefix);
private static IResourceBuilder<T> WithReferenceCore<T>(
IResourceBuilder<T> builder,
IResourceBuilder<EntraIdApplicationResource> source,
string connectionName,
string downstreamApisPrefix,
bool isDotnetProgram)
where T : IResourceWithEnvironment
{
var entra = source.Resource;
var separator = isDotnetProgram ? "__" : "_";
var keyPrefix = connectionName.Length == 0 ? "" : $"{connectionName}{separator}";
// Create a reference relationship so the dashboard shows the connection
builder.WithReferenceRelationship(entra);
entra.Consumers.Add(builder.Resource);
// That section is a sibling of AzureAd, not part of it, so a .NET program's entries ignore the connection name.
var environmentVariablePrefix = isDotnetProgram ? $"{downstreamApisPrefix}__" : $"{keyPrefix}{downstreamApisPrefix}_";
builder.Resource.Annotations.Add(new EntraIdReferenceAnnotation(entra, downstreamApisPrefix, environmentVariablePrefix, isDotnetProgram));
if (entra.ClientCredentials.OfType<EntraIdFederatedIdentityCredential>().Any(c => c.UsesConsumerIdentity))
{
EnsureManagedIdentityRequired(builder.Resource);
}
builder.WithEnvironment(context =>
{
// Collect only this reference's settings so normalization never changes unrelated environment variables.
var environmentVariables = new Dictionary<string, object>();
// Core identity properties
environmentVariables[$"{keyPrefix}Instance"] = entra.Instance;
// Microsoft.Identity.Web builds the sign-in authority from Instance and TenantId, so TenantId decides who can
// sign in. Audiences beyond the home tenant need a keyword such as "organizations" there, but a keyword can't
// be used when the app acquires tokens as itself, so Microsoft.Identity.Web falls back to AppHomeTenantId for
// those requests and fails when it isn't set. See
// https://learn.microsoft.com/entra/identity-platform/howto-convert-app-to-be-multi-tenant
var homeTenantId = entra.HomeTenantIdValue;
if (entra.SignInTenantKeyword is { } signInTenantKeyword)
{
environmentVariables[$"{keyPrefix}TenantId"] = signInTenantKeyword;
if (homeTenantId is not null)
{
environmentVariables[$"{keyPrefix}AppHomeTenantId"] = homeTenantId;
}
}
else if (homeTenantId is not null)
{
environmentVariables[$"{keyPrefix}TenantId"] = homeTenantId;
}
if (entra.ClientIdValue is { } clientId)
{
environmentVariables[$"{keyPrefix}ClientId"] = clientId;
}
// Send the x5c claim only when explicitly requested. It enables certificate rollover but
// is only meaningful for certificate credentials, so it is opt-in via WithSendX5C().
if (entra.SendX5C)
{
environmentVariables[$"{keyPrefix}SendX5C"] = "true";
}
// Client credentials — each type emits its own env vars
// A credential that does not apply to this resource emits nothing and does not take an index, so the array has no gaps.
var credentialIndex = 0;
foreach (var credential in entra.ClientCredentials)
{
var credPrefix = $"{keyPrefix}ClientCredentials{separator}{credentialIndex}";
if (credential.EmitEnvironmentVariables(environmentVariables, credPrefix, separator, entra, builder.Resource, context.ExecutionContext))
{
credentialIndex++;
}
}
// Client capabilities (e.g., "cp1" for CAE)
for (var i = 0; i < entra.ClientCapabilities.Count; i++)
{
environmentVariables[$"{keyPrefix}ClientCapabilities{separator}{i}"] = entra.ClientCapabilities[i];
}
// Audiences
for (var i = 0; i < entra.Audiences.Count; i++)
{
environmentVariables[$"{keyPrefix}Audiences{separator}{i}"] = entra.Audiences[i];
}
// Web API authorization
if (entra.AllowWebApiToBeAuthorizedByACL)
{
environmentVariables[$"{keyPrefix}AllowWebApiToBeAuthorizedByACL"] = "true";
}
// The scopes that this registration exposes, in the space-separated form that Microsoft.Identity.Web's
// RequiredScope attribute reads from AzureAd:Scopes. A disabled scope can't be requested, so it isn't required.
var exposedScopes = entra.Scopes.Where(static s => s.IsEnabled).Select(static s => s.Name).ToArray();
if (exposedScopes.Length > 0)
{
environmentVariables[$"{keyPrefix}Scopes"] = string.Join(' ', exposedScopes);
}
// The APIs that this registration declares permissions to, as Microsoft.Identity.Web DownstreamApis entries.
// The startup pipeline validates this too, but a consumer's environment can be resolved without it, such as in tests.
entra.ValidateApiAccess();
EntraIdApplicationResource.ValidateConsumerReferences(builder.Resource);
foreach (var downstreamApi in entra.ApiAccess.SelectMany(static a => a.GetDownstreamApis()))
{
var apiPrefix = $"{environmentVariablePrefix}{downstreamApi.Name}{separator}";
for (var i = 0; i < downstreamApi.Scopes.Count; i++)
{
environmentVariables[$"{apiPrefix}Scopes{separator}{i}"] = downstreamApi.Scopes[i];
}
if (downstreamApi.BaseUrl is not null)
{
environmentVariables[$"{apiPrefix}BaseUrl"] = downstreamApi.BaseUrl;
}
if (downstreamApi.RequestAppToken)
{
environmentVariables[$"{apiPrefix}RequestAppToken"] = "true";
}
}
// Extra query parameters
foreach (var kvp in entra.ExtraQueryParameters)
{
environmentVariables[$"{keyPrefix}ExtraQueryParameters{separator}{kvp.Key}"] = kvp.Value;
}
if (!isDotnetProgram)
{
environmentVariables = environmentVariables.ToDictionary(
kvp => EntraIdReferenceAnnotation.ToEnvironmentVariableName(isDotnetProgram, kvp.Key),
kvp => kvp.Value);
}
foreach (var variable in environmentVariables)
{
context.EnvironmentVariables[variable.Key] = variable.Value;
}
});
return builder;
}
}
/// <summary>
/// Optional settings for a scope exposed by a registration created by Aspire.
/// </summary>
[AspireDto]
internal sealed class EntraIdScopeOptions
{
/// <summary>
/// Gets the scope's title, shown on the admin consent page. Defaults to the scope's name.
/// </summary>
public string? DisplayName { get; init; }
/// <summary>
/// Gets the scope's title shown on the consent page for users. Defaults to the display name.
/// </summary>
public string? UserConsentDisplayName { get; init; }
/// <summary>
/// Gets what the scope allows, shown on the consent page for users. Defaults to the scope's description.
/// </summary>
public string? UserConsentDescription { get; init; }
/// <summary>
/// Gets whether only an administrator can consent to the scope. Defaults to false, which lets users consent.
/// </summary>
public bool? RequiresAdminConsent { get; init; }
/// <summary>
/// Gets whether clients can request the scope. Defaults to true.
/// </summary>
public bool? IsEnabled { get; init; }
/// <summary>
/// Gets the scope's ID as a GUID, which must stay the same across deployments. Aspire derives a stable ID when omitted.
/// </summary>
public string? Id { get; init; }
}
/// <summary>
/// The Microsoft first-party APIs whose common permissions Aspire knows by name.
/// </summary>
internal enum EntraIdWellKnownApi
{
/// <summary>
/// Microsoft Graph.
/// </summary>
MicrosoftGraph,
/// <summary>
/// Azure Resource Manager, whose delegated permission is user_impersonation.
/// </summary>
AzureResourceManager,
/// <summary>
/// Azure Storage, whose delegated permission is user_impersonation.
/// </summary>
AzureStorage
}
/// <summary>
/// Identifies an API that is not an Aspire resource by its application ID and application ID URI.
/// </summary>
[AspireDto]
internal sealed class EntraIdCustomApi
{
/// <summary>
/// Gets a name for the API used in diagnostics and as the name of its DownstreamApis entry (its letters and digits).
/// </summary>
public required string DisplayName { get; init; }
/// <summary>
/// Gets the API's application (client) ID as a GUID.
/// </summary>
public required string ApplicationId { get; init; }
/// <summary>
/// Gets the API's application ID URI, the prefix of its scopes in token requests. It is not derived from the application ID because an API can use any URI.
/// </summary>
public required string ResourceUri { get; init; }
/// <summary>
/// Gets the API's root URL, which Aspire adds to the DownstreamApis entry as BaseUrl. When omitted, the caller must supply the address.
/// </summary>
public string? BaseUrl { get; init; }
}
/// <summary>
/// A permission of an API that is not an Aspire resource.
/// </summary>
[AspireDto]
internal sealed class EntraIdExternalApiPermission
{
/// <summary>
/// Gets the permission's name, such as User.Read.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Gets the kind of permission. Defaults to a delegated permission.
/// </summary>
public EntraIdPermissionType? Type { get; init; }
/// <summary>
/// Gets the permission's ID as a GUID. Required for a custom API and for a permission that a well-known API does not list by name.
/// </summary>
public string? Id { get; init; }
}
/// <summary>
/// Optional token exchange settings for a pre-existing managed identity federation trust.
/// </summary>
[AspireDto]
internal sealed class EntraIdTokenExchangeOptions
{
/// <summary>
/// Gets the token exchange audience. Defaults to api://AzureADTokenExchange when omitted.
/// </summary>
public string? TokenExchangeUrl { get; init; }
/// <summary>
/// Gets the token exchange authority URL when it differs from the application's authority.
/// </summary>
public string? TokenExchangeAuthority { get; init; }
}