• Products
    • IdentityServer
    • IdentityServer for Redistribution
    • Backend for Frontend (BFF) Security Framework
  • Documentation
  • Training
  • Resources
    • Company Blog
    • Featured Articles
    • About
      • Company
      • Partners
      • Careers
      • Contact
    Duende Software Blog
    • Products
      • IdentityServer
      • IdentityServer for Redistribution
      • Backend for Frontend (BFF) Security Framework
      • Open Source
    • Documentation
    • Training
    • Resources
      • Company Blog

        Stay up-to-date with the latest developments in identity and access management.

      • Featured Articles
      • About
        • Company
        • Partners
        • Careers
        • Contact
      • Start for free
        Contact sales

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

      Khalid Abuhakmeh Customer Success Engineer at Duende Software Khalid Abuhakmeh

      published on June 3, 2025

      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:

      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.

      #: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.

      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.

      Duende logo

      Products

      • IdentityServer
      • IdentityServer for Redistribution
      • Backend for Frontend (BFF)
      • IdentityModel
      • Access Token Management
      • IdentityModel OIDC Client

      Community

      • Documentation
      • Company Blog
      • GitHub Discussions

      Company

      • Company
      • Partners
      • Training
      • Quickstarts
      • Careers
      • Contact

      Subscribe to our newsletter

      Stay up-to-date with the latest developments in identity and access management.

      Copyright © 2020-2025 Duende Software. All rights reserved.

      Privacy Policy | Terms of Service