diff --git a/Directory.Packages.props b/Directory.Packages.props
index 6e44dfe59..6e6068542 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -30,9 +30,9 @@ that supports the target frameworks our products target (8, 9, 10) -->
-
-
-
+
+
+
@@ -125,6 +125,8 @@ that supports the target frameworks our products target (8, 9, 10) -->
+
+
diff --git a/identity-server/clients/src/ConsoleResourceIndicators/ConsoleResourceIndicators.csproj b/identity-server/clients/src/ConsoleResourceIndicators/ConsoleResourceIndicators.csproj
index a77f31eac..b9616fdfd 100644
--- a/identity-server/clients/src/ConsoleResourceIndicators/ConsoleResourceIndicators.csproj
+++ b/identity-server/clients/src/ConsoleResourceIndicators/ConsoleResourceIndicators.csproj
@@ -9,6 +9,8 @@
+
+
diff --git a/identity-server/clients/src/ConsoleResourceIndicators/OutputMode.cs b/identity-server/clients/src/ConsoleResourceIndicators/OutputMode.cs
new file mode 100644
index 000000000..e84cf5d42
--- /dev/null
+++ b/identity-server/clients/src/ConsoleResourceIndicators/OutputMode.cs
@@ -0,0 +1,10 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace ConsoleResourceIndicators;
+
+internal enum OutputMode
+{
+ Verbose,
+ Table
+}
diff --git a/identity-server/clients/src/ConsoleResourceIndicators/Program.cs b/identity-server/clients/src/ConsoleResourceIndicators/Program.cs
index ac736a9e1..2cdd177a6 100644
--- a/identity-server/clients/src/ConsoleResourceIndicators/Program.cs
+++ b/identity-server/clients/src/ConsoleResourceIndicators/Program.cs
@@ -1,23 +1,60 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
-using System.Buffers.Text;
-using System.Text;
-using Clients;
using ConsoleResourceIndicators;
-using Duende.IdentityModel.Client;
-using Duende.IdentityModel.OidcClient;
using Microsoft.Extensions.Hosting;
-using Serilog;
+using Spectre.Console;
var builder = Host.CreateApplicationBuilder(args);
// Add ServiceDefaults from Aspire
builder.AddServiceDefaults();
-OidcClient _oidcClient;
+// Display banner
+AnsiConsole.Write(new Rule("[bold green]Resource Indicators Demo[/]").Centered());
+AnsiConsole.WriteLine();
-"Resource Indicators Demo".ConsoleBox(ConsoleColor.Green);
+// Resolve the authority from the configuration
+var authority = builder.Configuration["is-host"]
+ ?? throw new InvalidOperationException("Authority configuration 'is-host' is missing.");
+
+// Display important setup information
+var setupPanel = new Panel(
+ new Markup($"[yellow]⚠[/] [bold]Before running tests:[/]\n" +
+ $"[dim]→[/] Ensure your Identity Server is running at: [cyan]{authority}[/]\n" +
+ $"[dim]→[/] Sign in to the Identity Server before starting tests\n" +
+ $"[dim]→[/] This will allow tests to complete quickly and smoothly"))
+ .Border(BoxBorder.Rounded)
+ .BorderColor(Color.Yellow)
+ .Header("[yellow]Setup Checklist[/]");
+
+AnsiConsole.Write(setupPanel);
+AnsiConsole.WriteLine();
+
+// Determine output mode based on whether console is interactive
+OutputMode mode;
+
+if (Console.IsInputRedirected || Console.IsOutputRedirected || !Environment.UserInteractive)
+{
+ // Non-interactive environment, use verbose mode by default
+ AnsiConsole.MarkupLine("[dim]Running in non-interactive mode. Using verbose output.[/]");
+ mode = OutputMode.Verbose;
+}
+else
+{
+ // Interactive environment, prompt user for output mode
+ var outputMode = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("[cyan]Choose output mode:[/]")
+ .AddChoices("Table View (Live Status)", "Verbose Output (Detailed)")
+ .HighlightStyle(new Style(Color.Green)));
+
+ mode = outputMode.StartsWith("Table") ? OutputMode.Table : OutputMode.Verbose;
+}
+
+AnsiConsole.WriteLine();
+
+var testRunner = new TestRunner(authority, mode);
var testsToRun = new List
{
@@ -31,138 +68,126 @@ var testsToRun = new List
new() { Id = "8", Enabled = true, Scope = "resource1.scope1 resource2.scope1 resource3.scope1 shared.scope", Resources = ["urn:resource3"] },
new() { Id = "9", Enabled = true, Scope = "resource3.scope1 offline_access", Resources = ["urn:resource3"] },
new() { Id = "10", Enabled = true, Scope = "resource3.scope1", Resources = ["urn:resource3"] },
- new() { Id = "11", Enabled = true, Scope = "resource1.scope1 offline_access", Resources = ["urn:resource3"] },
- new() { Id = "12", Enabled = true, Scope = "shared.scope", Resources = ["urn:invalid"] }
+ new() { Id = "11", Enabled = true, Scope = "resource1.scope1 offline_access", Resources = ["urn:resource3"], AccessTokenExpected = false },
+ new() { Id = "12", Enabled = true, Scope = "shared.scope", Resources = ["urn:invalid"], AccessTokenExpected = false }
};
-foreach (var test in testsToRun.Where(t => t.Enabled))
+await testRunner.RunAllTestsAsync(testsToRun);
+
+// Show summary
+AnsiConsole.WriteLine();
+AnsiConsole.Write(new Rule("[bold green]Test Summary[/]").Centered());
+
+var summary = new Table()
+ .Border(TableBorder.Rounded)
+ .AddColumn("[bold]Status[/]")
+ .AddColumn("[bold]Count[/]");
+
+var completed = testsToRun.Count(t => t.Enabled && t.Status == TestStatus.Completed);
+var failed = testsToRun.Count(t => t.Enabled && t.Status == TestStatus.Failed);
+var total = testsToRun.Count(t => t.Enabled);
+
+summary.AddRow("[green]Completed[/]", completed.ToString());
+summary.AddRow("[red]Failed[/]", failed.ToString());
+summary.AddRow("[cyan]Total[/]", total.ToString());
+
+AnsiConsole.Write(summary);
+
+// Show expected errors section
+var testsWithExpectedErrors = testsToRun
+ .Where(t => t.Enabled && t.Result?.RefreshResults.Any(r => r.WasExpectedError) == true)
+ .ToList();
+
+if (testsWithExpectedErrors.Any())
{
- var resources = test.Resources != null ? test.Resources.Aggregate((x, y) => $"{x}, {y}") : "-none-";
- ($"Runing test: ({test.Id}) SCOPES: " + test.Scope + ", RESOURCES: " + resources).ConsoleBox(ConsoleColor.Green);
+ AnsiConsole.WriteLine();
+ AnsiConsole.Write(new Rule("[bold yellow]Expected Errors (By Design)[/]").Centered());
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine("[dim]The following errors were expected as part of the test validation:[/]");
+ AnsiConsole.WriteLine();
- try
+ var expectedErrorsTable = new Table()
+ .Border(TableBorder.Rounded)
+ .AddColumn("[bold]Test ID[/]")
+ .AddColumn("[bold]Resource[/]")
+ .AddColumn("[bold]Error[/]")
+ .AddColumn("[bold]Reason[/]");
+
+ foreach (var test in testsWithExpectedErrors)
{
- await FrontChannel(test.Scope, test.Resources);
- Thread.Sleep(millisecondsTimeout: 1000);
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Exception: {ex.Message}");
- }
-}
+ var expectedErrors = test.Result!.RefreshResults.Where(r => r.WasExpectedError).ToList();
-// Exit the application
-"Exiting application...".ConsoleYellow();
-Environment.Exit(0);
-
-async Task FrontChannel(string scope, IEnumerable resource)
-{
- // Resolve the authority from the configuration.
- var authority = builder.Configuration["is-host"];
-
- resource ??= [];
-
- // create a redirect URI using an available port on the loopback address.
- // requires the OP to allow random ports on 127.0.0.1 - otherwise set a static port
- var browser = new SystemBrowser();
- var redirectUri = string.Format($"http://127.0.0.1:{browser.Port}");
-
- var options = new OidcClientOptions
- {
- Authority = authority,
-
- ClientId = "console.resource.indicators",
-
- RedirectUri = redirectUri,
- Scope = scope,
- Resource = [.. resource],
- FilterClaims = false,
- LoadProfile = false,
- Browser = browser,
-
- Policy =
+ foreach (var error in expectedErrors)
{
- RequireIdentityTokenSignature = false
+ var reason = "Resource not configured for this test";
+
+ expectedErrorsTable.AddRow(
+ test.Id,
+ error.Resource.Replace("urn:", ""),
+ $"[yellow]{error.Error ?? "unknown"}[/]",
+ $"[dim]{reason}[/]"
+ );
}
- };
-
- var serilog = new LoggerConfiguration()
- .MinimumLevel.Warning()
- .Enrich.FromLogContext()
- .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}{Exception}{NewLine}")
- .CreateLogger();
-
- options.LoggerFactory.AddSerilog(serilog);
-
- _oidcClient = new OidcClient(options);
- var result = await _oidcClient.LoginAsync();
-
- var parts = result.AccessToken.Split('.');
- var header = parts[0];
- var payload = parts[1];
-
- Console.WriteLine();
- Console.WriteLine("Standard access token:");
- Console.WriteLine(Encoding.UTF8.GetString(Base64Url.DecodeFromChars(header)).PrettyPrintJson());
- Console.WriteLine(Encoding.UTF8.GetString(Base64Url.DecodeFromChars(payload)).PrettyPrintJson());
-
- if (result.RefreshToken == null)
- {
- Console.WriteLine();
- Console.WriteLine("No Refresh Token, exiting.");
-
- Environment.Exit(0);
}
- await BackChannel(result);
+ AnsiConsole.Write(expectedErrorsTable);
}
-async Task BackChannel(LoginResult result)
+// Show detailed results if available
+if (mode == OutputMode.Table)
{
- Console.WriteLine("\n\n");
- Console.WriteLine("Refreshing with resource parameters");
+ var testsWithResults = testsToRun.Where(t => t.Enabled && t.Result != null).ToList();
- var resources = new List() { "urn:resource1", "urn:resource2", "urn:resource3" };
-
- foreach (var resource in resources)
+ if (testsWithResults.Any())
{
- $"Refreshing for resource: {resource}...".ConsoleGreen();
- await Refresh(result.RefreshToken, resource);
+ AnsiConsole.WriteLine();
+ AnsiConsole.Write(new Rule("[bold cyan]Detailed Test Results[/]").Centered());
- Thread.Sleep(millisecondsTimeout: 500);
- }
-}
+ var detailsTable = new Table()
+ .Border(TableBorder.Rounded)
+ .AddColumn("[bold]Test ID[/]")
+ .AddColumn("[bold]Access Token[/]")
+ .AddColumn("[bold]Refresh Token[/]")
+ .AddColumn("[bold]Refresh Operations[/]");
-async Task Refresh(string refreshToken, string resource)
-{
- var result = await _oidcClient.RefreshTokenAsync(refreshToken,
- new Parameters
+ foreach (var test in testsWithResults)
{
- { "resource", resource }
- });
+ var accessToken = test.Result!.AccessTokenReceived
+ ? "[green]✓ Received[/]"
+ : test.AccessTokenExpected
+ ? "[red]✗ Not Received[/]"
+ : "[green]✓ Not Expected[/]";
- if (result.IsError)
- {
- Console.WriteLine();
- Console.WriteLine(result.Error);
- return;
+ var refreshToken = test.Result.RefreshTokenReceived
+ ? "[green]✓ Received[/]"
+ : test.RefreshTokenExpected
+ ? "[red]✗ Not Received[/]"
+ : "[green]✓ Not Expected[/]";
+
+ var refreshOps = test.Result.RefreshResults.Any()
+ ? $"{test.Result.RefreshResults.Count(r => r.Success)}/{test.Result.RefreshResults.Count} successful"
+ : "-";
+
+ detailsTable.AddRow(
+ test.Id,
+ accessToken,
+ refreshToken,
+ refreshOps
+ );
+ }
+
+ AnsiConsole.Write(detailsTable);
}
-
- Console.WriteLine();
- Console.WriteLine("down-scoped access token:");
-
- var parts = result.AccessToken.Split('.');
- var header = parts[0];
- var payload = parts[1];
-
- Console.WriteLine(Encoding.UTF8.GetString(Base64Url.DecodeFromChars(header)).PrettyPrintJson());
- Console.WriteLine(Encoding.UTF8.GetString(Base64Url.DecodeFromChars(payload)).PrettyPrintJson());
}
-internal class Test
+// Exit prompt - only in interactive mode
+if (Environment.UserInteractive && !Console.IsInputRedirected)
{
- public string Id { get; set; }
- public bool Enabled { get; set; }
- public string Scope { get; set; }
- public IEnumerable Resources { get; set; } = null;
+ AnsiConsole.WriteLine();
+ AnsiConsole.Markup("[dim]Press Enter to exit...[/]");
+ Console.ReadLine();
+}
+else
+{
+ Environment.Exit(0);
}
diff --git a/identity-server/clients/src/ConsoleResourceIndicators/TestModel.cs b/identity-server/clients/src/ConsoleResourceIndicators/TestModel.cs
new file mode 100644
index 000000000..25a4f55f9
--- /dev/null
+++ b/identity-server/clients/src/ConsoleResourceIndicators/TestModel.cs
@@ -0,0 +1,42 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace ConsoleResourceIndicators;
+
+internal enum TestStatus
+{
+ Pending,
+ Running,
+ Completed,
+ Failed
+}
+
+internal class TestResult
+{
+ public bool AccessTokenReceived { get; set; }
+ public bool RefreshTokenReceived { get; set; }
+ public List RefreshResults { get; set; } = [];
+}
+
+internal class RefreshResult
+{
+ public string Resource { get; set; } = string.Empty;
+ public bool Success { get; set; }
+ public string Error { get; set; }
+ public bool WasExpectedError { get; set; }
+}
+
+internal class Test
+{
+ public string Id { get; set; } = string.Empty;
+ public bool Enabled { get; set; }
+ public bool AccessTokenExpected { get; set; } = true;
+ public bool RefreshTokenExpected => Scope.Contains("offline_access") && AccessTokenExpected;
+ public string Scope { get; set; } = string.Empty;
+ public IEnumerable Resources { get; set; } = [];
+ public TestStatus Status { get; set; } = TestStatus.Pending;
+ public string ErrorMessage { get; set; }
+ public DateTime? StartTime { get; set; }
+ public DateTime? EndTime { get; set; }
+ public TestResult Result { get; set; }
+}
diff --git a/identity-server/clients/src/ConsoleResourceIndicators/TestRunner.cs b/identity-server/clients/src/ConsoleResourceIndicators/TestRunner.cs
new file mode 100644
index 000000000..6c1c1d961
--- /dev/null
+++ b/identity-server/clients/src/ConsoleResourceIndicators/TestRunner.cs
@@ -0,0 +1,360 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Buffers.Text;
+using System.Text;
+using Duende.IdentityModel.Client;
+using Duende.IdentityModel.OidcClient;
+using Spectre.Console;
+using Spectre.Console.Json;
+
+namespace ConsoleResourceIndicators;
+
+internal class TestRunner(string authority, OutputMode outputMode)
+{
+ private readonly string _authority = authority;
+ private readonly OutputMode _outputMode = outputMode;
+ private OidcClient _oidcClient;
+
+ private static readonly string[] RefreshResources = ["urn:resource1", "urn:resource2", "urn:resource3"];
+ private const int DelayBetweenTestsMs = 1000;
+ private const int DelayBetweenRefreshMs = 500;
+
+ public async Task RunAllTestsAsync(List tests)
+ {
+ if (_outputMode == OutputMode.Table)
+ {
+ await RunTestsWithTableAsync(tests);
+ }
+ else
+ {
+ await RunTestsVerboseAsync(tests);
+ }
+ }
+
+ private async Task RunTestsVerboseAsync(List tests)
+ {
+ foreach (var test in tests.Where(t => t.Enabled))
+ {
+ await RunTestVerboseAsync(test);
+ }
+ }
+
+ private async Task RunTestsWithTableAsync(List tests)
+ {
+ var enabledTests = tests.Where(t => t.Enabled).ToList();
+
+ await AnsiConsole.Live(CreateTestTable(enabledTests))
+ .StartAsync(async ctx =>
+ {
+ foreach (var test in enabledTests)
+ {
+ test.Status = TestStatus.Running;
+ test.StartTime = DateTime.Now;
+ ctx.UpdateTarget(CreateTestTable(enabledTests));
+
+ try
+ {
+ await ExecuteTestAsync(test, verbose: false);
+ test.Status = TestStatus.Completed;
+ }
+ catch (Exception ex)
+ {
+ test.Status = TestStatus.Failed;
+ test.ErrorMessage = ex.Message;
+ }
+
+ test.EndTime = DateTime.Now;
+ ctx.UpdateTarget(CreateTestTable(enabledTests));
+ await Task.Delay(DelayBetweenTestsMs);
+ }
+ });
+ }
+
+ private static Table CreateTestTable(List tests)
+ {
+ var table = new Table()
+ .Border(TableBorder.Rounded)
+ .AddColumn("[bold]ID[/]")
+ .AddColumn("[bold]Status[/]")
+ .AddColumn("[bold]Scopes[/]")
+ .AddColumn("[bold]Resources[/]")
+ .AddColumn("[bold]Duration[/]");
+
+ foreach (var test in tests)
+ {
+ var status = test.Status switch
+ {
+ TestStatus.Pending => "[grey]Pending[/]",
+ TestStatus.Running => "[yellow]Running...[/]",
+ TestStatus.Completed => "[green]✓ Completed[/]",
+ TestStatus.Failed => $"[red]✗ Failed[/]",
+ _ => "[grey]Unknown[/]"
+ };
+
+ var resourcesList = test.Resources?.Any() == true
+ ? string.Join(", ", test.Resources.Select(r => r.Replace("urn:", "")))
+ : "-";
+
+ var duration = test.StartTime.HasValue && test.EndTime.HasValue
+ ? $"{(test.EndTime.Value - test.StartTime.Value).TotalSeconds:F1}s"
+ : test.StartTime.HasValue
+ ? "..."
+ : "-";
+
+ // Truncate scopes for table display
+ var scopeDisplay = test.Scope.Length > 40
+ ? string.Concat(test.Scope.AsSpan(0, 37), "...")
+ : test.Scope;
+
+ table.AddRow(
+ test.Id,
+ status,
+ scopeDisplay,
+ resourcesList,
+ duration
+ );
+ }
+
+ return table;
+ }
+
+ private async Task RunTestVerboseAsync(Test test)
+ {
+ var resourcesList = test.Resources?.Any() == true
+ ? string.Join(", ", test.Resources)
+ : "-none-";
+
+ // Escape the text to prevent Spectre.Console from interpreting it as markup
+ var scopeText = test.Scope.EscapeMarkup();
+ var resourcesText = resourcesList.EscapeMarkup();
+
+ var panel = new Panel(
+ new Markup($"[bold]Test {test.Id}[/]\n" +
+ $"[dim]Scopes:[/] {scopeText}\n" +
+ $"[dim]Resources:[/] {resourcesText}"))
+ .Border(BoxBorder.Rounded)
+ .BorderColor(Color.Blue)
+ .Header("[blue]Running Test[/]");
+
+ AnsiConsole.Write(panel);
+
+ try
+ {
+ test.Status = TestStatus.Running;
+ test.StartTime = DateTime.Now;
+ await ExecuteTestAsync(test, verbose: true);
+ test.Status = TestStatus.Completed;
+ test.EndTime = DateTime.Now;
+ AnsiConsole.MarkupLine("[green]✓ Test completed successfully[/]\n");
+ await Task.Delay(DelayBetweenTestsMs);
+ }
+ catch (Exception ex)
+ {
+ test.Status = TestStatus.Failed;
+ test.ErrorMessage = ex.Message;
+ test.EndTime = DateTime.Now;
+ AnsiConsole.MarkupLine($"[red]✗ Test failed: {Markup.Escape(ex.Message)}[/]\n");
+ }
+ }
+
+ private async Task ExecuteTestAsync(Test test, bool verbose)
+ {
+ test.Result = new TestResult();
+
+ var browser = new SystemBrowser();
+ var redirectUri = $"http://127.0.0.1:{browser.Port}";
+
+ var options = new OidcClientOptions
+ {
+ Authority = _authority,
+ ClientId = "console.resource.indicators",
+ RedirectUri = redirectUri,
+ Scope = test.Scope,
+ Resource = test.Resources?.ToList() ?? [],
+ FilterClaims = false,
+ LoadProfile = false,
+ Browser = browser,
+ Policy =
+ {
+ RequireIdentityTokenSignature = false
+ }
+ };
+
+ _oidcClient = new OidcClient(options);
+ var result = await _oidcClient.LoginAsync();
+
+ test.Result.AccessTokenReceived = result.AccessToken != null;
+
+ if (verbose)
+ {
+ HandleAccessTokenVerbose(result.AccessToken, test.AccessTokenExpected);
+
+ if (test.AccessTokenExpected && test.RefreshTokenExpected)
+ {
+ test.Result.RefreshTokenReceived = result.RefreshToken != null;
+ await HandleRefreshTokenVerbose(result, test.Resources, test.Result);
+ }
+ else if (!test.RefreshTokenExpected)
+ {
+ AnsiConsole.MarkupLine("[green]✓ Refresh Token was not expected and not received[/]");
+ }
+ }
+ else
+ {
+ // In table mode, just validate and collect results without output
+ if (test.AccessTokenExpected && result.AccessToken == null)
+ {
+ throw new Exception("Access token expected but not received");
+ }
+
+ if (test.AccessTokenExpected && test.RefreshTokenExpected)
+ {
+ if (result.RefreshToken == null)
+ {
+ throw new Exception("Refresh token expected but not received");
+ }
+ test.Result.RefreshTokenReceived = true;
+ await HandleRefreshTokenSilent(result, test.Resources, test.Result);
+ }
+ }
+ }
+
+ private static void HandleAccessTokenVerbose(string accessToken, bool expected)
+ {
+ if (expected)
+ {
+ if (accessToken is null)
+ {
+ AnsiConsole.MarkupLine("[red]✗ An Access Token was expected but not received[/]");
+ return;
+ }
+
+ AnsiConsole.MarkupLine("[green]✓ Access Token received[/]");
+ AnsiConsole.WriteLine();
+
+ PrintJwtToken(accessToken, "Standard Access Token");
+ }
+ else
+ {
+ AnsiConsole.MarkupLine("[green]✓ Access Token was not expected and not received[/]");
+ }
+ }
+
+ private async Task HandleRefreshTokenVerbose(LoginResult result, IEnumerable testResources, TestResult testResult)
+ {
+ if (result.RefreshToken is null)
+ {
+ AnsiConsole.MarkupLine("[red]✗ A Refresh Token was expected but not received[/]");
+ return;
+ }
+
+ AnsiConsole.WriteLine();
+ AnsiConsole.WriteLine();
+
+ AnsiConsole.Write(new Rule("[yellow]Refreshing with Resource Parameters[/]").LeftJustified());
+
+ var resourcesSet = testResources?.ToHashSet() ?? [];
+
+ foreach (var resource in RefreshResources)
+ {
+ AnsiConsole.MarkupLine($"[cyan]→ Refreshing for resource: {resource}[/]");
+ await RefreshTokenAsync(result.RefreshToken, resource, resourcesSet.Contains(resource), verbose: true, testResult);
+ await Task.Delay(DelayBetweenRefreshMs);
+ }
+ }
+
+ private async Task HandleRefreshTokenSilent(LoginResult result, IEnumerable testResources, TestResult testResult)
+ {
+ var resourcesSet = testResources?.ToHashSet() ?? [];
+
+ foreach (var resource in RefreshResources)
+ {
+ await RefreshTokenAsync(result.RefreshToken!, resource, resourcesSet.Contains(resource), verbose: false, testResult);
+ await Task.Delay(DelayBetweenRefreshMs);
+ }
+ }
+
+ private async Task RefreshTokenAsync(string refreshToken, string resource, bool resourceIsConfigured, bool verbose, TestResult testResult)
+ {
+ if (_oidcClient == null)
+ {
+ throw new InvalidOperationException("OIDC client not initialized");
+ }
+
+ var result = await _oidcClient.RefreshTokenAsync(refreshToken,
+ new Parameters
+ {
+ { "resource", resource }
+ });
+
+ var refreshResult = new RefreshResult
+ {
+ Resource = resource,
+ Success = !result.IsError,
+ Error = result.Error,
+ WasExpectedError = !resourceIsConfigured && result.IsError
+ };
+
+ testResult.RefreshResults.Add(refreshResult);
+
+ if (result.IsError)
+ {
+ if (resourceIsConfigured)
+ {
+ var message = $"An error was not expected but was received: {result.Error}";
+ if (verbose)
+ {
+ AnsiConsole.MarkupLine($"[red]✗ {Markup.Escape(message)}[/]");
+ }
+ else
+ {
+ throw new Exception(message);
+ }
+ }
+ else if (verbose)
+ {
+ // Expected error - show in verbose mode
+ AnsiConsole.MarkupLine($"[green]✓ Expected error received: [/][yellow]{Markup.Escape(result.Error ?? "unknown")}[/]");
+ }
+ // In non-verbose mode, we don't show expected errors here - they'll be in the summary
+ return;
+ }
+
+ if (verbose)
+ {
+ AnsiConsole.WriteLine();
+ PrintJwtToken(result.AccessToken!, "Down-scoped access token");
+ }
+ }
+
+ private static void PrintJwtToken(string token, string blockHeader = "JWT Token")
+ {
+ var parts = token.Split('.');
+ if (parts.Length < 2)
+ {
+ AnsiConsole.MarkupLine("[red]Invalid JWT token format[/]");
+ return;
+ }
+
+ var header = parts[0];
+ var payload = parts[1];
+
+ var headerJson = Encoding.UTF8.GetString(Base64Url.DecodeFromChars(header));
+ var payloadJson = Encoding.UTF8.GetString(Base64Url.DecodeFromChars(payload));
+
+ // Use Spectre.Console's built-in JSON rendering with proper namespace
+ AnsiConsole.Write(
+ new Panel(
+ new Rows(
+ new Markup("[bold yellow]Header:[/]"),
+ new JsonText(headerJson),
+ new Text(""),
+ new Markup("[bold yellow]Payload:[/]"),
+ new JsonText(payloadJson)
+ ))
+ .Border(BoxBorder.Rounded)
+ .BorderColor(Color.Grey)
+ .Header($"[dim]{blockHeader}[/]"));
+ }
+}
diff --git a/identity-server/clients/src/WindowsConsoleSystemBrowser/Program.cs b/identity-server/clients/src/WindowsConsoleSystemBrowser/Program.cs
index 6f85a1c7b..3043990a7 100644
--- a/identity-server/clients/src/WindowsConsoleSystemBrowser/Program.cs
+++ b/identity-server/clients/src/WindowsConsoleSystemBrowser/Program.cs
@@ -74,7 +74,7 @@ internal class Program
{
Authority = Constants.Authority,
ClientId = "winconsole",
- Scope = "openid profile scope1",
+ Scope = "openid profile resource1.scope1",
RedirectUri = redirectUri,
};
@@ -94,7 +94,12 @@ internal class Program
var callbackManager = new CallbackManager(state.State);
// open system browser to start authentication
- Process.Start(state.StartUrl);
+ var psi = new ProcessStartInfo
+ {
+ FileName = state.StartUrl,
+ UseShellExecute = true
+ };
+ Process.Start(psi);
Console.WriteLine("Running callback manager");
var response = await callbackManager.RunServer();
diff --git a/identity-server/clients/src/WindowsConsoleSystemBrowser/RegistryConfig.cs b/identity-server/clients/src/WindowsConsoleSystemBrowser/RegistryConfig.cs
index 12f562843..651026a52 100644
--- a/identity-server/clients/src/WindowsConsoleSystemBrowser/RegistryConfig.cs
+++ b/identity-server/clients/src/WindowsConsoleSystemBrowser/RegistryConfig.cs
@@ -1,7 +1,6 @@
// Copyright (c) Duende Software. All rights reserved.
// See LICENSE in the project root for license information.
-using System.Reflection;
using System.Runtime.Versioning;
using Microsoft.Win32;
@@ -36,7 +35,7 @@ internal class RegistryConfig
private const string CommandKeyValueName = "";
private const string CommandKeyValueFormat = "\"{0}\" \"%1\"";
- private static string CommandKeyValueValue => string.Format(CommandKeyValueFormat, Assembly.GetExecutingAssembly().Location);
+ private static string CommandKeyValueValue => string.Format(CommandKeyValueFormat, Environment.ProcessPath);
private const string UrlProtocolValueName = "URL Protocol";
private const string UrlProtocolValueValue = "";
diff --git a/identity-server/hosts/Shared/Configuration/ClientsConsole.cs b/identity-server/hosts/Shared/Configuration/ClientsConsole.cs
index 65250a669..c60e02fd4 100644
--- a/identity-server/hosts/Shared/Configuration/ClientsConsole.cs
+++ b/identity-server/hosts/Shared/Configuration/ClientsConsole.cs
@@ -269,7 +269,7 @@ public static class ClientsConsole
RedirectUris = { "sample-windows-client://callback" },
RequireConsent = false,
AllowOfflineAccess = true,
- AllowedIdentityTokenSigningAlgorithms = { "ES256" },
+ AllowedIdentityTokenSigningAlgorithms = { "RS256" },
AllowedScopes =
{
IdentityServerConstants.StandardScopes.OpenId,