File: EntraIdEndpointRedirectUri.cs
Web Access
Project: src\src\Aspire.Hosting.Azure.EntraId\Aspire.Hosting.Azure.EntraId.csproj (Aspire.Hosting.Azure.EntraId)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIRECOMPUTE002 // IComputeEnvironmentResource.GetHostAddressExpression is experimental.
 
using Aspire.Hosting.ApplicationModel;
 
namespace Aspire.Hosting.Azure;
 
/// <summary>
/// A redirect URI whose value is computed from other model values when the registration is provisioned.
/// </summary>
internal interface IEntraIdDerivedRedirectUri
{
    ReferenceExpression CreateExpression();
}
 
/// <summary>
/// A redirect URI built from a host name supplied by a parameter and a caller-supplied callback path.
/// </summary>
internal sealed class EntraIdHostRedirectUri(ParameterResource host, string path) : IEntraIdDerivedRedirectUri
{
    // The value is a host name (for example, app.contoso.com), so the scheme is always https.
    public ReferenceExpression CreateExpression() => ReferenceExpression.Create($"https://{host}{path}");
}
 
/// <summary>
/// A redirect URI built from a resource endpoint and a caller-supplied callback path.
/// </summary>
internal sealed class EntraIdEndpointRedirectUri : IEntraIdDerivedRedirectUri
{
    private readonly bool _isPublishMode;
    private readonly RunModeOrigin? _runModeOrigin;
 
    public EntraIdEndpointRedirectUri(EndpointReference endpoint, string path, bool isPublishMode)
    {
        Endpoint = endpoint;
        Path = path;
        _isPublishMode = isPublishMode;
 
        if (!isPublishMode)
        {
            _runModeOrigin = new RunModeOrigin(endpoint);
            endpoint.Resource.Annotations.Add(new ResourceUrlsCallbackAnnotation(_runModeOrigin.CaptureUrl));
        }
    }
 
    public EndpointReference Endpoint { get; }
 
    public string Path { get; }
 
    public ReferenceExpression CreateExpression()
    {
        // The callback path is configured by the consuming application (for example, Microsoft.Identity.Web's
        // CallbackPath), so it can't be discovered from the endpoint and is supplied by the AppHost.
        if (!_isPublishMode)
        {
            return ReferenceExpression.Create($"{_runModeOrigin!}{Path}");
        }
 
        // Publish/deploy: ask the compute environment that hosts the resource for its public host name, the same way
        // Azure Front Door resolves origin host names. Deployed endpoints are always served over HTTPS.
        if (!ComputeEnvironmentEndpointResolver.TryGetEffectiveComputeEnvironment(Endpoint.Resource, out var computeEnvironment))
        {
            throw new InvalidOperationException(
                $"Resource '{Endpoint.Resource.Name}' does not have a compute environment. " +
                "Ensure a compute environment (for example, Azure Container Apps or Azure App Service) is configured in the application model " +
                "or register the redirect URI with an explicit URL.");
        }
 
        var host = computeEnvironment.GetHostAddressExpression(Endpoint);
        return ReferenceExpression.Create($"https://{host}{Path}");
    }
 
    /// <summary>
    /// Resolves the scheme, host, and port that users browse to when running locally.
    /// </summary>
    /// <remarks>
    /// The allocated endpoint is always <c>localhost</c>, but when the endpoint targets a <c>*.localhost</c> host or the
    /// dashboard uses a <c>*.dev.localhost</c> name, the orchestrator makes a friendly URL such as
    /// <c>https://web-myapp.dev.localhost:7081</c> the resource's primary URL. Microsoft Entra ID matches the host
    /// exactly for such names (only plain <c>localhost</c> ignores the port), so the redirect URI must use the same URL
    /// the user navigates to. The orchestrator computes that URL when it runs the resource's URL callbacks, which
    /// happens after the endpoint is allocated, so this waits for the callback.
    /// </remarks>
    private sealed class RunModeOrigin(EndpointReference endpoint) : IValueProvider, IManifestExpressionProvider
    {
        private readonly TaskCompletionSource<string> _origin = new(TaskCreationOptions.RunContinuationsAsynchronously);
 
        public string ValueExpression => endpoint.Property(EndpointProperty.Url).ValueExpression;
 
        public void CaptureUrl(ResourceUrlsCallbackContext context)
        {
            // The primary URL is the one shown in the summary; a *.localhost URL replaces the plain localhost one there.
            // Endpoint names are only unique within a resource, and WithUrlForEndpoint can add another resource's
            // endpoint link to this list, so match the owning resource as well.
            var urls = context.Urls
                .Where(u => u.Endpoint is { } e
                    && string.Equals(e.Resource.Name, endpoint.Resource.Name, StringComparison.Ordinal)
                    && string.Equals(e.EndpointName, endpoint.EndpointName, StringComparison.OrdinalIgnoreCase))
                .ToList();
            var primary = urls.FirstOrDefault(u => u.DisplayLocation != UrlDisplayLocation.DetailsOnly) ?? urls.FirstOrDefault();
 
            // The orchestrator turns relative URLs (for example, "/swagger" set by WithUrlForEndpoint) into absolute
            // ones against the allocated endpoint only after every callback has run, so mirror that here. Parsing a
            // relative URL directly would produce a file URI on some platforms.
            var allocatedUrl = endpoint.EndpointAnnotation.AllocatedEndpoint?.UriString;
            var url = primary?.Url;
            if (url is not null && url.StartsWith('/') && allocatedUrl is not null)
            {
                url = allocatedUrl.TrimEnd('/') + url;
            }
 
            // A callback may have removed every displayed URL for this endpoint. The endpoint is still allocated, so
            // complete with its allocated URL instead of leaving each resolution to wait for the fallback timeout.
            if (url is not null && Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https")
            {
                _origin.TrySetResult(uri.GetLeftPart(UriPartial.Authority));
            }
            else if (allocatedUrl is not null && Uri.TryCreate(allocatedUrl, UriKind.Absolute, out var allocatedUri))
            {
                _origin.TrySetResult(allocatedUri.GetLeftPart(UriPartial.Authority));
            }
        }
 
        public async ValueTask<string?> GetValueAsync(CancellationToken cancellationToken = default)
        {
            // Resolving the endpoint waits for allocation. Use it as the fallback when no URL callback runs for the
            // endpoint (for example, a non-HTTP endpoint), so provisioning never waits indefinitely.
            var allocated = await endpoint.Property(EndpointProperty.Url).GetValueAsync(cancellationToken).ConfigureAwait(false);
 
            try
            {
                return await _origin.Task.WaitAsync(TimeSpan.FromSeconds(30), cancellationToken).ConfigureAwait(false);
            }
            catch (TimeoutException)
            {
                return allocated;
            }
        }
    }
}