Introducing the next era of Duende IdentityServer.

Read our CEO’s announcement

Testing Duende IdentityServer Login Flow With a .NET 10 dotnet run app.cs

A profile photo of Khalid Abuhakmeh in black and white
Khalid Abuhakmeh
Two blue circles

We recently attended NDC Oslo and had a great time chatting with current and future Duende IdentityServer customers.
During the event, an individual approached us with an interesting dilemma and wondered if we could help them solve it.

They wanted to automate a UI test against their deployed instance of Duende IdentityServer but avoid using an end-to-end
library like Selenium or Playwright, because those libraries depend on a headless browser like Chromium or Firefox. Can
we test that first-party logins work properly entirely through .NET Code?

Luckily, brilliant folks work at Duende, including our Director of Engineering, Damian Hickey, who was able to write a
simple console application simulating a browser. Still, with the recent announcement of .NET 10’s
dotnet run
app.cs, we thought we could provide
this value through a script that is easily editable and runnable from any environment with .NET 10 available.

What is dotnet run app.cs?

Introduced in .NET 10 preview 4, developers no longer need to create projects to run C# code. You can think of these
snippets as self-contained scripts.

“You can now run a C# file directly using dotnet run app.cs. This means you no longer need to create a project file
or scaffold a whole application to run a quick script, test a snippet, or experiment with an idea.”
– Damian Edwards

Let’s look at a straightforward use case, a Hello World application with a NuGet dependency.

Csharp

#:package Spectre.Console@0.5.*

using Spectre.Console;

AnsiConsole.MarkupLine("[purple]Hello[/], [yellow]World![/]");

Running the command dotnet run hello.cs results in the following output:

dotnet run hello.cs output

These files can set values typically reserved for projects using the #: directive with an additional keyword and
value. These keywords include sdk, property, and package. You can read more about these directives in
the official blog post announcement.

Duende's Testing Code Snippet

As mentioned, you can run this script in a CI/CD environment or pass it to a DevOps team member or practitioner to get a
health check on a Duende IdentityServer deployment.

For this script to work, we need three essential pieces of information from the operator.

  1. An authentication-protected URL
  2. A username
  3. A password

For functionality, we'll also depend on Spectre.Console for a nicer user experience and
add AngleSharp for some HTML help.

Let’s write the script and then go over the most essential elements.

Csharp

#:package Spectre.Console@0.50.0
#:package Spectre.Console.Cli@0.50.0
#:package AngleSharp@1.3.0

using System.ComponentModel;
using System.Net;
using AngleSharp.Html.Parser;
using Spectre.Console;
using Spectre.Console.Cli;

var app = new CommandApp<LoginCommand>();
return app.Run(args);

public class LoginCommand : AsyncCommand<LoginCommand.Settings>
{
    public sealed class Settings : CommandSettings
    {
        // web address
        [Description("The interactive client URL that issues an auth challenge")]
        [CommandArgument(0, "<interactiveClientUrl>")]

        public string? InteractiveClientUrl { get; init; }

        // username
        [Description("username")]
        [CommandOption("-u|--username")]
        [DefaultValue("alice")]
        public string? Username { get; init; }

        // password
        [Description("password")]
        [CommandOption("-p|--password")]
        [DefaultValue("alice")]
        public string? Password { get; init; }
    }

    public override async Task<int> ExecuteAsync(CommandContext context, Settings settings)
    {
        try
        {
            var uri = new Uri(settings.InteractiveClientUrl!);
            AnsiConsole.MarkupLine($"[purple]💻 Logging into[/] [yellow]{uri.Host}[/]");

            using var client = new HttpClient(new CookieHandler())
            {
                // get base address from uri
                BaseAddress = new Uri($"{uri.Scheme}://{uri.Host}:{uri.Port}/")
            };
            var response = await client.GetAsync(uri.AbsolutePath);
            response.EnsureSuccessStatusCode();

            var loginBody = await response.Content.ReadAsStringAsync();

            var parser = new HtmlParser();
            var document = await parser.ParseDocumentAsync(loginBody);
            var antiForgeryToken = document.QuerySelector("input[name='__RequestVerificationToken']")
                ?.GetAttribute("value")!;
            var returnUrl = document.QuerySelector("input[name='Input.ReturnUrl']")?.GetAttribute("value")!;
            var formAction = document.QuerySelector("form")?.GetAttribute("action");

            // Prepare the form data for login
            var formItems = new Dictionary<string, string>
            {
                { "Input.Username", "alice" },
                { "Input.Password", "alice" },
                { "Input.ReturnUrl", returnUrl },
                { "Input.Button", "login" },
                { "__RequestVerificationToken", antiForgeryToken }
            };

            var loginContent = new FormUrlEncodedContent(formItems);
            var loginResponse = await client.PostAsync(formAction, loginContent);

            if (loginResponse.RequestMessage?.RequestUri?.Host != uri.Host)
            {
                // If the login is unsuccessful, the server will not redirect to original host
                var errorBody = await loginResponse.Content.ReadAsStringAsync();
                AnsiConsole.MarkupLine("[red]🛑 Failed: [/]");
                AnsiConsole.MarkupLine(errorBody);
                return -1;
            }

            // If the login is successful, the server will redirect to home page
            // and the RequestUri will match the base address
            AnsiConsole.MarkupLine("[green]🌟 Success![/]");
            return 0;
        }
        catch (Exception e)
        {
            AnsiConsole.MarkupLine("[red]🛑 Failed:[/]");
            AnsiConsole.WriteException(e);
            return -1;
        }
    }
}

internal class CookieHandler : DelegatingHandler
{
    public CookieContainer CookieContainer { get; } = new();

    public CookieHandler()
    {
        InnerHandler = new SocketsHttpHandler();
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var cookieHeader = CookieContainer.GetCookieHeader(request.RequestUri!);
        if (!string.IsNullOrEmpty(cookieHeader))
        {
            request.Headers.Add("Cookie", cookieHeader);
        }

        var response = await base.SendAsync(request, cancellationToken);

        if (response.Headers.Contains("Set-Cookie"))
        {
            var responseCookieHeader = string.Join(",", response.Headers.GetValues("Set-Cookie"));
            CookieContainer.SetCookies(request.RequestUri!, responseCookieHeader);
        }

        return response;
    }
}

The most essential element of our script is the CookieHandler, which helps us simulate a web client’s behavior by
reading and setting cookies on each request. Since ASP.NET Core relies on cookies for session management and antiforgery
token validation, this script would not work without getting this part right.

Once visiting the page, we need to parse the HTML to find the input fields that comprise the typical login form. This
script is written to assume you’re still using the Duende IdentityServer template field names. Once we find the inputs,
we can parse the necessary values and create a valid form data collection to post to our login endpoint.

Finally, we checked to see that the identity provider had redirected us back to the original host that issued our
authentication challenge, which is the standard behavior for OpenID Connect implementations.

Now that our script is ready, we can run the following command.

Bash

dotnet run Program.cs https://demo.duendesoftware.com/grants/

The /grants page is a public page that requires a user session on our Demo site, so that it will force an
authentication challenge. Also, our demo user of alice/alice is the default username and password combination.

If all goes well, we should see a successful result in our console output.

dotnet run for IdentityServer

Conclusion

This new .NET 10 feature, specifically the ability to run single .cs files with dotnet run app.cs, offers a more
streamlined approach for quick tests and experimentation. In this case, developers can use the script we provided to do
a quick health check to see that first-party logins are still functioning as expected. Overall, this addition provides a
convenient and efficient way to explore and test C# code, Duende IdentityServer, or open-source packages.

If you enjoyed this post, we’d love it if you left us a comment and shared it with friends and colleagues.