// 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 ASPIREPIPELINES001 // Existing registrations remove the framework's provisioning annotations.
#pragma warning disable ASPIREAZURE003 // Tenant-scoped resource annotations are experimental.
using System.Globalization;
using System.IO.Hashing;
using System.Text;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Azure.EntraId.Provisioning;
using Aspire.Hosting.Pipelines;
using Azure.Provisioning;
using Azure.Provisioning.Expressions;
using Azure.Provisioning.Primitives;
namespace Aspire.Hosting.Azure;
/// <summary>
/// A resource that represents a Microsoft Entra ID application registration.
/// </summary>
/// <remarks>
/// <para>
/// Entra ID application registrations define the identity configuration for services
/// in a distributed application. Each application registration includes a tenant ID,
/// client ID, and optionally client credentials and API scopes.
/// </para>
/// <para>
/// Aspire creates new registrations using a resource-group-scoped Microsoft Graph Bicep deployment.
/// Use <c>AddExistingEntraApplication</c> to reference an existing registration without deploying infrastructure.
/// </para>
/// <para>
/// References to this resource inject configuration as environment variables.
/// .NET program references default to names such as <c>AzureAd__TenantId</c> and <c>AzureAd__ClientId</c>,
/// compatible with Microsoft.Identity.Web. Other references default to an uppercase resource-name prefix
/// and a single underscore separator, and can configure their own prefix and separator.
/// </para>
/// <para>
/// The properties align with <c>MicrosoftEntraApplicationOptions</c> from
/// <c>Microsoft.Identity.Abstractions</c>.
/// </para>
/// </remarks>
[AspireExport]
public class EntraIdApplicationResource : AzureProvisioningResource, IResourceWithCustomWithReference<EntraIdApplicationResource>
{
private const string DefaultInstance = "https://login.microsoftonline.com/";
private readonly IResourceAnnotation[] _provisioningAnnotations;
/// <summary>
/// Initializes a new instance of the <see cref="EntraIdApplicationResource"/> class.
/// </summary>
/// <param name="name">The name of the resource.</param>
public EntraIdApplicationResource(string name)
: base(name, ConfigureApplicationInfrastructure)
{
Annotations.Add(new TenantScopedResourceAnnotation(new Dictionary<string, string>
{
[EntraIdBicepConstants.ApplicationResourceType] = EntraIdBicepConstants.ObjectIdOutputName,
[EntraIdBicepConstants.ServicePrincipalResourceType] = EntraIdBicepConstants.ServicePrincipalIdOutputName
}));
Parameters[KnownParameters.TenantResourceSuffix] = null;
// Existing registrations are never provisioned, so SetExistingApplication removes this along with the base annotations.
Annotations.Add(new PipelineStepAnnotation(_ => new PipelineStep
{
Name = $"validate-{name}-managed-identities",
Description = $"Validates the managed identities that Entra ID application {name} trusts.",
// The Azure resource preparer attaches each compute resource's identity, so the identities can only be checked after it.
Action = context =>
{
ValidateComputeTargets();
if (context.ExecutionContext.IsPublishMode)
{
ValidateManagedIdentities();
}
return Task.CompletedTask;
},
DependsOnSteps = [AzureEnvironmentResource.PrepareResourcesStepName],
RequiredBySteps = [WellKnownPipelineSteps.BeforeStart]
}));
_provisioningAnnotations = [.. Annotations.Where(annotation => annotation is PipelineStepAnnotation or PipelineConfigurationAnnotation)];
}
private bool _hasApiAccessValidation;
// Consumers and API permissions are only complete once the model is built, so the check runs before anything starts. The step is added
// with the first declaration, not up front, so a registration that declares nothing keeps no pipeline steps, as an existing one must.
internal void EnsureApiAccessValidation()
{
if (_hasApiAccessValidation)
{
return;
}
_hasApiAccessValidation = true;
Annotations.Add(new PipelineStepAnnotation(_ => new PipelineStep
{
Name = $"validate-{Name}-api-access",
Description = $"Validates the API permissions that Entra ID application {Name} declares.",
Action = _ =>
{
ValidateApiAccess();
return Task.CompletedTask;
},
RequiredBySteps = [WellKnownPipelineSteps.BeforeStart]
}));
}
internal bool IsExistingApplication { get; private set; }
static IResourceBuilder<TDestination>? IResourceWithCustomWithReference<EntraIdApplicationResource>.TryWithReference<TDestination>(
IResourceBuilder<TDestination> builder,
IResourceBuilder<IResource> source,
string? connectionName,
bool optional,
string? name)
{
if (source is not IResourceBuilder<EntraIdApplicationResource> application)
{
return null;
}
if (optional)
{
throw new InvalidOperationException("Optional references are not supported for Entra ID applications.");
}
if (name is not null)
{
throw new InvalidOperationException("Named service references are not supported for Entra ID applications. Use connectionName to customize the configuration prefix.");
}
return EntraIdResourceExtensions.WithReference(builder, application, connectionName);
}
internal void EnsureCanBeExistingApplication()
{
if (ClientCredentials.OfType<EntraIdLocalDeveloperCertificateCredential>().Any())
{
throw new InvalidOperationException("A registration using RunWithLocalDeveloperCertificateCredential cannot be converted to an existing application. Existing registrations remain caller-managed.");
}
}
internal void SetExistingApplication()
{
EnsureCanBeExistingApplication();
IsExistingApplication = true;
ProvisioningTaskCompletionSource = null;
// Configuration-only registrations must not generate Bicep or provisioning tasks. Remove only the base
// resource's annotations so any pipeline steps added by the AppHost are preserved.
foreach (var annotation in _provisioningAnnotations)
{
Annotations.Remove(annotation);
}
}
/// <summary>
/// Gets the application (client) ID produced when the registration is provisioned.
/// </summary>
/// <remarks>
/// This output is available for applications created by Aspire. Existing applications use
/// <see cref="ClientIdParameter"/> or <see cref="ClientId"/> instead.
/// </remarks>
public BicepOutputReference ClientIdOutputReference => new(EntraIdBicepConstants.ClientIdOutputName, this);
/// <summary>
/// Gets the home tenant ID produced when the registration is provisioned.
/// </summary>
/// <remarks>
/// Registrations are created in the tenant of the Azure subscription used for provisioning.
/// Existing applications use <see cref="TenantIdParameter"/> or <see cref="TenantId"/> instead.
/// </remarks>
public BicepOutputReference TenantIdOutputReference => new(EntraIdBicepConstants.TenantIdOutputName, this);
internal object? HomeTenantIdValue => IsExistingApplication
? (object?)TenantIdParameter ?? TenantId
: TenantIdOutputReference;
internal object? ClientIdValue => IsExistingApplication
? (object?)ClientIdParameter ?? ClientId
: ClientIdOutputReference;
/// <inheritdoc />
public override string GetBicepTemplateString()
{
// The base class caches the first template it generates. The pipeline generates it before the Azure resource
// preparer attaches the identities this registration trusts, so a cached template would lack the federated
// credentials, and the deployment checksum computed from it would not change when federation is turned on.
using var template = GetBicepTemplateFile();
return File.ReadAllText(template.Path);
}
/// <inheritdoc />
public override BicepTemplateFile GetBicepTemplateFile(string? directory = null, bool deleteTemporaryFileOnDispose = true)
{
ValidateProvisioningConfiguration();
return base.GetBicepTemplateFile(directory, deleteTemporaryFileOnDispose);
}
private void ValidateProvisioningConfiguration()
{
if (IsExistingApplication || this.HasAnnotationOfType<ExistingAzureResourceAnnotation>())
{
throw new InvalidOperationException(
"Entra ID applications are identified by tenant ID and client ID, not by an Azure resource name or resource group. " +
"Use AddExistingEntraApplication(name, tenantId, clientId) to reference an existing registration without provisioning it.");
}
if (Scope is { HasResourceGroup: false })
{
throw new InvalidOperationException("Entra ID application templates must be deployed at resource-group scope.");
}
if (!Enum.IsDefined(SignInAudience))
{
throw new InvalidOperationException($"'{SignInAudience}' is not a valid {nameof(EntraIdSignInAudience)} value.");
}
if (EntraIdValidation.ValidateInstance(Instance) is { } instanceError)
{
throw new InvalidOperationException($"The instance '{Instance}' is not valid. {instanceError}");
}
// Provisioning and resource initialization complete independently. Reject invalid credentials before
// the Azure controller can report Running, rather than leaving a consumer to fail when it resolves them.
foreach (var credential in ClientCredentials)
{
_ = credential.SourceType;
}
if (ClientCredentials.OfType<EntraIdFederatedIdentityCredential>().Any(c => c.IsResourceBacked) &&
EntraIdFederation.GetTokenExchangeAudience(Instance) is null)
{
throw new InvalidOperationException(
$"The instance '{Instance}' has no known managed identity token exchange audience. " +
"Managed identity federation is supported for the Azure public, US Government and China clouds.");
}
}
// Consumers are recorded as they reference the registration so that each one's own identity can be trusted.
internal List<IResource> Consumers { get; } = [];
// A scope can still be disabled after WithApiPermissions or WithPreAuthorizedApplication accepted it, because EntraIdScope.IsEnabled is settable.
// Emitting such a reference would leave a client requesting a scope that the API no longer lets anyone request, so the references are checked
// again once the model is complete. Only the registration's own declarations are checked: each registration checks the ones it makes.
internal void ValidateScopeReferences()
{
foreach (var access in ApiAccess.Where(static a => a.Application is not null))
{
foreach (var scope in access.Declared.Select(static p => p.Scope).OfType<EntraIdScope>().Where(static s => !s.IsEnabled))
{
throw new InvalidOperationException(
$"'{Name}' declares permissions to the scope '{scope.Name}' of '{access.DisplayName}', but the scope is disabled, so clients can't request it. " +
$"Remove it from WithApiPermissions (and WithAdminConsent) on '{Name}', or enable the scope.");
}
}
foreach (var preAuthorized in PreAuthorizedClients)
{
foreach (var scope in preAuthorized.Scopes.Where(static s => !s.IsEnabled))
{
throw new InvalidOperationException(
$"'{Name}' pre-authorizes '{preAuthorized.Client.Name}' for the scope '{scope.Name}', but the scope is disabled, so clients can't request it. " +
$"Remove it from WithPreAuthorizedApplication on '{Name}', or enable the scope.");
}
}
}
// Checks that the DownstreamApis entries that the registration's consumers receive can be built and are distinct. Only the
// resources that reference this registration get entries, and an entry is named after a resource that references the API, so
// this can only be checked once the whole model exists. Both the startup pipeline and the consumers' environment run it.
internal void ValidateApiAccess()
{
ValidateScopeReferences();
if (Consumers.Count == 0)
{
return;
}
var entries = new Dictionary<string, EntraIdApiAccess>(StringComparer.OrdinalIgnoreCase);
foreach (var access in ApiAccess)
{
if (access.Application is { IsExistingApplication: true, ApplicationIdUri: null } existingApi)
{
throw new InvalidOperationException(
$"'{Name}' declares permissions to the existing registration '{existingApi.Name}', but its application ID URI is not configured, " +
$"so the scope names to request can't be built. Call WithApplicationIdUri on '{existingApi.Name}' with the URI that it defines.");
}
foreach (var entry in access.GetDownstreamApis())
{
if (entries.TryAdd(entry.Name, access))
{
continue;
}
var other = entries[entry.Name];
throw new InvalidOperationException(access.Application is not null && other.Application is not null
? $"'{Name}' declares permissions to the registrations '{other.DisplayName}' and '{access.DisplayName}', and both would be configured as the " +
$"DownstreamApis entry '{entry.Name}', the name of a resource that references both. An entry holds the scopes of one API, so the consumer " +
$"couldn't tell them apart, and one token request can't cover both. Reference '{other.DisplayName}' and '{access.DisplayName}' from different resources."
: $"'{Name}' declares permissions to '{other.DisplayName}' and '{access.DisplayName}', which would both be configured as the DownstreamApis entry " +
$"'{entry.Name}'. An entry is named after a resource that references the API's registration, or after the display name of an API created with " +
$"EntraIdApi.FromApplicationId. Rename the resource, or give the API a different display name.");
}
}
foreach (var consumer in Consumers)
{
ValidateConsumerReferences(consumer);
}
}
// A resource can reference several registrations. Entries that different registrations write to the same environment variable
// would overwrite each other's scopes one by one, and leave behind the higher indexes of the longer list, mixing the permissions
// of two identities. The check compares the names that the consumer receives, because a non-.NET resource gets encoded, upper snake
// case names: the prefixes "A"/"B-C" and "A_B"/"C", or the entries "orders-api" and "ordersApi", become the same variable. A .NET
// program's DownstreamApis section is the same whatever the connection name, so references clash there most easily.
internal static void ValidateConsumerReferences(IResource consumer)
{
var owners = new Dictionary<string, (EntraIdApplicationResource Source, string Entry, string Prefix)>(StringComparer.OrdinalIgnoreCase);
foreach (var reference in consumer.Annotations.OfType<EntraIdReferenceAnnotation>())
{
var separator = reference.IsDotnetProgram ? "__" : "_";
foreach (var entry in reference.Source.ApiAccess.SelectMany(static a => a.GetDownstreamApis()))
{
// Every variable of an entry starts with this name, so a clash of the first scope is a clash of the entry.
var key = reference.GetEnvironmentVariableName($"{reference.EnvironmentVariablePrefix}{entry.Name}{separator}Scopes{separator}0");
if (!owners.TryGetValue(key, out var other) || (ReferenceEquals(other.Source, reference.Source) && other.Entry == entry.Name))
{
owners[key] = (reference.Source, entry.Name, reference.DownstreamApisPrefix);
continue;
}
throw new InvalidOperationException(other.Entry == entry.Name
? $"'{consumer.Name}' references the Entra ID applications '{other.Source.Name}' and '{reference.Source.Name}', and both declare permissions to an API that is configured as " +
$"the DownstreamApis entry '{entry.Name}' under '{reference.DownstreamApisPrefix}', so their scopes would be mixed. Pass a different downstreamApisPrefix " +
$"to WithReference for one of them."
: $"'{consumer.Name}' would receive the DownstreamApis entries '{other.Entry}' (of '{other.Source.Name}') and '{entry.Name}' (of '{reference.Source.Name}') as the same " +
$"environment variable, '{key}', so their scopes would be mixed. Rename a resource that references the API, give an API created with " +
$"EntraIdApi.FromApplicationId a different display name, or pass a different downstreamApisPrefix to WithReference for one of the references.");
}
}
}
internal void ValidateComputeTargets()
{
foreach (var target in ClientCredentials.OfType<EntraIdFederatedIdentityCredential>().Select(c => c.ComputeTarget).OfType<IResource>())
{
if (!Consumers.Contains(target))
{
throw new InvalidOperationException(
$"Resource '{target.Name}' is trusted by Entra ID application '{Name}' but does not reference it, so it would not receive the configuration. " +
$"Call WithReference on '{target.Name}' with the Entra ID application.");
}
}
}
// Every compute resource whose identity is trusted must have one by now. The Azure resource preparer attaches it
// for resources that need one, but only environments that support managed identities do.
internal void ValidateManagedIdentities()
{
foreach (var credential in ClientCredentials.OfType<EntraIdFederatedIdentityCredential>().Where(c => c.IsResourceBacked))
{
IEnumerable<IResource> targets = credential.ComputeTarget is { } target ? [target] : Consumers;
foreach (var resource in targets)
{
// A resource excluded from the manifest is not deployed, so the Azure resource preparer never gives it an identity.
if (resource.IsExcludedFromPublish())
{
continue;
}
credential.ValidateConsumer(this, resource);
}
}
}
/// <summary>
/// Gets the distinct user-assigned identities that the registration trusts.
/// </summary>
/// <remarks>
/// A compute resource's identity is attached by the Azure resource preparer, so this must be read after it has run.
/// </remarks>
internal IReadOnlyList<AzureUserAssignedIdentityResource> GetFederatedIdentities()
{
var identities = new List<AzureUserAssignedIdentityResource>();
foreach (var credential in ClientCredentials.OfType<EntraIdFederatedIdentityCredential>())
{
if (credential.ManagedIdentity is { } explicitIdentity)
{
identities.Add(explicitIdentity);
}
else if (credential.ComputeTarget is { } target)
{
// Resolved after the Azure resource preparer has attached the target's identity. It is absent when running locally.
if (!target.IsExcludedFromPublish() && EntraIdFederatedIdentityCredential.GetConsumerIdentity(target) is { } targetIdentity)
{
identities.Add(targetIdentity);
}
}
else if (credential.UsesConsumerIdentity)
{
identities.AddRange(Consumers.Where(c => !c.IsExcludedFromPublish()).Select(EntraIdFederatedIdentityCredential.GetConsumerIdentity).OfType<AzureUserAssignedIdentityResource>());
}
}
return [.. identities.Distinct()];
}
private static BicepList<string> CreateRedirectUris(EntraIdApplicationResource resource, AzureResourceInfrastructure infrastructure)
{
var values = resource._redirectUriValues;
if (values.All(static v => v is string))
{
return [.. values.OfType<string>()];
}
// Parameter values are only known at deployment time, so each is split on commas in Bicep. Consecutive
// literals and endpoint-derived URIs are grouped into one array to keep the call order:
// concat(['https://literal', endpointRedirectUri0], split(redirectUris, ','), split(otherRedirectUris, ','))
var arguments = new List<BicepExpression>();
var literals = new List<BicepExpression>();
// Derived URIs get generated parameter names. Reserve the names of every user-supplied parameter first, in any
// call order, so a generated name never overwrites a parameter such as one named "redirectUri0".
var reservedNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var parameter in values.Concat(resource.ApplicationOwners).Concat(resource.ServicePrincipalOwners).OfType<ParameterResource>())
{
reservedNames.Add(Infrastructure.NormalizeBicepIdentifier(parameter.Name));
}
var endpointIndex = 0;
string NextDerivedParameterName()
{
string name;
do
{
name = $"redirectUri{endpointIndex++}";
}
while (reservedNames.Contains(name));
return name;
}
foreach (var value in values)
{
if (value is string literal)
{
literals.Add(new StringLiteralExpression(literal));
continue;
}
if (value is IEntraIdDerivedRedirectUri derivedRedirectUri)
{
// The value is only known when the parameter is resolved: after endpoint allocation when running
// locally, from the compute environment when deploying, or from the host parameter's value.
var uriParameter = derivedRedirectUri
.CreateExpression()
.AsProvisioningParameter(infrastructure, NextDerivedParameterName());
literals.Add(uriParameter.Value.Compile());
continue;
}
if (literals.Count > 0)
{
arguments.Add(new ArrayExpression([.. literals]));
literals.Clear();
}
arguments.Add(new FunctionCallExpression(
new IdentifierExpression("split"),
((ParameterResource)value).AsProvisioningParameter(infrastructure).Value.Compile(),
new StringLiteralExpression(",")));
}
if (literals.Count > 0)
{
arguments.Add(new ArrayExpression([.. literals]));
}
BicepExpression expression = arguments.Count == 1
? arguments[0]
: new FunctionCallExpression(new IdentifierExpression("concat"), [.. arguments]);
return BicepList<string>.FromExpression(static _ => string.Empty, expression);
}
private static void ConfigureApplicationInfrastructure(AzureResourceInfrastructure infrastructure)
{
var resource = (EntraIdApplicationResource)infrastructure.AspireResource;
resource.ValidateProvisioningConfiguration();
// Directory objects have no Azure location, so omit the default location parameter.
infrastructure.Remove(infrastructure.GetProvisionableResources().OfType<ProvisioningParameter>()
.Single(parameter => parameter.BicepIdentifier == KnownParameters.Location));
infrastructure.BicepExtensions["microsoftGraphV1"] = "br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:1.0.0";
var tenantResourceSuffix = new ProvisioningParameter(KnownParameters.TenantResourceSuffix, typeof(string))
{
Value = string.Empty
};
BicepValue<string> registrationName = new ConditionalExpression(
new BinaryExpression(new IdentifierExpression(tenantResourceSuffix.BicepIdentifier), BinaryBicepOperator.Equal, new StringLiteralExpression(string.Empty)),
new StringLiteralExpression(resource.Name),
BicepFunction.Interpolate($"{resource.Name}-{tenantResourceSuffix}").Compile());
infrastructure.Add(tenantResourceSuffix);
var application = new MicrosoftGraphApplication("application")
{
DisplayName = registrationName,
UniqueName = registrationName,
SignInAudience = resource.SignInAudience.ToString(),
RequestedAccessTokenVersion = 2,
RedirectUris = CreateRedirectUris(resource, infrastructure),
EnableIdTokenIssuance = resource.EnableIdTokenIssuance
};
if (resource.ApplicationOwners.Count > 0)
{
application.Owners = CreateOwnerList(infrastructure, resource.ApplicationOwners, "applicationOwner");
}
if (resource.AppRoles.Count > 0)
{
application.AppRoles = [.. resource.AppRoles.Select(role => CreateAppRole(resource, role))];
}
if (resource.ClientCredentials.OfType<EntraIdLocalDeveloperCertificateCredential>().SingleOrDefault() is { } localCredential)
{
// This developer registration owns the full key collection. Embed only the selected public
// certificate so renewal changes the template checksum without a separate Graph read.
// Let Graph assign keyId: resubmitting an explicit existing ID can fail with KeyNotUpdatable.
// See https://learn.microsoft.com/graph/applications-how-to-add-certificate#step-2-add-the-certificate-details-using-microsoft-graph.
var certificate = localCredential.GetCertificate(resource);
application.KeyCredentials = [new MicrosoftGraphKeyCredential
{
CustomKeyIdentifier = certificate.Thumbprint,
DisplayName = "Aspire local developer certificate",
EndDateTime = certificate.NotAfter.ToString("O", CultureInfo.InvariantCulture),
Key = certificate.PublicCertificate,
StartDateTime = certificate.NotBefore.ToString("O", CultureInfo.InvariantCulture),
Type = "AsymmetricX509Cert",
Usage = "Verify"
}];
}
resource.ValidateScopeReferences();
// Names for the API access settings are allocated against everything that the template already declares.
var symbolNames = new EntraIdSymbolNames(infrastructure);
EntraIdApiProvisioning.ConfigureRequiredResourceAccess(resource, infrastructure, symbolNames, application);
infrastructure.Add(application);
var applicationIdUri = EntraIdApiProvisioning.ConfigureExposedApi(resource, infrastructure, symbolNames, application, registrationName);
var servicePrincipal = new MicrosoftGraphServicePrincipal("servicePrincipal")
{
AppId = application.AppId,
Tags = [EntraIdBicepConstants.IntegratedAppTag]
};
if (resource.ServicePrincipalOwners.Count > 0)
{
servicePrincipal.Owners = CreateOwnerList(infrastructure, resource.ServicePrincipalOwners, "servicePrincipalOwner");
}
infrastructure.Add(servicePrincipal);
EntraIdApiProvisioning.ConfigureGrants(resource, infrastructure, symbolNames, servicePrincipal);
// A symbol name must not collide with another symbol in the template, such as a user-supplied redirect URI
// parameter named "web-identity-principalId" and an identity named "web-identity". Parameters and resources share
// one namespace, and the ARM deployment engine is not case sensitive, so Bicep rejects parameters that differ only
// in casing (BCP353); one case-insensitive set covers both. Only this generation's symbols count:
// resource.Parameters keeps the names from earlier generations, and reserving those would change the names
// every time the template is generated.
var usedSymbolNames = new HashSet<string>(
infrastructure.GetProvisionableResources().OfType<ProvisioningParameter>().Select(p => p.BicepIdentifier)
.Concat(infrastructure.GetProvisionableResources().OfType<ProvisionableResource>().Select(r => r.BicepIdentifier)),
StringComparer.OrdinalIgnoreCase);
foreach (var identity in resource.GetFederatedIdentities())
{
// The subject is the identity's object (principal) ID; the client ID is what consumers pass to Microsoft.Identity.Web.
var identifier = Infrastructure.NormalizeBicepIdentifier(identity.Name);
var parameterName = AllocateSymbolName(usedSymbolNames, $"{identifier}_principalId");
var principalId = identity.PrincipalId.AsProvisioningParameter(infrastructure, parameterName);
var credentialIdentifier = AllocateSymbolName(usedSymbolNames, $"federatedCredential_{identifier}");
// Graph requires a federated credential's name to be 3-120 characters. Aspire resource names can be 1-64
// characters, so a fixed prefix keeps a short name above the minimum and, unlike one that also includes the
// registration's name, a long name below the maximum. The identity's name is unique in the model.
var credentialName = $"aspire-{identity.Name}";
// The Graph extension names a child as '{applicationUniqueName}/{credentialName}'. Referencing the
// application's property, rather than reusing its name expression, makes the child depend on the application.
BicepValue<string> applicationUniqueName = application.UniqueName.ToBicepExpression();
infrastructure.Add(new FederatedIdentityCredential(credentialIdentifier)
{
Name = BicepFunction.Interpolate($"{applicationUniqueName}/{credentialName}").Compile(),
Issuer = BicepFunction.Interpolate($"{EntraIdFederation.GetIssuerPrefix(resource.Instance)}/{BicepFunction.GetTenant().TenantId}/v2.0").Compile(),
Subject = principalId,
Audiences = [EntraIdFederation.GetTokenExchangeAudience(resource.Instance)!]
});
}
infrastructure.Add(new ProvisioningOutput(EntraIdBicepConstants.ClientIdOutputName, typeof(string)) { Value = application.AppId.ToBicepExpression() });
// Compile the scope-function property directly; ToBicepExpression would reference an undeclared "tenant" resource.
infrastructure.Add(new ProvisioningOutput(EntraIdBicepConstants.TenantIdOutputName, typeof(string)) { Value = BicepFunction.GetTenant().TenantId.Compile() });
infrastructure.Add(new ProvisioningOutput(EntraIdBicepConstants.ObjectIdOutputName, typeof(string)) { Value = application.Id.ToBicepExpression() });
infrastructure.Add(new ProvisioningOutput(EntraIdBicepConstants.ServicePrincipalIdOutputName, typeof(string)) { Value = servicePrincipal.Id.ToBicepExpression() });
infrastructure.Add(new ProvisioningOutput(EntraIdBicepConstants.UniqueNameOutputName, typeof(string)) { Value = application.UniqueName.ToBicepExpression() });
if (applicationIdUri is not null)
{
infrastructure.Add(new ProvisioningOutput(EntraIdBicepConstants.ApplicationIdUriOutputName, typeof(string)) { Value = applicationIdUri });
}
}
private static string AllocateSymbolName(HashSet<string> usedSymbolNames, string name)
{
var candidate = name;
for (var suffix = 2; !usedSymbolNames.Add(candidate); suffix++)
{
candidate = $"{name}_{suffix}";
}
return candidate;
}
// ── Core identity ──────────────────────────────────────────────────────
/// <summary>
/// Gets or sets the Entra ID instance URL.
/// </summary>
/// <remarks>
/// Defaults to <c>https://login.microsoftonline.com/</c>. Override for sovereign clouds
/// (e.g., <c>https://login.microsoftonline.us/</c> for Azure Government).
/// </remarks>
public string Instance { get; set; } = DefaultInstance;
/// <summary>
/// Gets or sets the parameter resource for the ID of the app's home tenant.
/// </summary>
/// <remarks>
/// The home tenant is the directory where the app is registered, shown as "Directory (tenant) ID"
/// on the app registration's Overview page.
/// </remarks>
public ParameterResource? TenantIdParameter { get; set; }
/// <summary>
/// Gets or sets a fixed ID of the app's home tenant.
/// </summary>
/// <remarks>
/// <para>
/// The home tenant is the directory where the app is registered, shown as "Directory (tenant) ID"
/// on the app registration's Overview page. It can be a GUID or a domain such as <c>contoso.onmicrosoft.com</c>.
/// </para>
/// <para>
/// When both <see cref="TenantIdParameter"/> and <see cref="TenantId"/> are set,
/// <see cref="TenantIdParameter"/> takes precedence.
/// </para>
/// </remarks>
public string? TenantId { get; set; }
/// <summary>
/// Gets or sets the parameter resource for the client ID.
/// </summary>
public ParameterResource? ClientIdParameter { get; set; }
/// <summary>
/// Gets or sets a fixed client ID value.
/// </summary>
/// <remarks>
/// When both <see cref="ClientIdParameter"/> and <see cref="ClientId"/> are set,
/// <see cref="ClientIdParameter"/> takes precedence.
/// </remarks>
public string? ClientId { get; set; }
/// <summary>
/// Gets or sets which accounts can sign in to the application.
/// </summary>
/// <remarks>
/// Defaults to <see cref="EntraIdSignInAudience.AzureADMyOrg"/>, which allows only accounts in the home tenant.
/// The value must match the "Supported account types" setting of the app registration.
/// </remarks>
public EntraIdSignInAudience SignInAudience { get; set; } = EntraIdSignInAudience.AzureADMyOrg;
/// <summary>
/// Gets the literal web redirect URIs to register for an application created by Aspire.
/// </summary>
/// <remarks>
/// These URIs are written directly into the generated infrastructure. Endpoints are not discovered automatically,
/// and existing registrations are not modified. Use <see cref="RedirectUriParameters"/> for URIs supplied at deployment time.
/// </remarks>
public IReadOnlyList<string> RedirectUris => [.. _redirectUriValues.OfType<string>()];
/// <summary>
/// Gets the parameters whose values are comma-separated lists of web redirect URIs to register for an application created by Aspire.
/// </summary>
/// <remarks>
/// The values are supplied at deployment time and split on commas in the generated infrastructure.
/// </remarks>
public IReadOnlyList<ParameterResource> RedirectUriParameters => [.. _redirectUriValues.OfType<ParameterResource>()];
// Holds string or ParameterResource entries in the order they were added, so registration order matches call order.
private readonly List<object> _redirectUriValues = [];
internal void AddRedirectUri(string redirectUri) => _redirectUriValues.Add(redirectUri);
internal void AddRedirectUri(ParameterResource redirectUri) => _redirectUriValues.Add(redirectUri);
internal void AddRedirectUri(IEntraIdDerivedRedirectUri redirectUri) => _redirectUriValues.Add(redirectUri);
/// <summary>
/// Gets the application roles to declare on an application created by Aspire.
/// </summary>
/// <remarks>
/// Application roles are distinct from delegated scopes and from Azure RBAC roles. Declaring a role does not
/// assign it to any user, group, or application, and existing registrations are not modified.
/// </remarks>
public IReadOnlyList<EntraIdAppRole> AppRoles => _appRoles.AsReadOnly();
private readonly List<EntraIdAppRole> _appRoles = [];
internal void AddAppRole(EntraIdAppRole role) => _appRoles.Add(role);
// Each entry is either a literal directory object ID (string) or a ParameterResource holding one.
internal List<object> ApplicationOwners { get; } = [];
internal List<object> ServicePrincipalOwners { get; } = [];
private static MicrosoftGraphAppRole CreateAppRole(EntraIdApplicationResource resource, EntraIdAppRole role)
{
var memberTypes = new BicepList<string>();
foreach (var memberType in role.AllowedMemberTypes)
{
memberTypes.Add(memberType);
}
return new MicrosoftGraphAppRole
{
Id = role.GetStableId(resource.Name).ToString("D"),
DisplayName = role.DisplayName,
Description = role.Description,
Value = role.Value,
IsEnabled = role.IsEnabled,
AllowedMemberTypes = memberTypes
};
}
private static BicepList<string> CreateOwnerList(AzureResourceInfrastructure infrastructure, IEnumerable<object> owners, string prefix)
{
var list = new BicepList<string>();
var index = 0;
foreach (var owner in owners)
{
// Only parameters need a Bicep identifier, so number just those.
BicepValue<string> value = owner switch
{
ParameterResource parameter => parameter.AsProvisioningParameter(infrastructure, $"{prefix}{index++}"),
string id => id,
_ => throw new InvalidOperationException("Unsupported owner type.")
};
list.Add(value);
}
return list;
}
/// <summary>
/// Gets or sets whether a created registration can issue ID tokens through implicit and hybrid flows.
/// </summary>
/// <remarks>
/// Defaults to <see langword="false"/>. This does not enable implicit access-token issuance and is not required
/// for authorization-code-only flows. Existing registrations are not modified.
/// </remarks>
public bool EnableIdTokenIssuance { get; set; }
/// <summary>
/// Gets the keyword that Microsoft.Identity.Web expects as <c>TenantId</c> for <see cref="SignInAudience"/>,
/// or <see langword="null"/> when the app is single-tenant and uses its home tenant instead.
/// </summary>
/// <remarks>
/// For more information about the keywords, see
/// <a href="https://learn.microsoft.com/entra/identity-platform/v2-protocols#endpoints">Microsoft identity platform endpoints</a>.
/// </remarks>
internal string? SignInTenantKeyword => SignInAudience switch
{
EntraIdSignInAudience.AzureADMyOrg => null,
EntraIdSignInAudience.AzureADMultipleOrgs => "organizations",
EntraIdSignInAudience.AzureADandPersonalMicrosoftAccount => "common",
EntraIdSignInAudience.PersonalMicrosoftAccount => "consumers",
_ => throw new InvalidOperationException($"'{SignInAudience}' is not a valid {nameof(EntraIdSignInAudience)} value.")
};
// ── Token acquisition ──────────────────────────────────────────────────
/// <summary>
/// Gets the list of client credentials configured for this application.
/// </summary>
/// <remarks>
/// Supports multiple credential types used by Microsoft.Identity.Web:
/// <list type="bullet">
/// <item><description><c>ClientSecret</c> — application secret</description></item>
/// <item><description><c>SignedAssertionFromManagedIdentity</c> — managed identity credential</description></item>
/// <item><description><c>Certificate</c> — certificate-based credential</description></item>
/// </list>
/// </remarks>
internal List<EntraIdClientCredential> ClientCredentials { get; } = [];
/// <summary>
/// Gets the client capabilities (e.g., <c>"cp1"</c> for Continuous Access Evaluation).
/// </summary>
internal List<string> ClientCapabilities { get; } = [];
/// <summary>
/// Gets or sets whether to send the <c>x5c</c> claim (the public key of the certificate) with the token request.
/// </summary>
/// <remarks>
/// Sending <c>x5c</c> enables easy certificate rollover, but it is only meaningful for certificate-based
/// credentials and increases request size, so it is opt-in via <c>WithSendX5C()</c>.
/// </remarks>
public bool SendX5C { get; set; }
// ── Web API ────────────────────────────────────────────────────────────
/// <summary>
/// Gets the list of audiences accepted by this application registration.
/// </summary>
internal List<string> Audiences { get; } = [];
/// <summary>
/// Gets or sets whether to allow ACL-based authorization for daemon-to-API calls.
/// </summary>
/// <remarks>
/// When <see langword="true"/>, the web API will not require roles or scopes in the token,
/// allowing client credentials flow callers to be authorized by an Access Control List.
/// </remarks>
public bool AllowWebApiToBeAuthorizedByACL { get; set; }
// ── Exposed API ────────────────────────────────────────────────────────
/// <summary>
/// Gets the application ID URI that identifies this registration as an API, when one was set explicitly.
/// </summary>
/// <remarks>
/// This is <see langword="null"/> when the registration uses the default <c>api://{clientId}</c> URI, which is known only after
/// the registration is created. Use <see cref="GetScopeUri(string)"/> to refer to a scope's full URI in either case.
/// </remarks>
public string? ApplicationIdUri { get; internal set; }
internal bool UsesDefaultApplicationIdUri { get; set; }
/// <summary>
/// Gets the delegated permission scopes that this registration exposes.
/// </summary>
public IReadOnlyList<EntraIdScope> Scopes => _scopes;
private readonly List<EntraIdScope> _scopes = [];
internal List<EntraIdPreAuthorizedClient> PreAuthorizedClients { get; } = [];
internal List<EntraIdApiAccess> ApiAccess { get; } = [];
internal bool HasApplicationIdUri => ApplicationIdUri is not null || UsesDefaultApplicationIdUri || _scopes.Count > 0;
/// <summary>
/// Gets the application ID URI produced when the registration is provisioned with an application ID URI.
/// </summary>
public BicepOutputReference ApplicationIdUriOutputReference => new(EntraIdBicepConstants.ApplicationIdUriOutputName, this);
internal BicepOutputReference ServicePrincipalIdOutputReference => new(EntraIdBicepConstants.ServicePrincipalIdOutputName, this);
internal object? ApplicationIdUriValue => ApplicationIdUri is { } uri
? uri
: IsExistingApplication
? null
: ApplicationIdUriOutputReference;
/// <summary>
/// Gets an expression for the full URI of one of this registration's scopes, such as <c>api://{clientId}/access_as_user</c>.
/// </summary>
/// <param name="scopeName">The name of a scope added with <c>WithScope</c>.</param>
/// <returns>An expression that resolves when the registration's application ID URI is known.</returns>
/// <exception cref="ArgumentException">The registration does not expose a scope with that name.</exception>
/// <exception cref="InvalidOperationException">The registration is an existing registration without an application ID URI.</exception>
/// <remarks>
/// Clients request the full URI as the token scope, for example in Microsoft.Identity.Web's <c>DownstreamApis</c> configuration.
/// </remarks>
public ReferenceExpression GetScopeUri(string scopeName)
{
ArgumentException.ThrowIfNullOrEmpty(scopeName);
if (!_scopes.Any(s => string.Equals(s.Name, scopeName, StringComparison.Ordinal)))
{
throw new ArgumentException($"'{Name}' does not expose a scope named '{scopeName}'. Add it with WithScope first.", nameof(scopeName));
}
return CreateScopeValue(scopeName);
}
internal void AddScope(EntraIdScope scope) => _scopes.Add(scope);
// The scope's full name, as a client requests it in a token request. An existing registration can use any application ID URI,
// so one that isn't configured can't be guessed: a made-up URI would only fail later, as a token error.
internal ReferenceExpression CreateScopeValue(string scope) => ApplicationIdUriValue switch
{
string uri => ReferenceExpression.Create($"{EntraIdApi.CreateScope(uri, scope)}"),
BicepOutputReference output => ReferenceExpression.Create($"{output}/{scope}"),
_ => throw CreateMissingApplicationIdUriException()
};
internal InvalidOperationException CreateMissingApplicationIdUriException() =>
new($"The application ID URI of '{Name}' is not configured. Call WithApplicationIdUri with the URI that the registration defines.");
// Scope IDs must not change between deployments, and Graph rejects a request that drops an enabled scope, so a
// random ID is not an option. Deriving the ID from the registration and scope names also gives every tenant the same IDs.
internal Guid GetScopeId(EntraIdScope scope)
{
if (scope.Id is { } id)
{
return id;
}
if (IsExistingApplication)
{
throw new InvalidOperationException(
$"The ID of scope '{scope.Name}' of the existing registration '{Name}' is unknown. Set EntraIdScope.Id to the ID that the registration defines.");
}
Span<byte> hash = stackalloc byte[16];
XxHash128.Hash(Encoding.UTF8.GetBytes($"{Name}/{scope.Name}"), hash);
// Mark the GUID as RFC 9562 version 8 (custom) with the standard variant.
hash[6] = (byte)((hash[6] & 0x0F) | 0x80);
hash[8] = (byte)((hash[8] & 0x3F) | 0x80);
return new Guid(hash, bigEndian: true);
}
// ── Query parameters ───────────────────────────────────────────────────
/// <summary>
/// Gets extra query parameters to send to the identity provider.
/// </summary>
/// <remarks>
/// Useful for routing to specific test slices or data centers.
/// </remarks>
internal Dictionary<string, string> ExtraQueryParameters { get; } = [];
}
/// <summary>
/// Base class for client credential entries in an Entra ID application registration.
/// </summary>
/// <remarks>
/// <para>
/// Each subclass corresponds to a specific <c>SourceType</c> in the Microsoft.Identity.Web
/// <c>ClientCredentials</c> configuration array. Use one of the concrete types:
/// </para>
/// <list type="bullet">
/// <item><description><see cref="EntraIdClientSecretCredential"/> — application secret.</description></item>
/// <item><description><see cref="EntraIdFederatedIdentityCredential"/> — FIC with managed identity.</description></item>
/// <item><description><see cref="EntraIdKeyVaultCertificateCredential"/> — certificate from Azure Key Vault.</description></item>
/// <item><description><see cref="EntraIdStoreCertificateCredential"/> — certificate from the certificate store.</description></item>
/// <item><description><see cref="EntraIdFileCertificateCredential"/> — certificate from a file on disk.</description></item>
/// <item><description><see cref="EntraIdSignedAssertionFileCredential"/> — signed assertion file (AKS workload identity).</description></item>
/// </list>
/// </remarks>
public abstract class EntraIdClientCredential
{
/// <summary>
/// Gets the <c>SourceType</c> string used in the Microsoft.Identity.Web configuration.
/// </summary>
public abstract string SourceType { get; }
/// <summary>
/// Emits environment variables for this credential into the given context.
/// </summary>
/// <param name="envVars">The environment variable dictionary to populate.</param>
/// <param name="prefix">The environment variable prefix (e.g., <c>"AzureAd__ClientCredentials__0"</c>).</param>
/// <param name="separator">The separator between configuration keys.</param>
/// <param name="application">The Entra ID application that owns the credential.</param>
/// <param name="consumer">The resource that receives the configuration.</param>
/// <param name="executionContext">The execution context that the configuration is for.</param>
/// <returns><see langword="true"/> when the credential was emitted, or <see langword="false"/> when it does not apply to the consumer.</returns>
internal virtual bool EmitEnvironmentVariables(IDictionary<string, object> envVars, string prefix, string separator, EntraIdApplicationResource application, IResource consumer, DistributedApplicationExecutionContext executionContext)
{
envVars[$"{prefix}{separator}SourceType"] = SourceType;
return true;
}
}
/// <summary>
/// A client secret credential for an Entra ID application.
/// </summary>
/// <remarks>
/// Maps to <c>SourceType = "ClientSecret"</c> in Microsoft.Identity.Web configuration.
/// </remarks>
public sealed class EntraIdClientSecretCredential : EntraIdClientCredential
{
/// <inheritdoc />
public override string SourceType => "ClientSecret";
/// <summary>
/// Gets the client secret as a parameter resource.
/// </summary>
public required ParameterResource ClientSecret
{
get;
init
{
ArgumentNullException.ThrowIfNull(value);
if (!value.Secret)
{
throw new ArgumentException("The client secret parameter must be marked as secret. Use AddParameter with secret: true when creating the parameter.", nameof(ClientSecret));
}
field = value;
}
}
/// <inheritdoc />
internal override bool EmitEnvironmentVariables(IDictionary<string, object> envVars, string prefix, string separator, EntraIdApplicationResource application, IResource consumer, DistributedApplicationExecutionContext executionContext)
{
base.EmitEnvironmentVariables(envVars, prefix, separator, application, consumer, executionContext);
envVars[$"{prefix}{separator}ClientSecret"] = ClientSecret;
return true;
}
}
/// <summary>
/// A federated identity credential (FIC) with managed identity for an Entra ID application.
/// </summary>
/// <remarks>
/// Maps to <c>SourceType = "SignedAssertionFromManagedIdentity"</c> in Microsoft.Identity.Web configuration.
/// The registration must trust a user-assigned managed identity in the same tenant, and the identity must
/// be attached to the workload. This configuration does not create that trust or assign the identity.
/// See <see href="https://learn.microsoft.com/entra/workload-id/workload-identity-federation-config-app-trust-managed-identity"/>.
/// </remarks>
public sealed class EntraIdFederatedIdentityCredential : EntraIdClientCredential
{
/// <summary>
/// Initializes a new instance of the <see cref="EntraIdFederatedIdentityCredential"/> class for a managed identity
/// that Aspire does not manage.
/// </summary>
/// <param name="managedIdentityClientId">The client ID of the user-assigned managed identity that supplies the signed assertion.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="managedIdentityClientId"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="managedIdentityClientId"/> is empty or is not a GUID.</exception>
public EntraIdFederatedIdentityCredential(string managedIdentityClientId)
{
ArgumentNullException.ThrowIfNull(managedIdentityClientId);
if (EntraIdValidation.ValidateManagedIdentityClientId(managedIdentityClientId) is { } error)
{
throw new ArgumentException(error, nameof(managedIdentityClientId));
}
ManagedIdentityClientId = managedIdentityClientId;
}
// Aspire provisions the trust for the managed identity of each referencing resource.
internal EntraIdFederatedIdentityCredential()
{
}
/// <inheritdoc />
public override string SourceType => "SignedAssertionFromManagedIdentity";
/// <summary>
/// Gets the client ID of the user-assigned managed identity that supplies the signed assertion.
/// </summary>
/// <remarks>
/// This is the identity's client ID, not the object (principal) ID used as the federated credential's subject.
/// Microsoft Entra's managed-identity federation scenario supports user-assigned identities only.
/// It is <see langword="null"/> when Aspire resolves the identity, in which case each consuming resource uses its own
/// user-assigned managed identity or the one it was configured with.
/// </remarks>
public string? ManagedIdentityClientId { get; }
/// <summary>
/// Gets or sets the token exchange URL for federated identity credential scenarios.
/// </summary>
/// <remarks>
/// If not specified, defaults to <c>api://AzureADTokenExchange</c>, or the audience of the application's cloud
/// when the credential is backed by a managed identity resource.
/// </remarks>
public string? TokenExchangeUrl { get; set; }
/// <summary>
/// Gets or sets the token exchange authority URL.
/// </summary>
/// <remarks>
/// Used when the issuer for token exchange differs from the application's authority.
/// </remarks>
public string? TokenExchangeAuthority { get; set; }
// The identity's principal ID is the federated credential's subject, and its client ID is what consumers receive.
internal AzureUserAssignedIdentityResource? ManagedIdentity { get; set; }
// When set, the credential trusts and applies to only this compute resource, using the identity it has when published.
internal IResource? ComputeTarget { get; set; }
// No client ID, identity resource or compute resource was supplied, so each consumer's own identity is used.
internal bool UsesConsumerIdentity => ManagedIdentityClientId is null && ManagedIdentity is null && ComputeTarget is null;
// The credential trusts an Aspire-modeled identity, so Aspire can provision the trust.
internal bool IsResourceBacked => ManagedIdentity is not null || ComputeTarget is not null || UsesConsumerIdentity;
internal static AzureUserAssignedIdentityResource? GetConsumerIdentity(IResource consumer)
=> consumer.TryGetLastAnnotation<AppIdentityAnnotation>(out var annotation)
? annotation.IdentityResource as AzureUserAssignedIdentityResource
: null;
internal void ValidateConsumer(EntraIdApplicationResource application, IResource consumer)
{
var consumerIdentity = GetConsumerIdentity(consumer)
?? throw new InvalidOperationException(
$"Resource '{consumer.Name}' references Entra ID application '{application.Name}', which uses a managed identity credential, " +
"but the resource has no user-assigned managed identity. Use a compute environment that supports managed identities, such as AddAzureContainerAppEnvironment, or call WithAzureUserAssignedIdentity.");
if (ManagedIdentity is not null && !ReferenceEquals(consumerIdentity, ManagedIdentity))
{
throw new InvalidOperationException(
$"Resource '{consumer.Name}' references Entra ID application '{application.Name}', which trusts managed identity '{ManagedIdentity.Name}', " +
$"but the resource does not use that identity. Call WithAzureUserAssignedIdentity on '{consumer.Name}' with that identity.");
}
}
private sealed class ManagedIdentityClientIdValue(
EntraIdFederatedIdentityCredential credential,
EntraIdApplicationResource application,
IResource consumer) : IValueProvider, IManifestExpressionProvider, IValueWithReferences, IBicepOutputReferenceProvider
{
private BicepOutputReference ClientId
{
get
{
credential.ValidateConsumer(application, consumer);
return (credential.ManagedIdentity ?? GetConsumerIdentity(consumer))!.ClientId;
}
}
public string ValueExpression => ClientId.ValueExpression;
// While the preparer is discovering dependencies the identity does not exist yet, so there is nothing to reference.
public BicepOutputReference? GetOutputReference()
=> credential.ManagedIdentity is not null || GetConsumerIdentity(consumer) is not null ? ClientId : null;
public IEnumerable<object> References => GetOutputReference() is { } output ? [output] : [];
public ValueTask<string?> GetValueAsync(CancellationToken cancellationToken = default)
=> ((IValueProvider)ClientId).GetValueAsync(cancellationToken);
}
/// <inheritdoc />
internal override bool EmitEnvironmentVariables(IDictionary<string, object> envVars, string prefix, string separator, EntraIdApplicationResource application, IResource consumer, DistributedApplicationExecutionContext executionContext)
{
if (ComputeTarget is not null && !ReferenceEquals(ComputeTarget, consumer))
{
return false;
}
// Managed identities exist only in Azure. Locally the credential has nothing to assert, so a resource-backed
// credential is skipped and another credential, such as a client secret, is used.
if (IsResourceBacked && executionContext.IsRunMode)
{
return false;
}
base.EmitEnvironmentVariables(envVars, prefix, separator, application, consumer, executionContext);
var tokenExchangeUrl = TokenExchangeUrl;
if (IsResourceBacked)
{
// An identity passed in is known now, so its output is used as is. The validation step that runs after the
// Azure resource preparer reports a consumer that does not use it.
if (ManagedIdentity is { } identity)
{
envVars[$"{prefix}{separator}ManagedIdentityClientId"] = identity.ClientId;
}
else
{
// A consumer's own identity does not exist yet: an environment callback runs once and its result is cached,
// and the Azure resource preparer runs it to discover dependencies before it attaches the identity. A
// ReferenceExpression takes its providers when it is created, so this looks the identity up when read.
envVars[$"{prefix}{separator}ManagedIdentityClientId"] = new ManagedIdentityClientIdValue(this, application, consumer);
}
// Sovereign clouds use their own token exchange audience, which Microsoft.Identity.Web reads from TokenExchangeUrl.
if (tokenExchangeUrl is null &&
EntraIdFederation.GetTokenExchangeAudience(application.Instance) is { } audience &&
audience != EntraIdFederation.DefaultTokenExchangeAudience)
{
tokenExchangeUrl = audience;
}
}
else
{
envVars[$"{prefix}{separator}ManagedIdentityClientId"] = ManagedIdentityClientId
?? throw new InvalidOperationException("A managed identity client ID is required.");
}
if (tokenExchangeUrl is not null)
{
envVars[$"{prefix}{separator}TokenExchangeUrl"] = tokenExchangeUrl;
}
if (TokenExchangeAuthority is not null)
{
envVars[$"{prefix}{separator}TokenExchangeAuthority"] = TokenExchangeAuthority;
}
return true;
}
}
/// <summary>
/// A certificate credential from Azure Key Vault for an Entra ID application.
/// </summary>
/// <remarks>
/// Maps to <c>SourceType = "KeyVault"</c> in Microsoft.Identity.Web configuration.
/// </remarks>
public sealed class EntraIdKeyVaultCertificateCredential : EntraIdClientCredential
{
/// <inheritdoc />
public override string SourceType => "KeyVault";
/// <summary>
/// Gets the URL of the Azure Key Vault (e.g., <c>"https://myvault.vault.azure.net"</c>).
/// </summary>
public required string KeyVaultUrl { get; init; }
/// <summary>
/// Gets the name of the certificate in Azure Key Vault.
/// </summary>
public required string CertificateNameInKeyVault { get; init; }
/// <inheritdoc />
internal override bool EmitEnvironmentVariables(IDictionary<string, object> envVars, string prefix, string separator, EntraIdApplicationResource application, IResource consumer, DistributedApplicationExecutionContext executionContext)
{
base.EmitEnvironmentVariables(envVars, prefix, separator, application, consumer, executionContext);
envVars[$"{prefix}{separator}KeyVaultUrl"] = KeyVaultUrl;
envVars[$"{prefix}{separator}KeyVaultCertificateName"] = CertificateNameInKeyVault;
return true;
}
}
/// <summary>
/// A certificate credential from the certificate store for an Entra ID application.
/// </summary>
/// <remarks>
/// <para>
/// Maps to <c>SourceType = "StoreWithThumbprint"</c> or <c>"StoreWithDistinguishedName"</c>
/// in Microsoft.Identity.Web configuration, depending on which identifier is provided.
/// </para>
/// <para>
/// Set exactly one of <see cref="Thumbprint"/> or <see cref="DistinguishedName"/>. Setting both,
/// or neither, causes <see cref="SourceType"/> to throw an <see cref="InvalidOperationException"/>.
/// </para>
/// </remarks>
public sealed class EntraIdStoreCertificateCredential : EntraIdClientCredential
{
/// <inheritdoc />
/// <exception cref="InvalidOperationException">
/// Thrown when neither or both of <see cref="Thumbprint"/> and <see cref="DistinguishedName"/> are set.
/// </exception>
public override string SourceType => (Thumbprint, DistinguishedName) switch
{
(not null, null) => "StoreWithThumbprint",
(null, not null) => "StoreWithDistinguishedName",
(not null, not null) => throw new InvalidOperationException(
$"Only one of {nameof(Thumbprint)} or {nameof(DistinguishedName)} can be set on {nameof(EntraIdStoreCertificateCredential)}."),
_ => throw new InvalidOperationException(
$"Either {nameof(Thumbprint)} or {nameof(DistinguishedName)} must be set on {nameof(EntraIdStoreCertificateCredential)}.")
};
/// <summary>
/// Gets the certificate store path (e.g., <c>"CurrentUser/My"</c> or <c>"LocalMachine/My"</c>).
/// </summary>
public required string StorePath { get; init; }
/// <summary>
/// Gets or sets the certificate thumbprint.
/// </summary>
public string? Thumbprint { get; set; }
/// <summary>
/// Gets or sets the certificate distinguished name (e.g., <c>"CN=MyCert"</c>).
/// </summary>
public string? DistinguishedName { get; set; }
/// <inheritdoc />
internal override bool EmitEnvironmentVariables(IDictionary<string, object> envVars, string prefix, string separator, EntraIdApplicationResource application, IResource consumer, DistributedApplicationExecutionContext executionContext)
{
base.EmitEnvironmentVariables(envVars, prefix, separator, application, consumer, executionContext);
envVars[$"{prefix}{separator}CertificateStorePath"] = StorePath;
if (Thumbprint is not null)
{
envVars[$"{prefix}{separator}CertificateThumbprint"] = Thumbprint;
}
else if (DistinguishedName is not null)
{
envVars[$"{prefix}{separator}CertificateDistinguishedName"] = DistinguishedName;
}
return true;
}
}
/// <summary>
/// A certificate credential from a file on disk for an Entra ID application.
/// </summary>
/// <remarks>
/// Maps to <c>SourceType = "Path"</c> in Microsoft.Identity.Web configuration.
/// Not recommended for production use.
/// </remarks>
public sealed class EntraIdFileCertificateCredential : EntraIdClientCredential
{
/// <inheritdoc />
public override string SourceType => "Path";
/// <summary>
/// Gets the path to the certificate file (e.g., a PFX file).
/// </summary>
public required string FilePath { get; init; }
/// <summary>
/// Gets or sets the password for the certificate file, if password-protected.
/// </summary>
/// <remarks>
/// This is a <see cref="ParameterResource"/> rather than a <see cref="string"/> so the value flows through
/// Aspire's parameter pipeline and is redacted in logs and the dashboard like any other secret.
/// The parameter must be created with <c>secret: true</c>.
/// </remarks>
public ParameterResource? Password
{
get;
set
{
if (value is { Secret: false })
{
throw new ArgumentException("The certificate password parameter must be marked as secret. Use AddParameter with secret: true when creating the parameter.", nameof(Password));
}
field = value;
}
}
/// <inheritdoc />
internal override bool EmitEnvironmentVariables(IDictionary<string, object> envVars, string prefix, string separator, EntraIdApplicationResource application, IResource consumer, DistributedApplicationExecutionContext executionContext)
{
base.EmitEnvironmentVariables(envVars, prefix, separator, application, consumer, executionContext);
envVars[$"{prefix}{separator}CertificateDiskPath"] = FilePath;
if (Password is not null)
{
envVars[$"{prefix}{separator}CertificatePassword"] = Password;
}
return true;
}
}
/// <summary>
/// A signed assertion file credential for an Entra ID application (e.g., AKS workload identity).
/// </summary>
/// <remarks>
/// Maps to <c>SourceType = "SignedAssertionFilePath"</c> in Microsoft.Identity.Web configuration.
/// If <see cref="FilePath"/> is not provided, the <c>AZURE_FEDERATED_TOKEN_FILE</c>
/// environment variable is used.
/// </remarks>
public sealed class EntraIdSignedAssertionFileCredential : EntraIdClientCredential
{
/// <inheritdoc />
public override string SourceType => "SignedAssertionFilePath";
/// <summary>
/// Gets or sets the path to the signed assertion file on disk.
/// </summary>
/// <remarks>
/// If not specified, defaults to the <c>AZURE_FEDERATED_TOKEN_FILE</c> environment variable.
/// </remarks>
public string? FilePath { get; set; }
/// <inheritdoc />
internal override bool EmitEnvironmentVariables(IDictionary<string, object> envVars, string prefix, string separator, EntraIdApplicationResource application, IResource consumer, DistributedApplicationExecutionContext executionContext)
{
base.EmitEnvironmentVariables(envVars, prefix, separator, application, consumer, executionContext);
if (FilePath is not null)
{
envVars[$"{prefix}{separator}SignedAssertionFileDiskPath"] = FilePath;
}
return true;
}
}