From 5fa0ca61b27bf226b193323898cdea4eaeee55c3 Mon Sep 17 00:00:00 2001 From: Stu Frankish Date: Thu, 13 Nov 2025 10:57:00 +0000 Subject: [PATCH] Update Console Clients & Aspire version (#2270) * Updated console client configuration for Windows System Browser console client. Updated Windows System Console so default URL behaviour and callback works. Updated Aspire to v13. * Remove an unused using * Refactored to fix a number of issues and introduce improvements using Spectre Console. * Updated Aspire Test package --- Directory.Packages.props | 8 +- .../ConsoleResourceIndicators.csproj | 2 + .../ConsoleResourceIndicators/OutputMode.cs | 10 + .../src/ConsoleResourceIndicators/Program.cs | 263 +++++++------ .../ConsoleResourceIndicators/TestModel.cs | 42 ++ .../ConsoleResourceIndicators/TestRunner.cs | 360 ++++++++++++++++++ .../WindowsConsoleSystemBrowser/Program.cs | 9 +- .../RegistryConfig.cs | 3 +- .../Shared/Configuration/ClientsConsole.cs | 2 +- 9 files changed, 572 insertions(+), 127 deletions(-) create mode 100644 identity-server/clients/src/ConsoleResourceIndicators/OutputMode.cs create mode 100644 identity-server/clients/src/ConsoleResourceIndicators/TestModel.cs create mode 100644 identity-server/clients/src/ConsoleResourceIndicators/TestRunner.cs 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,