Testing Duende IdentityServer Login Flow With a .NET 10 dotnet run app.cs
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.
#: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:
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.
- An authentication-protected URL
- A username
- 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.
#: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.
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.
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.