Automating AWS SSO Login for Aspire AppHost Startup

14 minute read

If you use AWS SSO (IAM Identity Center) for local development, you have probably run into this situation.

You start your Aspire application, everything begins to spin up, and then somewhere in the logs an AWS client throws an exception because your SSO session has expired. At that point the fix is simple:

$ aws sso login --profile <profile-name>

Then restart the application and try again.

The problem is not that the solution is difficult; it is simply a step that is very easy to forget.

AWS SSO sessions expire regularly and, if your application relies on AWS services, the error can easily pull you out of the zone. Even worse, when the session is no longer valid the error messages are not always obvious.

If you are using Aspire to orchestrate the startup of the entire application environment, we can instruct the AppHost to verify that the AWS session is valid before anything else begins.

In this post we will build, step by step, a small extension for the Aspire AppHost that:

  • checks whether the configured AWS profile is using SSO
  • verifies that the session is still valid
  • automatically runs aws sso login when the session has expired
  • conveniently prints the device code in the Aspire logs

By the end of the post, this annoying “remember to run this command first” step will be handled automatically by the application.

This post assumes that:

In addition to configuring the AWS profile used by the application, the Aspire.Hosting.AWS package also brings in the AWS SDK for .NET dependencies needed for credential management and SSO support.

Validating the AWS profile

Before we can check whether an AWS SSO session is still valid, we need to make sure the configured AWS profile actually exists and that it is an SSO-based profile.

The AWS SDK can read profiles from the shared AWS configuration on your machine, the same configuration used by the AWS CLI.

We can use CredentialProfileStoreChain to resolve the configured profile and verify that it is an SSO profile.

static SSOAWSCredentials GetSsoCredentials(string profileName)
{
    var chain = new CredentialProfileStoreChain();

    if (!chain.TryGetAWSCredentials(profileName, out var credentials))
    {
        throw new InvalidOperationException(
          $"AWS profile '{profileName}' not found in shared config.");
    }

    if (credentials is not SSOAWSCredentials ssoCredentials)
    {
        throw new InvalidOperationException(
          $"Profile '{profileName}' is not an AWS SSO/IAM Identity Center profile.");
    }

    return ssoCredentials;
}

This gives us two useful validations up front.

First, it fails early if the named profile does not exist at all.

Second, it ensures that the profile is backed by SSOAWSCredentials. This matters because the rest of the extension relies on the SSO login flow provided by the AWS CLI. If the profile uses static credentials or another authentication method, there is nothing for the extension to renew.

At this point we know that:

  • the AWS profile exists
  • the profile uses AWS SSO
  • we have access to the SSOAWSCredentials instance needed for the next step

In the next step we will use that object to verify whether the current SSO session is still valid.

Validating the SSO session programmatically

The next step is to verify whether the current SSO session is still valid.

The AWS SDK already exposes everything we need for this check. Calling GetCredentialsAsync() on the SSOAWSCredentials instance will attempt to resolve the current credentials using the cached SSO session.

await ssoCredentials.GetCredentialsAsync();

If the session is still valid, the call succeeds and returns temporary AWS credentials. In that case we can simply continue with the application startup.

If the session has expired, the SDK throws an AmazonClientException. The exception message typically contains indicators such as:

  • SSO Token has expired
  • The SSO session associated with this profile has expired
  • SSO token cannot be refreshed
  • No valid SSO Token could be found.

This exception becomes the signal that the SSO session needs to be renewed.

In the extension we can wrap the call in a try/catch block and trigger a login flow only when the failure indicates an expired SSO token.

try
{
    await ssoCredentials.GetCredentialsAsync();
}
catch (AmazonClientException ex) when (IsExpiredSso(ex))
{
    // session expired, we will start a new login flow
}

static bool IsExpiredSso(Exception ex)
{
    var s = ex.ToString();
    return s.Contains("SSO Token has expired", StringComparison.OrdinalIgnoreCase)
        || s.Contains("can not be refreshed", StringComparison.OrdinalIgnoreCase)
        || s.Contains("The SSO session associated with this profile has expired", StringComparison.OrdinalIgnoreCase)
        || s.Contains("No valid SSO Token could be found.", StringComparison.OrdinalIgnoreCase);
}

This approach has a useful property: if the session is already valid, nothing special happens and the application continues to start normally.

If the session is no longer valid, we detect the condition early and can initiate a new login before the rest of the Aspire environment starts.

In the next step we will see how to start a new SSO login session programmatically by invoking the AWS CLI.

Starting a new SSO session programmatically

If the SSO session has expired, the AWS SDK alone cannot initiate a new login flow. The SDK can refresh cached credentials, but when the underlying SSO session is gone, the only supported way to authenticate again is through the AWS CLI.

That means our extension needs to invoke the same command a developer would normally run manually:

aws sso login --profile <profile-name>

To do this from .NET we can start the CLI as a subprocess and wait for it to complete. While the command runs, we stream its output into the Aspire logs so the user can follow the login flow.

The core of the implementation looks like this:

static async Task RunLoginAsync(string profileName, ILogger logger, CancellationToken ct)
{
    var psi = new ProcessStartInfo
    {
        FileName = "aws",
        Arguments = $"sso login --profile {profileName}",
        UseShellExecute = false,
        RedirectStandardOutput = true,
        RedirectStandardError = true,
        CreateNoWindow = true
    };

    using var process = new Process
    {
        StartInfo = psi,
        EnableRaisingEvents = true
    };

    var exitTcs = new TaskCompletionSource<int>(
        TaskCreationOptions.RunContinuationsAsynchronously);

    process.Exited += (_, _) => exitTcs.TrySetResult(process.ExitCode);

    process.OutputDataReceived += (_, e) =>
    {
        if (!string.IsNullOrWhiteSpace(e.Data))
            logger.LogDebug("[aws sso login] {Line}", e.Data);
    };

    process.ErrorDataReceived += (_, e) =>
    {
        if (!string.IsNullOrWhiteSpace(e.Data))
            logger.LogWarning("[aws sso login][stderr] {Line}", e.Data);
    };

    if (!process.Start())
        throw new InvalidOperationException(
            "Failed to start aws CLI. Is AWS CLI v2 installed and on PATH?");

    process.BeginOutputReadLine();
    process.BeginErrorReadLine();

    await using var reg = ct.Register(() =>
    {
        if (!process.HasExited)
            process.Kill(entireProcessTree: true);
    });

    var exitCode = await exitTcs.Task;

    if (exitCode != 0)
        throw new InvalidOperationException(
            $"aws sso login failed with exit code {exitCode}");
}

A few implementation details are worth pointing out:

  • UseShellExecute = false allows us to capture the CLI output.
  • RedirectStandardOutput and RedirectStandardError let us forward the CLI logs into the Aspire logger.
  • CancellationToken support ensures the process is terminated if the AppHost shuts down during login.
  • A non-zero exit code is treated as a failure, preventing the application from starting with invalid credentials.

At this point the extension can automatically trigger the same login flow developers would normally start manually.

In the next step we will make the login experience slightly smoother by extracting and highlighting the device authorization code printed by the AWS CLI.

Improving the device authorization flow

When aws sso login starts an interactive login, it usually prints a message like this:

Then enter the code:
ABCD-EFGH

That output is perfectly fine when you run the command directly in a terminal. Inside an Aspire startup flow, though, it helps to make the code much more visible in the logs.

We can do that by watching the CLI standard output, detecting the Then enter the code: line, and treating the next non-empty line as the device code.

Here is the relevant part of the implementation:

var expectCodeNextNonEmptyLine = false;
var deviceCodeLogged = false;

process.OutputDataReceived += (_, e) =>
{
    if (string.IsNullOrWhiteSpace(e.Data))
        return;

    var line = e.Data.Trim();

    logger.LogDebug("[aws sso login] {Line}", line);

    if (line.Equals("Then enter the code:", StringComparison.OrdinalIgnoreCase))
    {
        expectCodeNextNonEmptyLine = true;
        return;
    }

    if (expectCodeNextNonEmptyLine && !deviceCodeLogged)
    {
        logger.LogWarning("AWS SSO device code: {Code}", line);
        deviceCodeLogged = true;
        expectCodeNextNonEmptyLine = false;
    }
};

The logic is intentionally simple:

  • when the CLI prints Then enter the code:, we remember that the next non-empty line is important
  • the first non-empty line after that is logged separately as the device code
  • once the code has been captured, we stop looking for it again

This small addition makes the login flow much easier to follow when you are watching Aspire logs during startup. The normal CLI output is still there, but the device code now stands out clearly.

With this piece in place, we now have everything needed to validate the session and trigger a new login flow when required.

In the next step we will hook this logic into the Aspire startup lifecycle so it runs automatically before the application starts.

Hooking into the Aspire startup lifecycle

Now that we can validate the AWS profile, check whether the SSO session is still valid, and trigger a new login when needed, we need to decide when this should happen.

Aspire already gives us the right extension point through its AppHost eventing system.

The built-in AppHost lifecycle events run in this order:

  • BeforeStartEvent
  • ResourceEndpointsAllocatedEvent
  • AfterResourceCreatedEvent

For this scenario, BeforeStartEvent is exactly what we want. It runs before the AppHost starts, which means we can validate the AWS session before the rest of the application environment is initialized. Aspire exposes these subscriptions through builder.Eventing.Subscribe<TEvent>(...), and the event callback can access dependency injection through @event.Services.

This lets us wire the authentication check into startup like this:

builder.Eventing.Subscribe<BeforeStartEvent>(async (@event, cancellationToken) =>
{
    var logger = @event.Services.GetRequiredService<ILogger<DistributedApplication>>();

    var ssoCredentials = GetSsoCredentials(profileName);

    try
    {
        await ssoCredentials.GetCredentialsAsync();
        logger.LogInformation("Successfully authenticated to AWS with profile '{Profile}'", profileName);
    }
    catch (AmazonClientException ex) when (IsExpiredSso(ex))
    {
        logger.LogWarning(
            "AWS credentials for profile '{Profile}' are not valid. Attempting SSO login...",
            profileName);

        await RunLoginAsync(profileName, logger, cancellationToken);

        logger.LogInformation("Successfully authenticated to AWS with profile '{Profile}'", profileName);
    }
});

There are a few nice properties here:

  • the check runs before the distributed application starts
  • the handler has access to the AppHost service provider, so retrieving a logger is straightforward
  • the CancellationToken can be forwarded to the login process
  • if anything fails, startup fails early instead of surfacing confusing AWS errors later

At this point the behavior is already in place, but the code is still just a block sitting in Program.cs (or in the AppHost project file in newer Aspire templates).

In the next step we will wrap it in an extension method so it becomes a small, reusable AppHost builder API.

Extending the AppHost builder

At this point we have all the moving parts:

  • validate the configured profile
  • verify whether the SSO session is still valid
  • trigger aws sso login when needed
  • improve the device authorization flow in the logs
  • hook everything into BeforeStartEvent

The next step is to wrap that behavior in a small extension method on IDistributedApplicationBuilder.

This keeps the logic out of Program.cs and gives us a clean API that reads naturally in the AppHost:

builder.EnsureAwsSsoLogin(aws);

The extension method starts like this:

public static class DistributedApplicationBuilderExtensions
{
    public static IDistributedApplicationBuilder EnsureAwsSsoLogin(
        this IDistributedApplicationBuilder builder,
        IAWSSDKConfig config)
    {
        if (config.Profile is null)
        {
            throw new InvalidOperationException(
                "AWS profile is not set in configuration.");
        }

        builder.Eventing.Subscribe<BeforeStartEvent>(async (@event, cancellationToken) =>
        {
            var logger = @event.Services
                .GetRequiredService<ILogger<DistributedApplication>>();

            var ssoCredentials = GetSsoCredentials(config.Profile);

            try
            {
                await ssoCredentials.GetCredentialsAsync();

                logger.LogInformation(
                    "Successfully authenticated to AWS with profile '{Profile}'",
                    config.Profile);
            }
            catch (AmazonClientException ex) when (IsExpiredSso(ex))
            {
                logger.LogWarning(
                    "AWS credentials for profile '{Profile}' are not valid. Attempting SSO login...",
                    config.Profile);

                await RunLoginAsync(config.Profile, logger, cancellationToken);

                logger.LogInformation(
                    "Successfully authenticated to AWS with profile '{Profile}'",
                    config.Profile);
            }
        });

        return builder;
    }
}

There are a couple of important design choices here.

First, the extension accepts the IAWSSDKConfig instance you already configure in the AppHost through AddAWSSDKConfig(). That means it does not need its own parallel configuration model.

Second, the method returns the original IDistributedApplicationBuilder, so it behaves like a normal Aspire configuration extension and can participate in fluent setup.

Third, the method validates config.Profile immediately. If no profile is configured, there is no point in subscribing to the startup event at all.

This shape makes the feature feel like part of the AppHost rather than a loose helper function dropped into Program.cs.

In the next section we can put everything together and show the final implementation as one complete snippet.

Putting everything together

At this point we have built all the pieces required for the extension:

  • validate that the configured AWS profile exists and uses SSO
  • check whether the SSO session is still valid
  • trigger aws sso login when the session has expired
  • surface the device authorization code clearly in the logs
  • hook the logic into the Aspire startup lifecycle

The complete implementation is available as a GitHub Gist:

https://gist.github.com/Kralizek/7c5ade067cbbd37ec2ee55f36bc4d86f

For convenience, the full code is also included below.

using System.Diagnostics;
using System.Text;

using Amazon.Runtime;
using Amazon.Runtime.CredentialManagement;

using Aspire.Hosting.AWS;

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace AppHost;

public static class DistributedApplicationBuilderExtensions
{
    public static IDistributedApplicationBuilder EnsureAwsSsoLogin(
        this IDistributedApplicationBuilder builder,
        IAWSSDKConfig config)
    {
        if (config.Profile is null)
        {
            throw new InvalidOperationException("AWS profile is not set in configuration.");
        }

        builder.Eventing.Subscribe<BeforeStartEvent>(async (@event, cancellationToken) =>
        {
            var logger = @event.Services.GetRequiredService<ILogger<DistributedApplication>>();

            var chain = new CredentialProfileStoreChain();

            if (!chain.TryGetAWSCredentials(config.Profile, out var credentials))
            {
                throw new InvalidOperationException(
                    $"AWS profile '{config.Profile}' not found in shared config.");
            }

            if (credentials is not SSOAWSCredentials sso)
            {
                throw new InvalidOperationException(
                    $"Profile '{config.Profile}' is not an AWS SSO/IAM Identity Center profile.");
            }

            try
            {
                await sso.GetCredentialsAsync();

                logger.LogInformation(
                    "Successfully authenticated to AWS with profile '{Profile}'",
                    config.Profile);
            }
            catch (AmazonClientException ex) when (IsExpiredSso(ex))
            {
                logger.LogWarning(
                    "AWS credentials for profile '{Profile}' are not valid. Attempting SSO login...",
                    config.Profile);

                await RunLoginAsync(config.Profile, logger, cancellationToken);

                logger.LogInformation(
                    "Successfully authenticated to AWS with profile '{Profile}'",
                    config.Profile);
            }
        });

        return builder;
    }

    static bool IsExpiredSso(Exception ex)
    {
        var s = ex.ToString();

        return s.Contains("SSO Token has expired", StringComparison.OrdinalIgnoreCase)
            || s.Contains("can not be refreshed", StringComparison.OrdinalIgnoreCase)
            || s.Contains("The SSO session associated with this profile has expired", StringComparison.OrdinalIgnoreCase)
            || s.Contains("No valid SSO Token could be found.", StringComparison.OrdinalIgnoreCase);
    }

    static async Task RunLoginAsync(string profileName, ILogger logger, CancellationToken ct)
    {
        var psi = new ProcessStartInfo
        {
            FileName = "aws",
            Arguments = $"sso login --profile {profileName}",
            UseShellExecute = false,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            CreateNoWindow = true
        };

        using var p = new Process();
        p.StartInfo = psi;
        p.EnableRaisingEvents = true;

        var stderr = new StringBuilder();

        var expectCodeNextNonEmptyLine = false;
        var deviceCodeLogged = false;

        var exitTcs = new TaskCompletionSource<int>(
            TaskCreationOptions.RunContinuationsAsynchronously);

        p.Exited += (_, _) => exitTcs.TrySetResult(p.ExitCode);

        p.OutputDataReceived += (_, e) =>
        {
            if (string.IsNullOrWhiteSpace(e.Data))
                return;

            var line = e.Data.Trim();

            logger.LogDebug("[aws sso login]: {Line}", line);

            if (line.Equals("Then enter the code:", StringComparison.OrdinalIgnoreCase))
            {
                expectCodeNextNonEmptyLine = true;
                return;
            }

            if (expectCodeNextNonEmptyLine && !deviceCodeLogged)
            {
                logger.LogWarning("AWS SSO device code: {Code}", line);
                deviceCodeLogged = true;
                expectCodeNextNonEmptyLine = false;
            }
        };

        p.ErrorDataReceived += (_, e) =>
        {
            if (e.Data is null)
                return;

            stderr.AppendLine(e.Data);

            if (!string.IsNullOrWhiteSpace(e.Data))
                logger.LogWarning("[aws sso login][stderr] {Line}", e.Data.TrimEnd());
        };

        if (!p.Start())
        {
            throw new InvalidOperationException(
                "Failed to start aws CLI. Is AWS CLI v2 installed and on PATH?");
        }

        p.BeginOutputReadLine();
        p.BeginErrorReadLine();

        await using var reg = ct.Register(() =>
        {
            try
            {
                if (!p.HasExited)
                    p.Kill(entireProcessTree: true);
            }
            catch { }
        });

        var exitCode = await exitTcs.Task;

        if (exitCode != 0)
        {
            throw new InvalidOperationException(
                $"aws sso login failed (exit {exitCode}): {stderr}");
        }
    }
}

Once this file is added to your AppHost project, the extension can be used like this:

var aws = builder.AddAWSSDKConfig()
    .WithProfile("YourApplication")
    .WithRegion(Amazon.RegionEndpoint.EUNorth1);

builder.EnsureAwsSsoLogin(aws);

With this in place, the AppHost verifies the AWS SSO session before starting the distributed application. If the session has expired, it automatically triggers aws sso login and waits until authentication completes.

Limitations

This approach is designed for interactive development environments, not for production or automation.

A few assumptions are baked into the implementation:

  • AWS CLI v2 must be installed and available in the system PATH
  • the AWS SSO profile must already be configured
  • the application must run in an interactive environment where the login flow can complete

This means the extension is not appropriate for CI pipelines or production environments.

In those cases you should rely on non-interactive credential mechanisms such as:

  • IAM roles
  • environment-based credentials
  • workload identity integrations

Another limitation is that detecting an expired SSO session currently relies on inspecting the exception message returned by the AWS SDK. This is not ideal, but it is currently the most reliable way to detect the condition that requires a new login.

Despite these limitations, the approach works well for the scenario it targets: local development.

Closing thoughts

AWS SSO is a great alternative to long-lived access keys, but it does introduce a small amount of friction during development.

By letting the AppHost enforce the login step automatically, we remove another small interruption from the development workflow and let Aspire do what it already does best: orchestrating the environment your application depends on.

Recap

In this post we built a small extension for the Aspire AppHost that:

  • validates the configured AWS profile
  • checks whether the SSO session is still valid
  • automatically runs aws sso login when needed
  • improves the device authorization flow by highlighting the code in the logs
  • hooks the whole process into the Aspire startup lifecycle

With this in place, your Aspire application will ensure that the AWS session is valid before the rest of the environment starts.

Support this blog

If you liked this article, consider supporting this blog by buying me a pizza!