diff --git a/.github/workflow-gen/Program.cs b/.github/workflow-gen/Program.cs index cd6be2a69..4256ddec0 100644 --- a/.github/workflow-gen/Program.cs +++ b/.github/workflow-gen/Program.cs @@ -95,7 +95,7 @@ void GenerateCiWorkflow(Product product) .Job(BuildJobId) .RunEitherOnBranchOrAsPR() .Name("Build and test (unit)") - .RunsOn(GitHubHostedRunners.UbuntuLatest) + .RunsOn("large", ["ubuntu-latest-x64-16core"]) .Defaults().Run("bash", product.Name) .Job; diff --git a/.github/workflow-gen/StepExtensions.cs b/.github/workflow-gen/StepExtensions.cs index fb40cfd62..5e99c0d50 100644 --- a/.github/workflow-gen/StepExtensions.cs +++ b/.github/workflow-gen/StepExtensions.cs @@ -18,7 +18,7 @@ public static class StepExtensions job.Step() .Name("Setup .NET") - .ActionsSetupDotNet("3e891b0cb619bf60e2c25674b222b8940e2c1c25", ["8.0.x", "9.0.203", "10.0.100-rc.2.25502.107"]); + .ActionsSetupDotNet("3e891b0cb619bf60e2c25674b222b8940e2c1c25", ["8.0.x", "9.0.203", "10.0.100"]); // v4.1.0 } diff --git a/.github/workflows/aspnetcore-authentication-jwtbearer-ci.yml b/.github/workflows/aspnetcore-authentication-jwtbearer-ci.yml index 6a3be5c7b..204d11bb5 100644 --- a/.github/workflows/aspnetcore-authentication-jwtbearer-ci.yml +++ b/.github/workflows/aspnetcore-authentication-jwtbearer-ci.yml @@ -53,7 +53,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: Restore run: dotnet restore aspnetcore-authentication-jwtbearer.slnf - name: Verify Formatting @@ -61,7 +61,9 @@ jobs: build: name: Build and test (unit) if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) || (github.event_name == 'push') || (github.event_name == 'workflow_dispatch') - runs-on: ubuntu-latest + runs-on: + group: large + labels: [ubuntu-latest-x64-16core] permissions: actions: read checks: write @@ -85,7 +87,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: Restore run: dotnet restore aspnetcore-authentication-jwtbearer.slnf - name: Build @@ -174,7 +176,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: Tool restore run: dotnet tool restore - name: Pack aspnetcore-authentication-jwtbearer.slnf diff --git a/.github/workflows/aspnetcore-authentication-jwtbearer-release.yml b/.github/workflows/aspnetcore-authentication-jwtbearer-release.yml index d2919e4a9..1ed59594a 100644 --- a/.github/workflows/aspnetcore-authentication-jwtbearer-release.yml +++ b/.github/workflows/aspnetcore-authentication-jwtbearer-release.yml @@ -68,7 +68,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: Pack aspnetcore-authentication-jwtbearer.slnf run: dotnet pack -c Release aspnetcore-authentication-jwtbearer.slnf -o artifacts - name: Tool restore @@ -110,7 +110,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: List files run: tree shell: bash diff --git a/.github/workflows/bff-ci.yml b/.github/workflows/bff-ci.yml index 2c9a8642c..4d07d7401 100644 --- a/.github/workflows/bff-ci.yml +++ b/.github/workflows/bff-ci.yml @@ -53,7 +53,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: Restore run: dotnet restore bff.slnf - name: Verify Formatting @@ -61,7 +61,9 @@ jobs: build: name: Build and test (unit) if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) || (github.event_name == 'push') || (github.event_name == 'workflow_dispatch') - runs-on: ubuntu-latest + runs-on: + group: large + labels: [ubuntu-latest-x64-16core] permissions: actions: read checks: write @@ -85,7 +87,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: Restore run: dotnet restore bff.slnf - name: Build @@ -134,7 +136,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: Restore run: dotnet restore bff.slnf - name: Build @@ -221,7 +223,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: Tool restore run: dotnet tool restore - name: Pack bff.slnf diff --git a/.github/workflows/bff-release.yml b/.github/workflows/bff-release.yml index 28e75d673..bdfeac09c 100644 --- a/.github/workflows/bff-release.yml +++ b/.github/workflows/bff-release.yml @@ -68,7 +68,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: Pack bff.slnf run: dotnet pack -c Release bff.slnf -o artifacts - name: Tool restore @@ -110,7 +110,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: List files run: tree shell: bash diff --git a/.github/workflows/docs-mcp-ci.yml b/.github/workflows/docs-mcp-ci.yml index bbff4cd54..af8ffba6b 100644 --- a/.github/workflows/docs-mcp-ci.yml +++ b/.github/workflows/docs-mcp-ci.yml @@ -53,7 +53,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: Restore run: dotnet restore docs-mcp.slnf - name: Verify Formatting @@ -61,7 +61,9 @@ jobs: build: name: Build and test (unit) if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) || (github.event_name == 'push') || (github.event_name == 'workflow_dispatch') - runs-on: ubuntu-latest + runs-on: + group: large + labels: [ubuntu-latest-x64-16core] permissions: actions: read checks: write @@ -85,7 +87,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: Restore run: dotnet restore docs-mcp.slnf - name: Build @@ -160,7 +162,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: Tool restore run: dotnet tool restore - name: Pack docs-mcp.slnf diff --git a/.github/workflows/docs-mcp-release.yml b/.github/workflows/docs-mcp-release.yml index 502327540..6581e10ea 100644 --- a/.github/workflows/docs-mcp-release.yml +++ b/.github/workflows/docs-mcp-release.yml @@ -68,7 +68,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: Pack docs-mcp.slnf run: dotnet pack -c Release docs-mcp.slnf -o artifacts - name: Tool restore @@ -110,7 +110,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: List files run: tree shell: bash diff --git a/.github/workflows/identity-server-ci.yml b/.github/workflows/identity-server-ci.yml index fab4e1de6..ff60cbaa1 100644 --- a/.github/workflows/identity-server-ci.yml +++ b/.github/workflows/identity-server-ci.yml @@ -53,7 +53,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: Restore run: dotnet restore identity-server.slnf - name: Verify Formatting @@ -61,7 +61,9 @@ jobs: build: name: Build and test (unit) if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) || (github.event_name == 'push') || (github.event_name == 'workflow_dispatch') - runs-on: ubuntu-latest + runs-on: + group: large + labels: [ubuntu-latest-x64-16core] permissions: actions: read checks: write @@ -85,7 +87,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: Restore run: dotnet restore identity-server.slnf - name: Build @@ -148,7 +150,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: Restore run: dotnet restore identity-server.slnf - name: Build @@ -235,7 +237,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: Tool restore run: dotnet tool restore - name: Pack identity-server.slnf diff --git a/.github/workflows/identity-server-release.yml b/.github/workflows/identity-server-release.yml index f8d45df40..e66d0e7a5 100644 --- a/.github/workflows/identity-server-release.yml +++ b/.github/workflows/identity-server-release.yml @@ -68,7 +68,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: Pack identity-server.slnf run: dotnet pack -c Release identity-server.slnf -o artifacts - name: Tool restore @@ -110,7 +110,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: List files run: tree shell: bash diff --git a/.github/workflows/templates-release.yml b/.github/workflows/templates-release.yml index b9d29177e..ba659d74c 100644 --- a/.github/workflows/templates-release.yml +++ b/.github/workflows/templates-release.yml @@ -46,7 +46,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: Checkout target branch if: github.event.inputs.branch != 'main' run: git checkout ${{ github.event.inputs.branch }} @@ -110,7 +110,7 @@ jobs: dotnet-version: |- 8.0.x 9.0.203 - 10.0.100-rc.2.25502.107 + 10.0.100 - name: List files run: tree shell: bash diff --git a/Directory.Packages.props b/Directory.Packages.props index ec0a4e8d5..6e6068542 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,59 +1,56 @@ + 8.0.1 + 9.0.9 8.0.20 - 8.0.20 + 8.0.20 8.0.16 + 7.1.2 - 9.0.3 - 9.0.9 - 9.0.9 9.0.3 9.0.9 9.0.3 - 9.0.3 + 9.0.3 9.0.3 8.0.1 - 9.0.3 - 9.0.9 - 9.0.9 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 + 10.0.0 8.0.1 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - 10.0.0-rc.2.25502.107 - - - + + + - - - - + + + + - - + + + @@ -61,10 +58,9 @@ - - + - + @@ -75,31 +71,27 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - @@ -133,9 +125,11 @@ + + - + diff --git a/bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj b/bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj index fba694644..3a2ee4144 100644 --- a/bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj +++ b/bff/hosts/Hosts.AppHost/Hosts.AppHost.csproj @@ -1,6 +1,6 @@  - + Exe diff --git a/global.json b/global.json index d34fe265a..b48eef46e 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.100-rc.2.25502.107", + "version": "10.0.100", "rollForward": "latestMajor", "allowPrerelease": true } diff --git a/identity-server/aspire/AppHosts/All/All.csproj b/identity-server/aspire/AppHosts/All/All.csproj index eee62a103..4e2e179c7 100644 --- a/identity-server/aspire/AppHosts/All/All.csproj +++ b/identity-server/aspire/AppHosts/All/All.csproj @@ -1,6 +1,6 @@  - + Exe @@ -58,6 +58,9 @@ + + + diff --git a/identity-server/aspire/AppHosts/All/Program.cs b/identity-server/aspire/AppHosts/All/Program.cs index feb0cdd4f..15bb4cd15 100644 --- a/identity-server/aspire/AppHosts/All/Program.cs +++ b/identity-server/aspire/AppHosts/All/Program.cs @@ -46,13 +46,25 @@ void ConfigureIdentityServerHosts() projectRegistry.Add("is-host", hostMain); } + if (HostIsEnabled(nameof(Projects.Host_Main10))) + { + var hostMain = builder + .AddProject("is-host") + .WithHttpHealthCheck(path: "/.well-known/openid-configuration"); + + projectRegistry.Add("is-host", hostMain); + } + + // These hosts require a database var dbHosts = new List { nameof(Projects.Host_AspNetIdentity8), nameof(Projects.Host_AspNetIdentity9), + nameof(Projects.Host_AspNetIdentity10), nameof(Projects.Host_EntityFramework8), - nameof(Projects.Host_EntityFramework9) + nameof(Projects.Host_EntityFramework9), + nameof(Projects.Host_EntityFramework10) }; if (dbHosts.Any(HostIsEnabled)) @@ -100,6 +112,23 @@ void ConfigureIdentityServerHosts() projectRegistry.Add("is-host", hostAspNetIdentity); } + if (HostIsEnabled(nameof(Projects.Host_AspNetIdentity10))) + { + var hostAspNetIdentity = builder.AddProject(name: "is-host") + .WithHttpHealthCheck(path: "/.well-known/openid-configuration") + .WithReference(identityServerDb, connectionName: "DefaultConnection"); + + if (appConfig.RunDatabaseMigrations) + { + var aspnetMigration = builder.AddProject(name: "aspnetidentitydb-migrations") + .WithReference(identityServerDb, connectionName: "DefaultConnection") + .WaitFor(identityServerDb); + hostAspNetIdentity.WaitForCompletion(aspnetMigration); + } + + projectRegistry.Add("is-host", hostAspNetIdentity); + } + if (HostIsEnabled(nameof(Projects.Host_EntityFramework8))) { var hostEntityFramework = builder.AddProject(name: "is-host") @@ -133,6 +162,23 @@ void ConfigureIdentityServerHosts() projectRegistry.Add("is-host", hostEntityFramework); } + + if (HostIsEnabled(nameof(Projects.Host_EntityFramework10))) + { + var hostEntityFramework = builder.AddProject(name: "is-host") + .WithHttpHealthCheck(path: "/.well-known/openid-configuration") + .WithReference(identityServerDb, connectionName: "DefaultConnection"); + + if (appConfig.RunDatabaseMigrations) + { + var idSrvMigration = builder.AddProject(name: "identityserverdb-migrations") + .WithReference(identityServerDb, connectionName: "DefaultConnection") + .WaitFor(identityServerDb); + hostEntityFramework.WaitForCompletion(idSrvMigration); + } + + projectRegistry.Add("is-host", hostEntityFramework); + } } bool HostIsEnabled(string name) => diff --git a/identity-server/aspire/AppHosts/All/appsettings.json b/identity-server/aspire/AppHosts/All/appsettings.json index b0c269c4d..2951eb626 100644 --- a/identity-server/aspire/AppHosts/All/appsettings.json +++ b/identity-server/aspire/AppHosts/All/appsettings.json @@ -7,7 +7,7 @@ } }, "AspireProjectConfiguration": { - "IdentityHost": "Host_Main9", + "IdentityHost": "Host_Main10", "UseClients": { "ConsoleCibaClient": false, "ConsoleClientCredentialsFlow": false, diff --git a/identity-server/aspire/AppHosts/Dev/Dev.csproj b/identity-server/aspire/AppHosts/Dev/Dev.csproj index 58d0a7413..d058d3780 100644 --- a/identity-server/aspire/AppHosts/Dev/Dev.csproj +++ b/identity-server/aspire/AppHosts/Dev/Dev.csproj @@ -1,6 +1,6 @@  - + Exe 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/ConsoleResourceOwnerFlowRefreshToken/Program.cs b/identity-server/clients/src/ConsoleResourceOwnerFlowRefreshToken/Program.cs index d968cafa4..e8fc68559 100644 --- a/identity-server/clients/src/ConsoleResourceOwnerFlowRefreshToken/Program.cs +++ b/identity-server/clients/src/ConsoleResourceOwnerFlowRefreshToken/Program.cs @@ -34,12 +34,12 @@ response.Show(); var refresh_token = response.RefreshToken; -while (true) +for (var i = 0; i < 10; i++) { response = await RefreshTokenAsync(refresh_token); response.Show(); - Thread.Sleep(5000); + Thread.Sleep(50); await CallServiceAsync(response.AccessToken); diff --git a/identity-server/clients/src/ConsoleScopesResources/Program.cs b/identity-server/clients/src/ConsoleScopesResources/Program.cs index 2b530f0de..e903b9e96 100644 --- a/identity-server/clients/src/ConsoleScopesResources/Program.cs +++ b/identity-server/clients/src/ConsoleScopesResources/Program.cs @@ -36,8 +36,8 @@ var plannedRuns = new List new() { Enabled = true, Id = "J", Name = "No scope (resource: resource1)", Scope = "", Resource = "urn:resource1" }, new() { Enabled = true, Id = "K", Name = "No scope (resource: resource3)", Scope = "", Resource = "urn:resource3" }, new() { Enabled = true, Id = "L", Name = "Isolated scope without resource parameter", Scope = "resource3.scope1" }, - new() { Enabled = true, Id = "M", Name = "Isolated scope without resource parameter", Scope = "resource3.scope1", Resource = "urn:resource3" }, - new() { Enabled = true, Id = "N", Name = "Isolated scope without resource parameter", Scope = "resource3.scope1", Resource = "urn:resource2" } + new() { Enabled = true, Id = "M", Name = "Isolated scope with resource parameter", Scope = "resource3.scope1", Resource = "urn:resource3" }, + new() { Enabled = true, Id = "N", Name = "Shared scope with resource parameter", Scope = "shared.scope", Resource = "urn:resource2" } }; // Execute the planned runs 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, diff --git a/identity-server/hosts/Shared/Configuration/TestClients.cs b/identity-server/hosts/Shared/Configuration/TestClients.cs index 0c8e0705d..92cb99183 100644 --- a/identity-server/hosts/Shared/Configuration/TestClients.cs +++ b/identity-server/hosts/Shared/Configuration/TestClients.cs @@ -7,7 +7,7 @@ namespace Duende.IdentityServer.Hosts.Shared.Configuration; public static class TestClients { - public static IEnumerable Get() + public static List Get() { var clients = new List(); diff --git a/identity-server/hosts/Shared/Customization/CustomClientRegistrationProcessor.cs b/identity-server/hosts/Shared/Customization/CustomClientRegistrationProcessor.cs index 5f4ff5f09..91de8bacc 100644 --- a/identity-server/hosts/Shared/Customization/CustomClientRegistrationProcessor.cs +++ b/identity-server/hosts/Shared/Customization/CustomClientRegistrationProcessor.cs @@ -25,7 +25,7 @@ public sealed class CustomClientRegistrationProcessor( var clientId = clientIdParameter.ToString(); if (clientId != null) { - var existingClient = clientStore.FindClientByIdAsync(clientId); + var existingClient = await clientStore.FindClientByIdAsync(clientId); if (existingClient is not null) { return new DynamicClientRegistrationError( diff --git a/identity-server/hosts/net10/AspNetIdentity10/HostingExtensions.cs b/identity-server/hosts/net10/AspNetIdentity10/HostingExtensions.cs index 1023ca2df..d9619ec7d 100644 --- a/identity-server/hosts/net10/AspNetIdentity10/HostingExtensions.cs +++ b/identity-server/hosts/net10/AspNetIdentity10/HostingExtensions.cs @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. using Duende.IdentityServer; +using Duende.IdentityServer.Configuration; using Duende.IdentityServer.UI; using Duende.IdentityServer.UI.AspNetIdentity.Models; using IdentityServerHost.Data; @@ -122,6 +123,9 @@ internal static class HostingExtensions app.MapRazorPages() .RequireAuthorization(); + app.MapDynamicClientRegistration() + .AllowAnonymous(); + return app; } } diff --git a/identity-server/hosts/net10/AspNetIdentity10/IdentityServerExtensions.cs b/identity-server/hosts/net10/AspNetIdentity10/IdentityServerExtensions.cs index fd7c56e7d..a0b121b7e 100644 --- a/identity-server/hosts/net10/AspNetIdentity10/IdentityServerExtensions.cs +++ b/identity-server/hosts/net10/AspNetIdentity10/IdentityServerExtensions.cs @@ -1,7 +1,10 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Configuration.RequestProcessing; using Duende.IdentityServer.Hosts.Shared.Configuration; +using Duende.IdentityServer.Hosts.Shared.Customization; using Duende.IdentityServer.UI.AspNetIdentity.Models; namespace IdentityServerHost; @@ -26,6 +29,10 @@ internal static class IdentityServerExtensions .AddInMemoryClients(TestClients.Get()) .AddAspNetIdentity(); + builder.Services.AddIdentityServerConfiguration(opt => { }) + .AddInMemoryClientConfigurationStore(); + builder.Services.AddTransient(); + return builder; } } diff --git a/identity-server/hosts/net10/Main10/HostingExtensions.cs b/identity-server/hosts/net10/Main10/HostingExtensions.cs index 7435c6324..bd61b74ac 100644 --- a/identity-server/hosts/net10/Main10/HostingExtensions.cs +++ b/identity-server/hosts/net10/Main10/HostingExtensions.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Security.Claims; using Duende.IdentityServer; +using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Hosts.Shared.Customization; using Duende.IdentityServer.UI; using Microsoft.AspNetCore.Mvc.Razor; @@ -171,6 +172,9 @@ internal static class HostingExtensions app.MapRazorPages() .RequireAuthorization(); + app.MapDynamicClientRegistration() + .AllowAnonymous(); + return app; } } diff --git a/identity-server/hosts/net10/Main10/IdentityServerExtensions.cs b/identity-server/hosts/net10/Main10/IdentityServerExtensions.cs index e9108e231..f42ddb663 100644 --- a/identity-server/hosts/net10/Main10/IdentityServerExtensions.cs +++ b/identity-server/hosts/net10/Main10/IdentityServerExtensions.cs @@ -5,6 +5,7 @@ using System.Security.Cryptography.X509Certificates; using Duende.IdentityModel; using Duende.IdentityServer; using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Configuration.RequestProcessing; using Duende.IdentityServer.Hosts.Shared.Configuration; using Duende.IdentityServer.Hosts.Shared.Customization; using Microsoft.AspNetCore.Authentication.Certificate; @@ -51,7 +52,7 @@ internal static class IdentityServerExtensions options.Diagnostics.ChunkSize = 1024 * 1000 - 32; // 1 MB minus some formatting space; }) .AddServerSideSessions() - .AddInMemoryClients([.. TestClients.Get()]) + .AddInMemoryClients(TestClients.Get()) .AddInMemoryIdentityResources(TestResources.IdentityResources) .AddInMemoryApiResources(TestResources.ApiResources) .AddInMemoryApiScopes(TestResources.ApiScopes) @@ -81,6 +82,7 @@ internal static class IdentityServerExtensions builder.Services.AddIdentityServerConfiguration(opt => { }) .AddInMemoryClientConfigurationStore(); + builder.Services.AddTransient(); builder.Services.AddDistributedMemoryCache(); diff --git a/identity-server/hosts/net8/AspNetIdentity8/HostingExtensions.cs b/identity-server/hosts/net8/AspNetIdentity8/HostingExtensions.cs index 08a5cf6d6..3e0a04264 100644 --- a/identity-server/hosts/net8/AspNetIdentity8/HostingExtensions.cs +++ b/identity-server/hosts/net8/AspNetIdentity8/HostingExtensions.cs @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. using Duende.IdentityServer; +using Duende.IdentityServer.Configuration; using Duende.IdentityServer.UI; using Duende.IdentityServer.UI.AspNetIdentity.Models; using IdentityServerHost.Data; @@ -120,6 +121,9 @@ internal static class HostingExtensions app.MapRazorPages() .RequireAuthorization(); + app.MapDynamicClientRegistration() + .AllowAnonymous(); + return app; } } diff --git a/identity-server/hosts/net8/AspNetIdentity8/IdentityServerExtensions.cs b/identity-server/hosts/net8/AspNetIdentity8/IdentityServerExtensions.cs index fd7c56e7d..a0b121b7e 100644 --- a/identity-server/hosts/net8/AspNetIdentity8/IdentityServerExtensions.cs +++ b/identity-server/hosts/net8/AspNetIdentity8/IdentityServerExtensions.cs @@ -1,7 +1,10 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Configuration.RequestProcessing; using Duende.IdentityServer.Hosts.Shared.Configuration; +using Duende.IdentityServer.Hosts.Shared.Customization; using Duende.IdentityServer.UI.AspNetIdentity.Models; namespace IdentityServerHost; @@ -26,6 +29,10 @@ internal static class IdentityServerExtensions .AddInMemoryClients(TestClients.Get()) .AddAspNetIdentity(); + builder.Services.AddIdentityServerConfiguration(opt => { }) + .AddInMemoryClientConfigurationStore(); + builder.Services.AddTransient(); + return builder; } } diff --git a/identity-server/hosts/net8/Main8/HostingExtensions.cs b/identity-server/hosts/net8/Main8/HostingExtensions.cs index 7435c6324..bd61b74ac 100644 --- a/identity-server/hosts/net8/Main8/HostingExtensions.cs +++ b/identity-server/hosts/net8/Main8/HostingExtensions.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Security.Claims; using Duende.IdentityServer; +using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Hosts.Shared.Customization; using Duende.IdentityServer.UI; using Microsoft.AspNetCore.Mvc.Razor; @@ -171,6 +172,9 @@ internal static class HostingExtensions app.MapRazorPages() .RequireAuthorization(); + app.MapDynamicClientRegistration() + .AllowAnonymous(); + return app; } } diff --git a/identity-server/hosts/net8/Main8/IdentityServerExtensions.cs b/identity-server/hosts/net8/Main8/IdentityServerExtensions.cs index e9108e231..f42ddb663 100644 --- a/identity-server/hosts/net8/Main8/IdentityServerExtensions.cs +++ b/identity-server/hosts/net8/Main8/IdentityServerExtensions.cs @@ -5,6 +5,7 @@ using System.Security.Cryptography.X509Certificates; using Duende.IdentityModel; using Duende.IdentityServer; using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Configuration.RequestProcessing; using Duende.IdentityServer.Hosts.Shared.Configuration; using Duende.IdentityServer.Hosts.Shared.Customization; using Microsoft.AspNetCore.Authentication.Certificate; @@ -51,7 +52,7 @@ internal static class IdentityServerExtensions options.Diagnostics.ChunkSize = 1024 * 1000 - 32; // 1 MB minus some formatting space; }) .AddServerSideSessions() - .AddInMemoryClients([.. TestClients.Get()]) + .AddInMemoryClients(TestClients.Get()) .AddInMemoryIdentityResources(TestResources.IdentityResources) .AddInMemoryApiResources(TestResources.ApiResources) .AddInMemoryApiScopes(TestResources.ApiScopes) @@ -81,6 +82,7 @@ internal static class IdentityServerExtensions builder.Services.AddIdentityServerConfiguration(opt => { }) .AddInMemoryClientConfigurationStore(); + builder.Services.AddTransient(); builder.Services.AddDistributedMemoryCache(); diff --git a/identity-server/hosts/net9/AspNetIdentity9/HostingExtensions.cs b/identity-server/hosts/net9/AspNetIdentity9/HostingExtensions.cs index 1023ca2df..d9619ec7d 100644 --- a/identity-server/hosts/net9/AspNetIdentity9/HostingExtensions.cs +++ b/identity-server/hosts/net9/AspNetIdentity9/HostingExtensions.cs @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. using Duende.IdentityServer; +using Duende.IdentityServer.Configuration; using Duende.IdentityServer.UI; using Duende.IdentityServer.UI.AspNetIdentity.Models; using IdentityServerHost.Data; @@ -122,6 +123,9 @@ internal static class HostingExtensions app.MapRazorPages() .RequireAuthorization(); + app.MapDynamicClientRegistration() + .AllowAnonymous(); + return app; } } diff --git a/identity-server/hosts/net9/AspNetIdentity9/IdentityServerExtensions.cs b/identity-server/hosts/net9/AspNetIdentity9/IdentityServerExtensions.cs index fd7c56e7d..a0b121b7e 100644 --- a/identity-server/hosts/net9/AspNetIdentity9/IdentityServerExtensions.cs +++ b/identity-server/hosts/net9/AspNetIdentity9/IdentityServerExtensions.cs @@ -1,7 +1,10 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Configuration.RequestProcessing; using Duende.IdentityServer.Hosts.Shared.Configuration; +using Duende.IdentityServer.Hosts.Shared.Customization; using Duende.IdentityServer.UI.AspNetIdentity.Models; namespace IdentityServerHost; @@ -26,6 +29,10 @@ internal static class IdentityServerExtensions .AddInMemoryClients(TestClients.Get()) .AddAspNetIdentity(); + builder.Services.AddIdentityServerConfiguration(opt => { }) + .AddInMemoryClientConfigurationStore(); + builder.Services.AddTransient(); + return builder; } } diff --git a/identity-server/hosts/net9/Main9/HostingExtensions.cs b/identity-server/hosts/net9/Main9/HostingExtensions.cs index 7435c6324..bd61b74ac 100644 --- a/identity-server/hosts/net9/Main9/HostingExtensions.cs +++ b/identity-server/hosts/net9/Main9/HostingExtensions.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Security.Claims; using Duende.IdentityServer; +using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Hosts.Shared.Customization; using Duende.IdentityServer.UI; using Microsoft.AspNetCore.Mvc.Razor; @@ -171,6 +172,9 @@ internal static class HostingExtensions app.MapRazorPages() .RequireAuthorization(); + app.MapDynamicClientRegistration() + .AllowAnonymous(); + return app; } } diff --git a/identity-server/hosts/net9/Main9/IdentityServerExtensions.cs b/identity-server/hosts/net9/Main9/IdentityServerExtensions.cs index e9108e231..f42ddb663 100644 --- a/identity-server/hosts/net9/Main9/IdentityServerExtensions.cs +++ b/identity-server/hosts/net9/Main9/IdentityServerExtensions.cs @@ -5,6 +5,7 @@ using System.Security.Cryptography.X509Certificates; using Duende.IdentityModel; using Duende.IdentityServer; using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Configuration.RequestProcessing; using Duende.IdentityServer.Hosts.Shared.Configuration; using Duende.IdentityServer.Hosts.Shared.Customization; using Microsoft.AspNetCore.Authentication.Certificate; @@ -51,7 +52,7 @@ internal static class IdentityServerExtensions options.Diagnostics.ChunkSize = 1024 * 1000 - 32; // 1 MB minus some formatting space; }) .AddServerSideSessions() - .AddInMemoryClients([.. TestClients.Get()]) + .AddInMemoryClients(TestClients.Get()) .AddInMemoryIdentityResources(TestResources.IdentityResources) .AddInMemoryApiResources(TestResources.ApiResources) .AddInMemoryApiScopes(TestResources.ApiScopes) @@ -81,6 +82,7 @@ internal static class IdentityServerExtensions builder.Services.AddIdentityServerConfiguration(opt => { }) .AddInMemoryClientConfigurationStore(); + builder.Services.AddTransient(); builder.Services.AddDistributedMemoryCache(); diff --git a/identity-server/src/Configuration/RequestProcessing/DynamicClientRegistrationRequestProcessor.cs b/identity-server/src/Configuration/RequestProcessing/DynamicClientRegistrationRequestProcessor.cs index d8f62495c..55c41b4db 100644 --- a/identity-server/src/Configuration/RequestProcessing/DynamicClientRegistrationRequestProcessor.cs +++ b/identity-server/src/Configuration/RequestProcessing/DynamicClientRegistrationRequestProcessor.cs @@ -95,7 +95,7 @@ public class DynamicClientRegistrationRequestProcessor : IDynamicClientRegistrat protected virtual async Task AddClientSecret( DynamicClientRegistrationContext context) { - if (context.Client.ClientSecrets.Count == 0) + if (context.Client.ClientSecrets.Count == 0 && context.Request.TokenEndpointAuthenticationMethod != "none") { var (secret, plainText) = await GenerateSecret(context); context.Items["secret"] = secret; diff --git a/identity-server/src/Configuration/Validation/DynamicClientRegistration/DynamicClientRegistrationValidator.cs b/identity-server/src/Configuration/Validation/DynamicClientRegistration/DynamicClientRegistrationValidator.cs index ec518dc4b..3e38f8238 100644 --- a/identity-server/src/Configuration/Validation/DynamicClientRegistration/DynamicClientRegistrationValidator.cs +++ b/identity-server/src/Configuration/Validation/DynamicClientRegistration/DynamicClientRegistrationValidator.cs @@ -129,6 +129,12 @@ public class DynamicClientRegistrationValidator : IDynamicClientRegistrationVali if (context.Request.GrantTypes.Contains(OidcConstants.GrantTypes.ClientCredentials)) { + if (context.Request.RequireClientSecret is false || + context.Request.TokenEndpointAuthenticationMethod == "none") + { + return StepResult.Failure("client secret is required for client credentials grant type"); + } + context.Client.AllowedGrantTypes.Add(GrantType.ClientCredentials); } if (context.Request.GrantTypes.Contains(OidcConstants.GrantTypes.AuthorizationCode)) @@ -482,6 +488,11 @@ public class DynamicClientRegistrationValidator : IDynamicClientRegistrationVali { context.Client.RequireClientSecret = context.Request.RequireClientSecret.Value; } + else if (context.Request.TokenEndpointAuthenticationMethod == "none") + { + context.Client.RequireClientSecret = false; + } + return StepResult.Success(); } diff --git a/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs index a83a1338f..3165efe60 100644 --- a/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs +++ b/identity-server/src/IdentityServer/Configuration/DependencyInjection/BuilderExtensions/Core.cs @@ -236,11 +236,10 @@ public static class IdentityServerBuilderExtensionsCore builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(serviceProvider => new DiagnosticSummary( + builder.Services.AddSingleton(); + builder.Services.AddSingleton(serviceProvider => new DiagnosticDataService( DateTime.UtcNow, - serviceProvider.GetServices(), - serviceProvider.GetRequiredService(), - serviceProvider.GetRequiredService())); + serviceProvider.GetServices())); builder.Services.AddHostedService(); return builder; diff --git a/identity-server/src/IdentityServer/Constants.cs b/identity-server/src/IdentityServer/Constants.cs index 031fb8cd7..98e6a91e4 100644 --- a/identity-server/src/IdentityServer/Constants.cs +++ b/identity-server/src/IdentityServer/Constants.cs @@ -110,7 +110,7 @@ internal static class Constants OidcConstants.PromptModes.Consent, OidcConstants.PromptModes.SelectAccount, // Create not in here by default -- it's added if customer sets the CreateAccountUrl user interaction option - //OidcConstants.PromptModes.Create, + //OidcConstants.PromptModes.Create, }; /// @@ -222,6 +222,7 @@ internal static class Constants public const string IdentityServerBasePath = "idsvr:IdentityServerBasePath"; public const string SignOutCalled = "idsvr:IdentityServerSignOutCalled"; public const string DetectedExpiredUserSession = "idsvr:IdentityServerDetectedExpiredUserSession"; + public const string BackChannlLogoutTriggered = "idsvr:IdentityServerBackChannlLogoutTriggered"; } public static class TokenTypeHints diff --git a/identity-server/src/IdentityServer/Endpoints/Results/AuthorizeResult.cs b/identity-server/src/IdentityServer/Endpoints/Results/AuthorizeResult.cs index 92334d692..91dc0151e 100644 --- a/identity-server/src/IdentityServer/Endpoints/Results/AuthorizeResult.cs +++ b/identity-server/src/IdentityServer/Endpoints/Results/AuthorizeResult.cs @@ -223,6 +223,19 @@ public class AuthorizeHttpWriter : IHttpResponseWriter await uiLocalesService.StoreUiLocalesForRedirectAsync(response.Request?.UiLocales); } + var errorModel = await CreateErrorMessage(response, context); + + var message = new Message(errorModel, _clock.UtcNow.UtcDateTime); + var id = await _errorMessageStore.WriteAsync(message); + + var errorUrl = _options.UserInteraction.ErrorUrl; + + var url = errorUrl.AddQueryString(_options.UserInteraction.ErrorIdParameter, id); + context.Response.Redirect(_urls.GetAbsoluteUrl(url)); + } + + protected virtual Task CreateErrorMessage(AuthorizeResponse response, HttpContext context) + { var errorModel = new ErrorMessage { ActivityId = System.Diagnostics.Activity.Current?.Id, @@ -234,12 +247,6 @@ public class AuthorizeHttpWriter : IHttpResponseWriter ClientId = response.Request?.ClientId }; - var message = new Message(errorModel, _clock.UtcNow.UtcDateTime); - var id = await _errorMessageStore.WriteAsync(message); - - var errorUrl = _options.UserInteraction.ErrorUrl; - - var url = errorUrl.AddQueryString(_options.UserInteraction.ErrorIdParameter, id); - context.Response.Redirect(_urls.GetAbsoluteUrl(url)); + return Task.FromResult(errorModel); } } diff --git a/identity-server/src/IdentityServer/Extensions/HttpContextExtensions.cs b/identity-server/src/IdentityServer/Extensions/HttpContextExtensions.cs index 14a1f9910..a62cb40b5 100644 --- a/identity-server/src/IdentityServer/Extensions/HttpContextExtensions.cs +++ b/identity-server/src/IdentityServer/Extensions/HttpContextExtensions.cs @@ -23,6 +23,14 @@ public static class HttpContextExtensions internal static bool GetSignOutCalled(this HttpContext context) => context.Items.ContainsKey(Constants.EnvironmentKeys.SignOutCalled); + internal static void SetBackChannelLogoutTriggered(this HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + context.Items[Constants.EnvironmentKeys.BackChannlLogoutTriggered] = "true"; + } + + internal static bool GetBackChannelLogoutTriggered(this HttpContext context) => context.Items.ContainsKey(Constants.EnvironmentKeys.BackChannlLogoutTriggered); + internal static void SetExpiredUserSession(this HttpContext context, UserSession userSession) { ArgumentNullException.ThrowIfNull(context); diff --git a/identity-server/src/IdentityServer/Hosting/IdentityServerAuthenticationService.cs b/identity-server/src/IdentityServer/Hosting/IdentityServerAuthenticationService.cs index a9a41c376..d906486e3 100644 --- a/identity-server/src/IdentityServer/Hosting/IdentityServerAuthenticationService.cs +++ b/identity-server/src/IdentityServer/Hosting/IdentityServerAuthenticationService.cs @@ -7,6 +7,7 @@ using System.Security.Claims; using Duende.IdentityModel; using Duende.IdentityServer.Configuration.DependencyInjection; using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Models; using Duende.IdentityServer.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; @@ -27,6 +28,8 @@ internal class IdentityServerAuthenticationService : IAuthenticationService private readonly IAuthenticationSchemeProvider _schemes; private readonly IClock _clock; private readonly IUserSession _session; + private readonly IIssuerNameService _issuerNameService; + private readonly ISessionCoordinationService _sessionCoordinationService; private readonly ILogger _logger; public IdentityServerAuthenticationService( @@ -34,6 +37,8 @@ internal class IdentityServerAuthenticationService : IAuthenticationService IAuthenticationSchemeProvider schemes, IClock clock, IUserSession session, + IIssuerNameService issuerNameService, + ISessionCoordinationService sessionCoordinationService, ILogger logger) { _inner = decorator.Instance; @@ -41,6 +46,8 @@ internal class IdentityServerAuthenticationService : IAuthenticationService _schemes = schemes; _clock = clock; _session = session; + _issuerNameService = issuerNameService; + _sessionCoordinationService = sessionCoordinationService; _logger = logger; } @@ -77,6 +84,38 @@ internal class IdentityServerAuthenticationService : IAuthenticationService { // this sets a flag used by middleware to do post-signout work. context.SetSignOutCalled(); + + if (!context.GetBackChannelLogoutTriggered()) + { + // Note: it is important the work for triggering back-channel logout + // is inside the Response.OnStarting event. Otherwise, in some conditions + // the request will never complete. + // See: https://github.com/DuendeArchive/IdentityServer4/issues/4644 + context.Response.OnStarting(async () => + { + _logger.LogDebug("SignOutCalled set; processing post-signout session cleanup."); + + // back channel logout + var user = await _session.GetUserAsync(); + if (user != null) + { + var session = new UserSession + { + SubjectId = user.GetSubjectId(), + SessionId = await _session.GetSessionIdAsync(), + DisplayName = user.GetDisplayName(), + ClientIds = (await _session.GetClientListAsync()).ToList(), + Issuer = await _issuerNameService.GetCurrentAsync() + }; + await _sessionCoordinationService.ProcessLogoutAsync(session); + } + + // this clears our session id cookie so JS clients can detect the user has signed out + await _session.RemoveSessionIdCookieAsync(); + }); + + context.SetBackChannelLogoutTriggered(); + } } await _inner.SignOutAsync(context, scheme, properties); diff --git a/identity-server/src/IdentityServer/Hosting/IdentityServerMiddleware.cs b/identity-server/src/IdentityServer/Hosting/IdentityServerMiddleware.cs index 9d59e692d..315d1e751 100644 --- a/identity-server/src/IdentityServer/Hosting/IdentityServerMiddleware.cs +++ b/identity-server/src/IdentityServer/Hosting/IdentityServerMiddleware.cs @@ -7,7 +7,6 @@ using Duende.IdentityServer.Events; using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Licensing.V2; using Duende.IdentityServer.Logging; -using Duende.IdentityServer.Models; using Duende.IdentityServer.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; @@ -61,28 +60,6 @@ public class IdentityServerMiddleware context.Response.OnStarting(async () => { - if (context.GetSignOutCalled()) - { - _sanitizedLogger.LogDebug("SignOutCalled set; processing post-signout session cleanup."); - - // this clears our session id cookie so JS clients can detect the user has signed out - await userSession.RemoveSessionIdCookieAsync(); - - var user = await userSession.GetUserAsync(); - if (user != null) - { - var session = new UserSession - { - SubjectId = user.GetSubjectId(), - SessionId = await userSession.GetSessionIdAsync(), - DisplayName = user.GetDisplayName(), - ClientIds = (await userSession.GetClientListAsync()).ToList(), - Issuer = await issuerNameService.GetCurrentAsync() - }; - await sessionCoordinationService.ProcessLogoutAsync(session); - } - } - if (context.TryGetExpiredUserSession(out var expiredUserSession)) { _sanitizedLogger.LogDebug("Detected expired session removed; processing post-expiration cleanup."); diff --git a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticSummary.cs b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticSummary.cs index 6e7e78e78..c30e7fc78 100644 --- a/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticSummary.cs +++ b/identity-server/src/IdentityServer/Licensing/V2/Diagnostics/DiagnosticSummary.cs @@ -1,36 +1,21 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. -using System.Buffers; using System.Text; -using System.Text.Json; using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Services; using Microsoft.Extensions.Logging; namespace Duende.IdentityServer.Licensing.V2.Diagnostics; -internal class DiagnosticSummary(DateTime serverStartTime, IEnumerable entries, IdentityServerOptions options, ILoggerFactory loggerFactory) +internal class DiagnosticSummary(DiagnosticDataService diagnosticDataService, IdentityServerOptions options, ILoggerFactory loggerFactory) { private readonly ILogger _logger = loggerFactory.CreateLogger("Duende.IdentityServer.Diagnostics.Summary"); public async Task PrintSummary() { - var bufferWriter = new ArrayBufferWriter(); - await using var writer = new Utf8JsonWriter(bufferWriter, new JsonWriterOptions { Indented = false }); - - writer.WriteStartObject(); - - var diagnosticContext = new DiagnosticContext(serverStartTime, DateTime.UtcNow); - foreach (var diagnosticEntry in entries) - { - await diagnosticEntry.WriteAsync(diagnosticContext, writer); - } - - writer.WriteEndObject(); - - await writer.FlushAsync(); - - var span = bufferWriter.WrittenSpan; + var jsonMemory = await diagnosticDataService.GetJsonBytesAsync(); + var span = jsonMemory.Span; using var diagnosticActivity = Tracing.DiagnosticsActivitySource.StartActivity("DiagnosticSummary"); var chunkSize = options.Diagnostics.ChunkSize; @@ -47,7 +32,7 @@ internal class DiagnosticSummary(DateTime serverStartTime, IEnumerable _entries; + + internal DiagnosticDataService(DateTime serverStartTime, IEnumerable entries) + { + _serverStartTime = serverStartTime; + _entries = entries; + } + + public async Task> GetJsonBytesAsync() + { + var bufferWriter = new ArrayBufferWriter(); + await using var writer = new Utf8JsonWriter(bufferWriter, new JsonWriterOptions { Indented = false }); + + writer.WriteStartObject(); + + var diagnosticContext = new DiagnosticContext(_serverStartTime, DateTime.UtcNow); + foreach (var diagnosticEntry in _entries) + { + await diagnosticEntry.WriteAsync(diagnosticContext, writer); + } + + writer.WriteEndObject(); + + await writer.FlushAsync(); + + return bufferWriter.WrittenMemory; + } + + public async Task GetJsonStringAsync() + { + var bytes = await GetJsonBytesAsync(); + return Encoding.UTF8.GetString(bytes.Span); + } +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/Configuration/DynamicClientRegistrationTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Configuration/DynamicClientRegistrationTests.cs index 4d4679f3a..54995bd0f 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/Configuration/DynamicClientRegistrationTests.cs +++ b/identity-server/test/IdentityServer.IntegrationTests/Configuration/DynamicClientRegistrationTests.cs @@ -39,4 +39,28 @@ public class DynamicClientRegistrationTests : ConfigurationIntegrationTestBase newClient.ClientSecrets.Count.ShouldBe(1); newClient.ClientSecrets.Single().Value.ShouldBe(response.ClientSecret.Sha256()); } + + [Fact] + public async Task request_for_public_client_does_not_require_client_secret() + { + IdentityServerHost.ApiScopes.Add(new ApiScope("api1")); + + var request = new DynamicClientRegistrationRequest + { + RedirectUris = new[] { new Uri("https://example.com/callback") }, + GrantTypes = new[] { "authorization_code" }, + ClientName = "test", + ClientUri = new Uri("https://example.com"), + DefaultMaxAge = 10000, + Scope = "api1 openid profile", + TokenEndpointAuthenticationMethod = "none" + }; + var httpResponse = await ConfigurationHost.HttpClient!.PostAsJsonAsync("/connect/dcr", request); + + var response = await httpResponse.Content.ReadFromJsonAsync(); + response.ShouldNotBeNull(); + response.ClientSecret.ShouldBeNull(); + response.RequireClientSecret.ShouldNotBeNull(); + response.RequireClientSecret.Value.ShouldBeFalse(); + } } diff --git a/identity-server/test/IdentityServer.IntegrationTests/Configuration/DynamicClientRegistrationValidationTests.cs b/identity-server/test/IdentityServer.IntegrationTests/Configuration/DynamicClientRegistrationValidationTests.cs index 14a1dc0aa..5d233cc2d 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/Configuration/DynamicClientRegistrationValidationTests.cs +++ b/identity-server/test/IdentityServer.IntegrationTests/Configuration/DynamicClientRegistrationValidationTests.cs @@ -113,4 +113,34 @@ public class DynamicClientRegistrationValidationTests : ConfigurationIntegration var error = await response.Content.ReadFromJsonAsync(); error?.Error.ShouldBe("invalid_client_metadata"); } + + [Fact] + public async Task client_credentials_and_do_not_require_client_secret_should_fail() + { + var response = await ConfigurationHost.HttpClient!.PostAsJsonAsync("/connect/dcr", + new DynamicClientRegistrationRequest + { + GrantTypes = { "client_credentials" }, + RequireClientSecret = false + }); + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + + var error = await response.Content.ReadFromJsonAsync(); + error?.Error.ShouldBe("invalid_client_metadata"); + } + + [Fact] + public async Task client_credentials_and_token_endpoint_auth_method_none_should_fail() + { + var response = await ConfigurationHost.HttpClient!.PostAsJsonAsync("/connect/dcr", + new DynamicClientRegistrationRequest + { + GrantTypes = { "client_credentials" }, + TokenEndpointAuthenticationMethod = "none" + }); + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + + var error = await response.Content.ReadFromJsonAsync(); + error?.Error.ShouldBe("invalid_client_metadata"); + } } diff --git a/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticHostedServiceTests.cs b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticHostedServiceTests.cs index 9fba275d3..5969bb650 100644 --- a/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticHostedServiceTests.cs +++ b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticHostedServiceTests.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Licensing.V2.Diagnostics; +using Duende.IdentityServer.Services; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -24,7 +25,8 @@ public class DiagnosticHostedServiceTests secondDiagnosticEntry, thirdDiagnosticEntry }; - var diagnosticSummary = new DiagnosticSummary(DateTime.UtcNow, entries, new IdentityServerOptions(), new StubLoggerFactory(diagnosticSummaryLogger)); + var diagnosticService = new DiagnosticDataService(DateTime.UtcNow, entries); + var diagnosticSummary = new DiagnosticSummary(diagnosticService, new IdentityServerOptions(), new StubLoggerFactory(diagnosticSummaryLogger)); var options = Options.Create(new IdentityServerOptions()); var logger = new NullLogger(); diff --git a/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticSummaryTests.cs b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticSummaryTests.cs index ab3ae75bd..eaf48b94b 100644 --- a/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticSummaryTests.cs +++ b/identity-server/test/IdentityServer.UnitTests/Licensing/v2/DiagnosticSummaryTests.cs @@ -5,6 +5,7 @@ using System.Text.Json; using Duende.IdentityServer.Configuration; using Duende.IdentityServer.Events; using Duende.IdentityServer.Licensing.V2.Diagnostics; +using Duende.IdentityServer.Services; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; @@ -25,7 +26,8 @@ public class DiagnosticSummaryTests secondDiagnosticEntry, thirdDiagnosticEntry }; - var summary = new DiagnosticSummary(DateTime.UtcNow, entries, new IdentityServerOptions(), new StubLoggerFactory(logger)); + var diagnosticService = new DiagnosticDataService(DateTime.UtcNow, entries); + var summary = new DiagnosticSummary(diagnosticService, new IdentityServerOptions(), new StubLoggerFactory(logger)); await summary.PrintSummary(); @@ -42,7 +44,8 @@ public class DiagnosticSummaryTests var logger = new FakeLogger(); var diagnosticEntry = new LongDiagnosticEntry { OutputLength = chunkSize * 2 }; - var summary = new DiagnosticSummary(DateTime.UtcNow, [diagnosticEntry], options, new StubLoggerFactory(logger)); + var diagnosticService = new DiagnosticDataService(DateTime.UtcNow, [diagnosticEntry]); + var summary = new DiagnosticSummary(diagnosticService, options, new StubLoggerFactory(logger)); await summary.PrintSummary(); @@ -61,7 +64,9 @@ public class DiagnosticSummaryTests var logger = new FakeLogger(); var diagnosticEntry = new LongDiagnosticEntry { OutputLength = 2, OutputCharacter = '€' }; - var summary = new DiagnosticSummary(DateTime.UtcNow, [diagnosticEntry], options, new StubLoggerFactory(logger)); + var diagnosticService = new DiagnosticDataService(DateTime.UtcNow, [diagnosticEntry]); + var summary = new DiagnosticSummary(diagnosticService, options, new StubLoggerFactory(logger)); + await summary.PrintSummary(); @@ -76,7 +81,9 @@ public class DiagnosticSummaryTests var logger = new FakeLogger(); var diagnosticEntry = new LongDiagnosticEntry { OutputLength = options.Diagnostics.ChunkSize * 2 }; - var summary = new DiagnosticSummary(DateTime.UtcNow, [diagnosticEntry], options, new StubLoggerFactory(logger)); + var diagnosticService = new DiagnosticDataService(DateTime.UtcNow, [diagnosticEntry]); + var summary = new DiagnosticSummary(diagnosticService, options, new StubLoggerFactory(logger)); + await summary.PrintSummary(); foreach (var entry in logger.Collector.GetSnapshot()) @@ -91,7 +98,8 @@ public class DiagnosticSummaryTests var options = new IdentityServerOptions(); var logger = new FakeLogger(); var diagnosticEntry = new LongDiagnosticEntry { OutputLength = 100000 }; - var summary = new DiagnosticSummary(DateTime.UtcNow, [diagnosticEntry], options, new StubLoggerFactory(logger)); + var diagnosticService = new DiagnosticDataService(DateTime.UtcNow, [diagnosticEntry]); + var summary = new DiagnosticSummary(diagnosticService, options, new StubLoggerFactory(logger)); await summary.PrintSummary(); diff --git a/identity-server/test/IdentityServer.UnitTests/Services/DiagnosticDataServiceTests.cs b/identity-server/test/IdentityServer.UnitTests/Services/DiagnosticDataServiceTests.cs new file mode 100644 index 000000000..ef4178da0 --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Services/DiagnosticDataServiceTests.cs @@ -0,0 +1,272 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Text; +using System.Text.Json; +using Duende.IdentityServer.Licensing.V2.Diagnostics; +using Duende.IdentityServer.Services; + +namespace IdentityServer.UnitTests.Services; + +public class DiagnosticDataServiceTests +{ + [Fact] + public async Task GetJsonBytesAsync_WithNoEntries_ShouldReturnEmptyJsonObject() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List(); + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonBytesAsync(); + + var json = Encoding.UTF8.GetString(result.Span); + json.ShouldBe("{}"); + } + + [Fact] + public async Task GetJsonBytesAsync_WithSingleEntry_ShouldReturnValidJson() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List + { + new TestDiagnosticEntry("TestProperty", "TestValue") + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonBytesAsync(); + + var json = Encoding.UTF8.GetString(result.Span); + var jsonDoc = JsonDocument.Parse(json); + jsonDoc.RootElement.GetProperty("TestProperty").GetString().ShouldBe("TestValue"); + } + + [Fact] + public async Task GetJsonBytesAsync_WithMultipleEntries_ShouldIncludeAllEntries() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List + { + new TestDiagnosticEntry("Property1", "Value1"), + new TestDiagnosticEntry("Property2", "Value2"), + new TestDiagnosticEntry("Property3", "Value3") + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonBytesAsync(); + + var json = Encoding.UTF8.GetString(result.Span); + var jsonDoc = JsonDocument.Parse(json); + jsonDoc.RootElement.GetProperty("Property1").GetString().ShouldBe("Value1"); + jsonDoc.RootElement.GetProperty("Property2").GetString().ShouldBe("Value2"); + jsonDoc.RootElement.GetProperty("Property3").GetString().ShouldBe("Value3"); + } + + [Fact] + public async Task GetJsonBytesAsync_ShouldPassCorrectDiagnosticContext() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var capturedContext = new TestDiagnosticEntry.ContextCapture(); + var entries = new List + { + new TestDiagnosticEntry("TestProperty", "TestValue", capturedContext) + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + await service.GetJsonBytesAsync(); + + capturedContext.Context.ShouldNotBeNull(); + capturedContext.Context.ServerStartTime.ShouldBe(serverStartTime); + capturedContext.Context.CurrentSeverTime.ShouldBeGreaterThanOrEqualTo(serverStartTime); + } + + [Fact] + public async Task GetJsonBytesAsync_ShouldProduceCompactJson() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List + { + new TestDiagnosticEntry("Property1", "Value1"), + new TestDiagnosticEntry("Property2", "Value2") + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonBytesAsync(); + + var json = Encoding.UTF8.GetString(result.Span); + json.ShouldNotContain("\n"); + json.ShouldNotContain("\r"); + json.ShouldNotContain(" "); + } + + [Fact] + public async Task GetJsonStringAsync_WithNoEntries_ShouldReturnEmptyJsonObject() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List(); + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonStringAsync(); + + result.ShouldBe("{}"); + } + + [Fact] + public async Task GetJsonStringAsync_WithSingleEntry_ShouldReturnValidJson() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List + { + new TestDiagnosticEntry("TestProperty", "TestValue") + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonStringAsync(); + + var jsonDoc = JsonDocument.Parse(result); + jsonDoc.RootElement.GetProperty("TestProperty").GetString().ShouldBe("TestValue"); + } + + [Fact] + public async Task GetJsonStringAsync_WithMultipleEntries_ShouldIncludeAllEntries() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List + { + new TestDiagnosticEntry("Property1", "Value1"), + new TestDiagnosticEntry("Property2", "Value2"), + new TestDiagnosticEntry("Property3", "Value3") + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonStringAsync(); + + var jsonDoc = JsonDocument.Parse(result); + jsonDoc.RootElement.GetProperty("Property1").GetString().ShouldBe("Value1"); + jsonDoc.RootElement.GetProperty("Property2").GetString().ShouldBe("Value2"); + jsonDoc.RootElement.GetProperty("Property3").GetString().ShouldBe("Value3"); + } + + [Fact] + public async Task GetJsonStringAsync_ShouldReturnUtf8EncodedString() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List + { + new TestDiagnosticEntry("Property", "Value with émojis 🎉") + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonStringAsync(); + + var jsonDoc = JsonDocument.Parse(result); + jsonDoc.RootElement.GetProperty("Property").GetString().ShouldBe("Value with émojis 🎉"); + } + + [Fact] + public async Task GetJsonStringAsync_ShouldMatchGetJsonBytesAsync() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List + { + new TestDiagnosticEntry("Property1", "Value1"), + new TestDiagnosticEntry("Property2", "Value2") + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + var stringResult = await service.GetJsonStringAsync(); + var bytesResult = await service.GetJsonBytesAsync(); + var stringFromBytes = Encoding.UTF8.GetString(bytesResult.Span); + + stringResult.ShouldBe(stringFromBytes); + } + + [Fact] + public async Task GetJsonBytesAsync_WithComplexEntry_ShouldWriteNestedObjects() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List + { + new ComplexTestDiagnosticEntry() + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonBytesAsync(); + + var json = Encoding.UTF8.GetString(result.Span); + var jsonDoc = JsonDocument.Parse(json); + var complex = jsonDoc.RootElement.GetProperty("ComplexData"); + complex.GetProperty("StringValue").GetString().ShouldBe("test"); + complex.GetProperty("NumberValue").GetInt32().ShouldBe(42); + complex.GetProperty("BoolValue").GetBoolean().ShouldBeTrue(); + } + + [Fact] + public async Task GetJsonBytesAsync_WithAsyncEntry_ShouldHandleAsyncWrites() + { + var serverStartTime = DateTime.UtcNow.AddMinutes(-5); + var entries = new List + { + new AsyncTestDiagnosticEntry() + }; + var service = new DiagnosticDataService(serverStartTime, entries); + + var result = await service.GetJsonBytesAsync(); + + var json = Encoding.UTF8.GetString(result.Span); + var jsonDoc = JsonDocument.Parse(json); + jsonDoc.RootElement.GetProperty("AsyncData").GetString().ShouldBe("async value"); + } + + // Test helper classes + private class TestDiagnosticEntry : IDiagnosticEntry + { + private readonly string _propertyName; + private readonly string _propertyValue; + private readonly ContextCapture _contextCapture; + + public TestDiagnosticEntry(string propertyName, string propertyValue, ContextCapture contextCapture = null) + { + _propertyName = propertyName; + _propertyValue = propertyValue; + _contextCapture = contextCapture; + } + + public Task WriteAsync(DiagnosticContext context, Utf8JsonWriter writer) + { + if (_contextCapture != null) + { + _contextCapture.Context = context; + } + writer.WriteString(_propertyName, _propertyValue); + return Task.CompletedTask; + } + + public class ContextCapture + { + public DiagnosticContext Context { get; set; } + } + } + + private class ComplexTestDiagnosticEntry : IDiagnosticEntry + { + public Task WriteAsync(DiagnosticContext context, Utf8JsonWriter writer) + { + writer.WritePropertyName("ComplexData"); + writer.WriteStartObject(); + writer.WriteString("StringValue", "test"); + writer.WriteNumber("NumberValue", 42); + writer.WriteBoolean("BoolValue", true); + writer.WriteEndObject(); + return Task.CompletedTask; + } + } + + private class AsyncTestDiagnosticEntry : IDiagnosticEntry + { + public async Task WriteAsync(DiagnosticContext context, Utf8JsonWriter writer) + { + await Task.Delay(1); + writer.WriteString("AsyncData", "async value"); + } + } +}