From db4fb6fc370e57e46d9eb1dac8b19dc0d4e9b0fe Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 10:27:36 +0200 Subject: [PATCH 01/25] Nullable improvments for StringExtensions. --- .../src/Extensions/StringsExtensions.cs | 29 ++++++++++--------- .../Utility/InternalStringExtensions.cs | 5 ++-- .../src/Extensions/StringsExtensions.cs | 8 +++-- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/Open.IdentityServer/src/Extensions/StringsExtensions.cs b/src/Open.IdentityServer/src/Extensions/StringsExtensions.cs index 31f86870b..d12d60a53 100644 --- a/src/Open.IdentityServer/src/Extensions/StringsExtensions.cs +++ b/src/Open.IdentityServer/src/Extensions/StringsExtensions.cs @@ -7,10 +7,13 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; using System.Text.Encodings.Web; +#nullable enable + namespace Open.IdentityServer.Extensions; internal static class StringExtensions @@ -47,7 +50,7 @@ public static IEnumerable FromSpaceSeparatedString(this string input) return input.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries).ToList(); } - public static List ParseScopesString(this string scopes) + public static List? ParseScopesString(this string? scopes) { if (scopes.IsMissing()) { @@ -67,13 +70,13 @@ public static List ParseScopesString(this string scopes) } [DebuggerStepThrough] - public static bool IsMissing(this string value) + public static bool IsMissing([NotNullWhen(false)] this string? value) { return string.IsNullOrWhiteSpace(value); } [DebuggerStepThrough] - public static bool IsMissingOrTooLong(this string value, int maxLength) + public static bool IsMissingOrTooLong(this string? value, int maxLength) { if (string.IsNullOrWhiteSpace(value)) { @@ -89,13 +92,13 @@ public static bool IsMissingOrTooLong(this string value, int maxLength) } [DebuggerStepThrough] - public static bool IsPresent(this string value) + public static bool IsPresent([NotNullWhen(true)] this string? value) { return !string.IsNullOrWhiteSpace(value); } [DebuggerStepThrough] - public static string EnsureLeadingSlash(this string url) + public static string? EnsureLeadingSlash(this string? url) { if (url != null && !url.StartsWith("/")) { @@ -106,7 +109,7 @@ public static string EnsureLeadingSlash(this string url) } [DebuggerStepThrough] - public static string EnsureTrailingSlash(this string url) + public static string? EnsureTrailingSlash(this string? url) { if (url != null && !url.EndsWith("/")) { @@ -117,7 +120,7 @@ public static string EnsureTrailingSlash(this string url) } [DebuggerStepThrough] - public static string RemoveLeadingSlash(this string url) + public static string? RemoveLeadingSlash(this string? url) { if (url != null && url.StartsWith("/")) { @@ -128,7 +131,7 @@ public static string RemoveLeadingSlash(this string url) } [DebuggerStepThrough] - public static string RemoveTrailingSlash(this string url) + public static string? RemoveTrailingSlash(this string? url) { if (url != null && url.EndsWith("/")) { @@ -139,9 +142,9 @@ public static string RemoveTrailingSlash(this string url) } [DebuggerStepThrough] - public static string CleanUrlPath(this string url) + public static string CleanUrlPath(this string? url) { - if (String.IsNullOrWhiteSpace(url)) url = "/"; + if (string.IsNullOrWhiteSpace(url)) url = "/"; if (url != "/" && url.EndsWith("/")) { @@ -153,7 +156,7 @@ public static string CleanUrlPath(this string url) [DebuggerStepThrough] // Clone of UrlHelperBase.CheckIsLocalUrl from https://github.com/dotnet/aspnetcore/blob/3f1acb59718cadf111a0a796681e3d3509bb3381/src/Mvc/Mvc.Core/src/Routing/UrlHelperBase.cs - public static bool IsLocalUrl(this string url) + public static bool IsLocalUrl(this string? url) { if (string.IsNullOrEmpty(url)) { @@ -246,7 +249,7 @@ public static string AddHashFragment(this string url, string query) } [DebuggerStepThrough] - public static NameValueCollection ReadQueryStringAsNameValueCollection(this string url) + public static NameValueCollection ReadQueryStringAsNameValueCollection(this string? url) { if (url != null) { @@ -266,7 +269,7 @@ public static NameValueCollection ReadQueryStringAsNameValueCollection(this stri return new NameValueCollection(); } - public static string GetOrigin(this string url) + public static string? GetOrigin(this string? url) { if (url != null) { diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/InternalStringExtensions.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/InternalStringExtensions.cs index 06fc17f4d..c3fce0580 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/InternalStringExtensions.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/InternalStringExtensions.cs @@ -1,4 +1,5 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. #nullable enable @@ -11,13 +12,13 @@ namespace IdentityServer.IntegrationTests.Utility; internal static class InternalStringExtensions { [DebuggerStepThrough] - public static bool IsMissing(this string value) + public static bool IsMissing([NotNullWhen(false)] this string? value) { return string.IsNullOrWhiteSpace(value); } [DebuggerStepThrough] - public static bool IsPresent(this string value) + public static bool IsPresent([NotNullWhen(true)] this string? value) { return !(value.IsMissing()); } diff --git a/src/Storage/src/Extensions/StringsExtensions.cs b/src/Storage/src/Extensions/StringsExtensions.cs index 4aec2bf9b..8c46339be 100644 --- a/src/Storage/src/Extensions/StringsExtensions.cs +++ b/src/Storage/src/Extensions/StringsExtensions.cs @@ -1,21 +1,25 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +#nullable enable namespace Open.IdentityServer.Extensions; internal static class StringExtensions { [DebuggerStepThrough] - public static bool IsMissing(this string value) + public static bool IsMissing([NotNullWhen(false)] this string? value) { return string.IsNullOrWhiteSpace(value); } [DebuggerStepThrough] - public static bool IsPresent(this string value) + public static bool IsPresent([NotNullWhen(true)] this string? value) { return !string.IsNullOrWhiteSpace(value); } From 642f53c9bf60c4bc77bd28cbf92e779bd3ee0a06 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 13:13:55 +0200 Subject: [PATCH 02/25] Corrected prompt_login_should_show_login_page test and added the same test for max_age=0. --- .../Endpoints/Authorize/AuthorizeTests.cs | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs index 15320f0b0..af36eacea 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs @@ -1182,7 +1182,30 @@ public async Task prompt_login_should_show_login_page() nonce: "123_nonce", extra: new Parameters { - { "popup", "login" }, + { "prompt", "login" }, + } + ); + await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); + + _mockPipeline.LoginWasCalled.Should().BeTrue(); + } + + [Fact] + [Trait("Category", Category)] + public async Task max_age_0_should_show_login_page() + { + await _mockPipeline.LoginAsync("bob"); + + var url = _mockPipeline.CreateAuthorizeUrl( + clientId: "client3", + responseType: "id_token", + scope: "openid profile", + redirectUri: "https://client3/callback", + state: "123_state", + nonce: "123_nonce", + extra: new Parameters + { + { "max_age", "0" }, } ); await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); From fdcf4e6d7dd920e69e7ec1ca2218e088adbc4288 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 13:26:41 +0200 Subject: [PATCH 03/25] Added test for loging in and returning for both prompt and max_age. Added RemoveMaxAge to handle max_age the same way. --- .../ValidatedAuthorizeRequestExtensions.cs | 10 +++ .../AuthorizeInteractionResponseGenerator.cs | 4 ++ .../Common/IdentityServerPipeline.cs | 4 +- .../Endpoints/Authorize/AuthorizeTests.cs | 62 +++++++++++++++++-- 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/Open.IdentityServer/src/Extensions/ValidatedAuthorizeRequestExtensions.cs b/src/Open.IdentityServer/src/Extensions/ValidatedAuthorizeRequestExtensions.cs index 4d64fc3a5..0148d9d2b 100644 --- a/src/Open.IdentityServer/src/Extensions/ValidatedAuthorizeRequestExtensions.cs +++ b/src/Open.IdentityServer/src/Extensions/ValidatedAuthorizeRequestExtensions.cs @@ -28,6 +28,16 @@ public static void RemovePrompt(this ValidatedAuthorizeRequest request) request.Raw.Remove(OidcConstants.AuthorizeRequest.Prompt); } + /// + /// Removes the max_age parameter from the request. + /// + /// The validated authorize request. + public static void RemoveMaxAge(this ValidatedAuthorizeRequest request) + { + request.MaxAge = null; + request.Raw.Remove(OidcConstants.AuthorizeRequest.MaxAge); + } + /// /// Gets the first ACR value that starts with the specified prefix, with the prefix removed. /// diff --git a/src/Open.IdentityServer/src/ResponseHandling/Default/AuthorizeInteractionResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/Default/AuthorizeInteractionResponseGenerator.cs index 6d79f657c..04e4a2148 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Default/AuthorizeInteractionResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/AuthorizeInteractionResponseGenerator.cs @@ -192,6 +192,10 @@ protected internal virtual async Task ProcessLoginAsync(Val { Logger.LogInformation("Showing login: Requested MaxAge exceeded."); + // remove max_age so when we redirect back in from login page + // we won't think we need to force a max_age again + request.RemoveMaxAge(); + return new InteractionResponse { IsLogin = true }; } } diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs index f03d6b190..bb04cc9fb 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs @@ -187,6 +187,7 @@ public void ConfigureApp(IApplicationBuilder app) } public bool LoginWasCalled { get; set; } + public string? LoginReturnUrl { get; set; } public AuthorizationRequest? LoginRequest { get; set; } public ClaimsPrincipal? Subject { get; set; } public bool FollowLoginReturnUrl { get; set; } @@ -201,7 +202,8 @@ private async Task OnLogin(HttpContext ctx) private async Task ReadLoginRequest(HttpContext ctx) { var interaction = ctx.RequestServices.GetRequiredService(); - LoginRequest = await interaction.GetAuthorizationContextAsync(ctx.Request.Query["returnUrl"].FirstOrDefault()); + LoginReturnUrl = ctx.Request.Query["returnUrl"].FirstOrDefault(); + LoginRequest = await interaction.GetAuthorizationContextAsync(LoginReturnUrl); } private async Task IssueLoginCookie(HttpContext ctx) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs index af36eacea..df34bc515 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs @@ -1174,10 +1174,10 @@ public async Task prompt_login_should_show_login_page() await _mockPipeline.LoginAsync("bob"); var url = _mockPipeline.CreateAuthorizeUrl( - clientId: "client3", + clientId: "client1", responseType: "id_token", scope: "openid profile", - redirectUri: "https://client3/callback", + redirectUri: "https://client1/callback", state: "123_state", nonce: "123_nonce", extra: new Parameters @@ -1190,6 +1190,33 @@ public async Task prompt_login_should_show_login_page() _mockPipeline.LoginWasCalled.Should().BeTrue(); } + [Fact] + [Trait("Category", Category)] + public async Task prompt_login_should_allow_user_to_login_and_return() + { + await _mockPipeline.LoginAsync("bob"); + + var url = _mockPipeline.CreateAuthorizeUrl( + clientId: "client1", + responseType: "id_token", + scope: "openid profile", + redirectUri: "https://client1/callback", + state: "123_state", + nonce: "123_nonce", + extra: new Parameters + { + { "prompt", "login" }, + } + ); + await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); + + _mockPipeline.BrowserClient.AllowAutoRedirect = false; + var response = await _mockPipeline.BrowserClient.GetAsync(IdentityServerPipeline.BaseUrl + _mockPipeline.LoginReturnUrl); + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location.ToString().Should().StartWith("https://client1/callback"); + response.Headers.Location.ToString().Should().Contain("id_token="); + } + [Fact] [Trait("Category", Category)] public async Task max_age_0_should_show_login_page() @@ -1197,10 +1224,10 @@ public async Task max_age_0_should_show_login_page() await _mockPipeline.LoginAsync("bob"); var url = _mockPipeline.CreateAuthorizeUrl( - clientId: "client3", + clientId: "client1", responseType: "id_token", scope: "openid profile", - redirectUri: "https://client3/callback", + redirectUri: "https://client1/callback", state: "123_state", nonce: "123_nonce", extra: new Parameters @@ -1212,4 +1239,31 @@ public async Task max_age_0_should_show_login_page() _mockPipeline.LoginWasCalled.Should().BeTrue(); } + + [Fact] + [Trait("Category", Category)] + public async Task max_age_0_should_allow_user_to_login_and_return() + { + await _mockPipeline.LoginAsync("bob"); + + var url = _mockPipeline.CreateAuthorizeUrl( + clientId: "client1", + responseType: "id_token", + scope: "openid profile", + redirectUri: "https://client1/callback", + state: "123_state", + nonce: "123_nonce", + extra: new Parameters + { + { "max_age", "0" }, + } + ); + await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); + + _mockPipeline.BrowserClient.AllowAutoRedirect = false; + var response = await _mockPipeline.BrowserClient.GetAsync(IdentityServerPipeline.BaseUrl + _mockPipeline.LoginReturnUrl); + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location.ToString().Should().StartWith("https://client1/callback"); + response.Headers.Location.ToString().Should().Contain("id_token="); + } } \ No newline at end of file From 09857ef1229f2313a417f50cf286b1b805fd0f2f Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 13:32:28 +0200 Subject: [PATCH 04/25] Added failing tests for letting the login page know prompt/max_age values. --- .../Endpoints/Authorize/AuthorizeTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs index df34bc515..779184487 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs @@ -1188,6 +1188,7 @@ public async Task prompt_login_should_show_login_page() await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); _mockPipeline.LoginWasCalled.Should().BeTrue(); + _mockPipeline.LoginRequest.PromptModes.Should().Contain("login"); } [Fact] @@ -1238,6 +1239,7 @@ public async Task max_age_0_should_show_login_page() await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); _mockPipeline.LoginWasCalled.Should().BeTrue(); + _mockPipeline.LoginRequest.Parameters.Get(OidcConstants.AuthorizeRequest.MaxAge).Should().Be("0"); } [Fact] From 0a2aece4777e646633721ad9bf69accf0fb9b1ab Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 13:36:50 +0200 Subject: [PATCH 05/25] Removing the prompt/max_age parameters from callback endpoint, but keeping the values otherwise so that the login page knows way login is shown. --- .../Endpoints/AuthorizeCallbackEndpoint.cs | 3 +++ .../ValidatedAuthorizeRequestExtensions.cs | 20 ------------------- .../AuthorizeInteractionResponseGenerator.cs | 8 -------- 3 files changed, 3 insertions(+), 28 deletions(-) diff --git a/src/Open.IdentityServer/src/Endpoints/AuthorizeCallbackEndpoint.cs b/src/Open.IdentityServer/src/Endpoints/AuthorizeCallbackEndpoint.cs index 8555cd39a..5b04d0372 100644 --- a/src/Open.IdentityServer/src/Endpoints/AuthorizeCallbackEndpoint.cs +++ b/src/Open.IdentityServer/src/Endpoints/AuthorizeCallbackEndpoint.cs @@ -76,6 +76,9 @@ public override async Task ProcessAsync(HttpContext context) try { + parameters.Remove(OidcConstants.AuthorizeRequest.Prompt); + parameters.Remove(OidcConstants.AuthorizeRequest.MaxAge); + var result = await ProcessAuthorizeRequestAsync(parameters, user, consent?.Data); Logger.LogTrace("End Authorize Request. Result type: {0}", result?.GetType().ToString() ?? "-none-"); diff --git a/src/Open.IdentityServer/src/Extensions/ValidatedAuthorizeRequestExtensions.cs b/src/Open.IdentityServer/src/Extensions/ValidatedAuthorizeRequestExtensions.cs index 0148d9d2b..936e4743e 100644 --- a/src/Open.IdentityServer/src/Extensions/ValidatedAuthorizeRequestExtensions.cs +++ b/src/Open.IdentityServer/src/Extensions/ValidatedAuthorizeRequestExtensions.cs @@ -18,26 +18,6 @@ namespace Open.IdentityServer.Validation; /// public static class ValidatedAuthorizeRequestExtensions { - /// - /// Removes the prompt parameter from the request. - /// - /// The validated authorize request. - public static void RemovePrompt(this ValidatedAuthorizeRequest request) - { - request.PromptModes = Enumerable.Empty(); - request.Raw.Remove(OidcConstants.AuthorizeRequest.Prompt); - } - - /// - /// Removes the max_age parameter from the request. - /// - /// The validated authorize request. - public static void RemoveMaxAge(this ValidatedAuthorizeRequest request) - { - request.MaxAge = null; - request.Raw.Remove(OidcConstants.AuthorizeRequest.MaxAge); - } - /// /// Gets the first ACR value that starts with the specified prefix, with the prefix removed. /// diff --git a/src/Open.IdentityServer/src/ResponseHandling/Default/AuthorizeInteractionResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/Default/AuthorizeInteractionResponseGenerator.cs index 04e4a2148..936bb312f 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Default/AuthorizeInteractionResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/AuthorizeInteractionResponseGenerator.cs @@ -134,10 +134,6 @@ protected internal virtual async Task ProcessLoginAsync(Val request.PromptModes.Contains(OidcConstants.PromptModes.SelectAccount)) { Logger.LogInformation("Showing login: request contains prompt={0}", request.PromptModes.ToSpaceSeparatedString()); - - // remove prompt so when we redirect back in from login page - // we won't think we need to force a prompt again - request.RemovePrompt(); return new InteractionResponse { IsLogin = true }; } @@ -192,10 +188,6 @@ protected internal virtual async Task ProcessLoginAsync(Val { Logger.LogInformation("Showing login: Requested MaxAge exceeded."); - // remove max_age so when we redirect back in from login page - // we won't think we need to force a max_age again - request.RemoveMaxAge(); - return new InteractionResponse { IsLogin = true }; } } From e405d4db9ca9c8e4ba9ebc15279a9a7dd3baac6b Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 13:43:50 +0200 Subject: [PATCH 06/25] prompt=create is only allowed by itself. --- .../src/Validation/Default/AuthorizeRequestValidator.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs b/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs index b098abe5a..7fce27c45 100644 --- a/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs +++ b/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs @@ -736,6 +736,12 @@ private async Task ValidateOptionalParametersA return Invalid(request, description: "Invalid prompt"); } + if (prompts.Contains(OidcConstants.PromptModes.Create) && prompts.Length > 1) + { + LogError("prompt contains 'create' and other values. 'create' should be used by itself.", request); + return Invalid(request, description: "Invalid prompt"); + } + request.PromptModes = prompts; } else From 1b37db48f5e0000a88c1202db8cf94ac38229cce Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 14:11:18 +0200 Subject: [PATCH 07/25] Test for combining prompt=create with any additional value. --- .../Endpoints/Authorize/AuthorizeTests.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs index 779184487..146a4659f 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs @@ -1167,6 +1167,29 @@ public async Task code_flow_with_fragment_response_type_should_be_allowed() } + [Fact] + [Trait("Category", Category)] + public async Task prompt_login_and_create_should_return_error() + { + await _mockPipeline.LoginAsync("bob"); + + var url = _mockPipeline.CreateAuthorizeUrl( + clientId: "client1", + responseType: "id_token", + scope: "openid profile", + redirectUri: "https://client1/callback", + state: "123_state", + nonce: "123_nonce", + extra: new Parameters + { + { "prompt", "login create" }, + } + ); + await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); + + _mockPipeline.ErrorWasCalled.Should().BeTrue(); + } + [Fact] [Trait("Category", Category)] public async Task prompt_login_should_show_login_page() From 4a9d2292e055ff57b032ea08688b97515a448a97 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 14:49:40 +0200 Subject: [PATCH 08/25] Added support for prompt=create --- .../Options/UserInteractionOptions.cs | 26 +++++++++ ...ntityServerApplicationBuilderExtensions.cs | 7 +++ src/Open.IdentityServer/src/Constants.cs | 1 + .../src/Endpoints/AuthorizeEndpointBase.cs | 4 ++ .../Results/CreateAccountPageResult.cs | 47 +++++++++++++++ .../AuthorizeInteractionResponseGenerator.cs | 58 ++++++++++++++----- .../Models/InteractionResponse.cs | 9 +++ .../Default/AuthorizeRequestValidator.cs | 2 +- .../Common/IdentityServerPipeline.cs | 21 +++++++ .../Endpoints/Authorize/AuthorizeTests.cs | 46 ++++++++++++++- 10 files changed, 201 insertions(+), 20 deletions(-) create mode 100644 src/Open.IdentityServer/src/Endpoints/Results/CreateAccountPageResult.cs diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/UserInteractionOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/UserInteractionOptions.cs index 3d6ba568d..c76d66a3e 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/UserInteractionOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/UserInteractionOptions.cs @@ -1,8 +1,10 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. using Open.IdentityServer.Extensions; +using System.Collections.Generic; namespace Open.IdentityServer.Configuration; @@ -106,4 +108,28 @@ public class UserInteractionOptions /// The device verification user code parameter. /// public string DeviceVerificationUserCodeParameter { get; set; } = Constants.UIConstants.DefaultRoutePathParams.UserCode; + + /// + /// Gets or sets the create account URL. If a local URL, the value must start with a leading slash. + /// + /// + /// The create account URL. + /// + public string CreateAccountUrl { get; set; } + + /// + /// Gets or sets the create account return URL parameter. + /// + /// + /// The create account return URL parameter. + /// + public string CreateAccountReturnUrlParameter { get; set; } = Constants.UIConstants.DefaultRoutePathParams.CreateAccount; + + /// + /// Gets or sets the supported prompt modes. + /// + /// + /// The supported prompt modes. + /// + public List SupportedPromptModes { get; set; } = Constants.SupportedPromptModes; } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Configuration/IdentityServerApplicationBuilderExtensions.cs b/src/Open.IdentityServer/src/Configuration/IdentityServerApplicationBuilderExtensions.cs index a953d5587..6c458ad90 100644 --- a/src/Open.IdentityServer/src/Configuration/IdentityServerApplicationBuilderExtensions.cs +++ b/src/Open.IdentityServer/src/Configuration/IdentityServerApplicationBuilderExtensions.cs @@ -12,6 +12,7 @@ using System; using System.Reflection; using System.Threading.Tasks; +using Open.IdentityServer; namespace Microsoft.AspNetCore.Builder; @@ -132,6 +133,12 @@ private static void ValidateOptions(IdentityServerOptions options, ILogger logge if (options.UserInteraction.ConsentReturnUrlParameter.IsMissing()) throw new InvalidOperationException("ConsentReturnUrlParameter is not configured"); if (options.UserInteraction.CustomRedirectReturnUrlParameter.IsMissing()) throw new InvalidOperationException("CustomRedirectReturnUrlParameter is not configured"); + if (options.UserInteraction.CreateAccountUrl.IsPresent()) + { + if (options.UserInteraction.CreateAccountReturnUrlParameter.IsMissing()) throw new InvalidOperationException("CreateAccountReturnUrlParameter is not configured"); + options.UserInteraction.SupportedPromptModes.Add(OidcConstants.PromptModes.Create); + } + if (options.Authentication.CheckSessionCookieName.IsMissing()) throw new InvalidOperationException("CheckSessionCookieName is not configured"); if (options.Cors.CorsPolicyName.IsMissing()) throw new InvalidOperationException("CorsPolicyName is not configured"); diff --git a/src/Open.IdentityServer/src/Constants.cs b/src/Open.IdentityServer/src/Constants.cs index 487ef5540..97478597f 100644 --- a/src/Open.IdentityServer/src/Constants.cs +++ b/src/Open.IdentityServer/src/Constants.cs @@ -177,6 +177,7 @@ public static class DefaultRoutePathParams { public const string Error = "errorId"; public const string Login = "returnUrl"; + public const string CreateAccount = "returnUrl"; public const string Consent = "returnUrl"; public const string Logout = "logoutId"; public const string EndSessionCallback = "endSessionId"; diff --git a/src/Open.IdentityServer/src/Endpoints/AuthorizeEndpointBase.cs b/src/Open.IdentityServer/src/Endpoints/AuthorizeEndpointBase.cs index 9e795e041..0c6fb500f 100644 --- a/src/Open.IdentityServer/src/Endpoints/AuthorizeEndpointBase.cs +++ b/src/Open.IdentityServer/src/Endpoints/AuthorizeEndpointBase.cs @@ -95,6 +95,10 @@ internal async Task ProcessAuthorizeRequestAsync(NameValueColle { return new LoginPageResult(request); } + if (interactionResult.IsCreateAccount) + { + return new CreateAccountPageResult(request); + } if (interactionResult.IsConsent) { return new ConsentPageResult(request); diff --git a/src/Open.IdentityServer/src/Endpoints/Results/CreateAccountPageResult.cs b/src/Open.IdentityServer/src/Endpoints/Results/CreateAccountPageResult.cs new file mode 100644 index 000000000..95eb9f559 --- /dev/null +++ b/src/Open.IdentityServer/src/Endpoints/Results/CreateAccountPageResult.cs @@ -0,0 +1,47 @@ +// Copyright (c) Rock Solid Knowledge Ltd. All rights reserved. +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + + +using System.Threading.Tasks; +using Open.IdentityServer.Validation; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Stores; +using Microsoft.AspNetCore.Http; + +namespace Open.IdentityServer.Endpoints.Results; + +/// +/// Result for login page +/// +/// +public class CreateAccountPageResult : ReturnUrlResult +{ + /// + /// Initializes a new instance of the class. + /// + /// The request. + /// request + public CreateAccountPageResult(ValidatedAuthorizeRequest request): + base(request) { } + + internal CreateAccountPageResult( + ValidatedAuthorizeRequest request, + IdentityServerOptions options, + IAuthorizationParametersMessageStore authorizationParametersMessageStore = null): + base(request, options, authorizationParametersMessageStore) { } + + /// + /// Executes the result. + /// + /// The HTTP context. + public override async Task ExecuteAsync(HttpContext context) + { + Init(context); + var createUrl = Options.UserInteraction.CreateAccountUrl; + var returnUrl = await BuildReturnUrl(context, createUrl.IsLocalUrl()); + + var url = createUrl.AddQueryString(Options.UserInteraction.CreateAccountReturnUrlParameter, returnUrl); + context.Response.RedirectToAbsoluteUrl(url); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/ResponseHandling/Default/AuthorizeInteractionResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/Default/AuthorizeInteractionResponseGenerator.cs index 936bb312f..3c8457dd4 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Default/AuthorizeInteractionResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/AuthorizeInteractionResponseGenerator.cs @@ -39,7 +39,7 @@ public class AuthorizeInteractionResponseGenerator : IAuthorizeInteractionRespon /// The clock /// protected readonly TimeProvider Clock; - + /// /// The telemetry /// @@ -56,7 +56,7 @@ public class AuthorizeInteractionResponseGenerator : IAuthorizeInteractionRespon public AuthorizeInteractionResponseGenerator( TimeProvider clock, ILogger logger, - IConsentService consent, + IConsentService consent, IProfileService profile, ITelemetryService telemetry) { @@ -64,7 +64,7 @@ public AuthorizeInteractionResponseGenerator( Logger = logger; Consent = consent; Profile = profile; - Telemetry = telemetry; + Telemetry = telemetry; } /// @@ -78,8 +78,8 @@ public virtual async Task ProcessInteractionAsync(Validated using var trace = Telemetry.Trace(TelemetryConstants.TraceCategories.Basic, this); Logger.LogTrace("ProcessInteractionAsync"); - if (consent != null && - consent.Granted == false && + if (consent != null && + consent.Granted == false && consent.Error.HasValue) { // special case when anonymous user has issued an error prior to authenticating @@ -93,7 +93,7 @@ public virtual async Task ProcessInteractionAsync(Validated AuthorizationError.LoginRequired => OidcConstants.AuthorizeErrors.LoginRequired, _ => OidcConstants.AuthorizeErrors.AccessDenied }; - + return new InteractionResponse { Error = error, @@ -101,11 +101,15 @@ public virtual async Task ProcessInteractionAsync(Validated }; } - var result = await ProcessLoginAsync(request); - - if (!result.IsLogin && !result.IsError && !result.IsRedirect) + var result = await ProcessCreateAsync(request); + if (!result.IsCreateAccount && !result.IsError && !result.IsRedirect) { - result = await ProcessConsentAsync(request, consent); + result = await ProcessLoginAsync(request); + + if (!result.IsLogin && !result.IsError && !result.IsRedirect) + { + result = await ProcessConsentAsync(request, consent); + } } if ((result.IsLogin || result.IsConsent || result.IsRedirect) && request.PromptModes.Contains(OidcConstants.PromptModes.None)) @@ -115,7 +119,7 @@ public virtual async Task ProcessInteractionAsync(Validated result = new InteractionResponse { Error = result.IsLogin ? OidcConstants.AuthorizeErrors.LoginRequired : - result.IsConsent ? OidcConstants.AuthorizeErrors.ConsentRequired : + result.IsConsent ? OidcConstants.AuthorizeErrors.ConsentRequired : OidcConstants.AuthorizeErrors.InteractionRequired }; } @@ -134,13 +138,13 @@ protected internal virtual async Task ProcessLoginAsync(Val request.PromptModes.Contains(OidcConstants.PromptModes.SelectAccount)) { Logger.LogInformation("Showing login: request contains prompt={0}", request.PromptModes.ToSpaceSeparatedString()); - + return new InteractionResponse { IsLogin = true }; } // unauthenticated user var isAuthenticated = request.Subject.IsAuthenticated(); - + // user de-activated bool isActive = false; @@ -148,7 +152,7 @@ protected internal virtual async Task ProcessLoginAsync(Val { var isActiveCtx = new IsActiveContext(request.Subject, request.Client, IdentityServerConstants.ProfileIsActiveCallers.AuthorizeEndpoint); await Profile.IsActiveAsync(isActiveCtx); - + isActive = isActiveCtx.IsActive; } @@ -202,7 +206,7 @@ protected internal virtual async Task ProcessLoginAsync(Val } } // check external idp restrictions if user not using local idp - else if (request.Client.IdentityProviderRestrictions != null && + else if (request.Client.IdentityProviderRestrictions != null && request.Client.IdentityProviderRestrictions.Any() && !request.Client.IdentityProviderRestrictions.Contains(currentIdp)) { @@ -227,6 +231,28 @@ protected internal virtual async Task ProcessLoginAsync(Val return new InteractionResponse(); } + /// + /// Processes the create account logic. + /// + /// The request. + /// A task that resolves to an indicating whether the create account screen should be shown. + /// is . + protected internal virtual Task ProcessCreateAsync(ValidatedAuthorizeRequest request) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + + var response = new InteractionResponse(); + + if (request.PromptModes.Contains(OidcConstants.PromptModes.Create)) + { + Logger.LogInformation("Showing create account: request contains prompt=create"); + + response.IsCreateAccount = true; + } + + return Task.FromResult(response); + } + /// /// Processes the consent logic. /// @@ -290,7 +316,7 @@ protected internal virtual async Task ProcessConsentAsync(V AuthorizationError.LoginRequired => OidcConstants.AuthorizeErrors.LoginRequired, _ => OidcConstants.AuthorizeErrors.AccessDenied }; - + response.Error = error; response.ErrorDescription = consent.ErrorDescription; } diff --git a/src/Open.IdentityServer/src/ResponseHandling/Models/InteractionResponse.cs b/src/Open.IdentityServer/src/ResponseHandling/Models/InteractionResponse.cs index 67eac3bf7..592a7ace1 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Models/InteractionResponse.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Models/InteractionResponse.cs @@ -1,4 +1,5 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. @@ -19,6 +20,14 @@ public class InteractionResponse /// public bool IsLogin { get; set; } + /// + /// Gets or sets a value indicating whether the user should create an account. + /// + /// + /// true if this instance is create; otherwise, false. + /// + public bool IsCreateAccount { get; set; } + /// /// Gets or sets a value indicating whether the user must consent. /// diff --git a/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs b/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs index 7fce27c45..1657b7c1b 100644 --- a/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs +++ b/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs @@ -728,7 +728,7 @@ private async Task ValidateOptionalParametersA if (prompt.IsPresent()) { var prompts = prompt.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (prompts.All(p => Constants.SupportedPromptModes.Contains(p))) + if (prompts.All(p => _options.UserInteraction.SupportedPromptModes.Contains(p))) { if (prompts.Contains(OidcConstants.PromptModes.None) && prompts.Length > 1) { diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs index bb04cc9fb..b7262cfc1 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs @@ -36,6 +36,8 @@ public class IdentityServerPipeline public const string LoginPage = BaseUrl + "/account/login"; public const string ConsentPage = BaseUrl + "/account/consent"; public const string ErrorPage = BaseUrl + "/home/error"; + public const string CreatePageRelative = "/account/create"; + public const string CreatePage = BaseUrl + CreatePageRelative; public const string DeviceAuthorization = BaseUrl + "/connect/deviceauthorization"; public const string DiscoveryEndpoint = BaseUrl + "/.well-known/openid-configuration"; @@ -182,6 +184,10 @@ public void ConfigureApp(IApplicationBuilder app) { path.Run(ctx => OnError(ctx)); }); + app.Map(CreatePageRelative, path => + { + path.Run(ctx => OnCreate(ctx)); + }); OnPostConfigure(app); } @@ -279,6 +285,21 @@ private async Task OnError(HttpContext ctx) await ReadErrorMessage(ctx); } + public bool CreateWasCalled { get; set; } + public AuthorizationRequest? CreateRequest { get; set; } + + private async Task OnCreate(HttpContext ctx) + { + CreateWasCalled = true; + await ReadCreateMessage(ctx); + } + + private async Task ReadCreateMessage(HttpContext ctx) + { + var interaction = ctx.RequestServices.GetRequiredService(); + CreateRequest = await interaction.GetAuthorizationContextAsync(ctx.Request.Query["returnUrl"].FirstOrDefault()); + } + private async Task ReadErrorMessage(HttpContext ctx) { var interaction = ctx.RequestServices.GetRequiredService(); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs index 146a4659f..dcd9d9380 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs @@ -18,6 +18,7 @@ using Open.IdentityServer.Test; using Microsoft.Extensions.DependencyInjection; using Xunit; +using Open.IdentityServer.Configuration; namespace IdentityServer.IntegrationTests.Endpoints.Authorize; @@ -1166,11 +1167,19 @@ public async Task code_flow_with_fragment_response_type_should_be_allowed() _mockPipeline.LoginWasCalled.Should().BeTrue(); } - [Fact] [Trait("Category", Category)] - public async Task prompt_login_and_create_should_return_error() + public async Task prompt_create_and_login_should_return_error() { + _mockPipeline.OnPreConfigureServices += services => + { + services.PostConfigure(options => + { + options.UserInteraction.SupportedPromptModes.Add(OidcConstants.PromptModes.Create); + }); + }; + _mockPipeline.Initialize(); + await _mockPipeline.LoginAsync("bob"); var url = _mockPipeline.CreateAuthorizeUrl( @@ -1182,7 +1191,7 @@ public async Task prompt_login_and_create_should_return_error() nonce: "123_nonce", extra: new Parameters { - { "prompt", "login create" }, + { "prompt", "create login" }, } ); await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); @@ -1190,6 +1199,37 @@ public async Task prompt_login_and_create_should_return_error() _mockPipeline.ErrorWasCalled.Should().BeTrue(); } + [Fact] + [Trait("Category", Category)] + public async Task prompt_create_should_show_login_page() + { + _mockPipeline.OnPreConfigureServices += services => + { + services.PostConfigure(options => + { + options.UserInteraction.CreateAccountUrl = IdentityServerPipeline.CreatePageRelative; + }); + }; + _mockPipeline.Initialize(); + + var url = _mockPipeline.CreateAuthorizeUrl( + clientId: "client1", + responseType: "id_token", + scope: "openid profile", + redirectUri: "https://client1/callback", + state: "123_state", + nonce: "123_nonce", + extra: new Parameters + { + { "prompt", "create" }, + } + ); + await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); + + _mockPipeline.CreateWasCalled.Should().BeTrue(); + _mockPipeline.CreateRequest.PromptModes.Should().Contain("create"); + } + [Fact] [Trait("Category", Category)] public async Task prompt_login_should_show_login_page() From 18bf5c5fd9d998a3afbfae39d8e1926a7cb53e6d Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 14:53:30 +0200 Subject: [PATCH 09/25] Failing on unsupported prompt modes. --- .../Default/AuthorizeRequestValidator.cs | 3 ++- .../Endpoints/Authorize/AuthorizeTests.cs | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs b/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs index 1657b7c1b..870bcef2d 100644 --- a/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs +++ b/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs @@ -746,7 +746,8 @@ private async Task ValidateOptionalParametersA } else { - _logger.LogDebug("Unsupported prompt mode - ignored: " + prompt); + LogError("prompt contains unsupported values " + prompt, request); + return Invalid(request, description: "Invalid prompt"); } } diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs index dcd9d9380..7f443bc23 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs @@ -1167,6 +1167,27 @@ public async Task code_flow_with_fragment_response_type_should_be_allowed() _mockPipeline.LoginWasCalled.Should().BeTrue(); } + [Fact] + [Trait("Category", Category)] + public async Task unsupported_prompt_should_return_error() + { + var url = _mockPipeline.CreateAuthorizeUrl( + clientId: "client1", + responseType: "id_token", + scope: "openid profile", + redirectUri: "https://client1/callback", + state: "123_state", + nonce: "123_nonce", + extra: new Parameters + { + { "prompt", "unsupported" }, + } + ); + await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); + + _mockPipeline.ErrorWasCalled.Should().BeTrue(); + } + [Fact] [Trait("Category", Category)] public async Task prompt_create_and_login_should_return_error() From d9bd10111b56db0faa512ae6d960b152d2b40df5 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 14:57:56 +0200 Subject: [PATCH 10/25] Added missing copyright --- .../Configuration/IdentityServerApplicationBuilderExtensions.cs | 1 + .../src/Extensions/ValidatedAuthorizeRequestExtensions.cs | 1 + .../Common/IdentityServerPipeline.cs | 1 + .../Endpoints/Authorize/AuthorizeTests.cs | 1 + 4 files changed, 4 insertions(+) diff --git a/src/Open.IdentityServer/src/Configuration/IdentityServerApplicationBuilderExtensions.cs b/src/Open.IdentityServer/src/Configuration/IdentityServerApplicationBuilderExtensions.cs index 6c458ad90..348d04ebd 100644 --- a/src/Open.IdentityServer/src/Configuration/IdentityServerApplicationBuilderExtensions.cs +++ b/src/Open.IdentityServer/src/Configuration/IdentityServerApplicationBuilderExtensions.cs @@ -1,4 +1,5 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. diff --git a/src/Open.IdentityServer/src/Extensions/ValidatedAuthorizeRequestExtensions.cs b/src/Open.IdentityServer/src/Extensions/ValidatedAuthorizeRequestExtensions.cs index 936e4743e..91ee50cbe 100644 --- a/src/Open.IdentityServer/src/Extensions/ValidatedAuthorizeRequestExtensions.cs +++ b/src/Open.IdentityServer/src/Extensions/ValidatedAuthorizeRequestExtensions.cs @@ -1,4 +1,5 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs index b7262cfc1..8577ce422 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs @@ -1,4 +1,5 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. #nullable enable diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs index 7f443bc23..d86c82fee 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs @@ -1,4 +1,5 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. From 1ebd4600eadb8de2712ab9c70247391cd9697f3b Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 15:15:34 +0200 Subject: [PATCH 11/25] Changed failing unit test to now ensure that prompt values are kept. --- .../AuthorizeInteractionResponseGeneratorTests_Login.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/AuthorizeInteractionResponseGenerator/AuthorizeInteractionResponseGeneratorTests_Login.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/AuthorizeInteractionResponseGenerator/AuthorizeInteractionResponseGeneratorTests_Login.cs index 8a9dbdb93..3f5523515 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/AuthorizeInteractionResponseGenerator/AuthorizeInteractionResponseGeneratorTests_Login.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/AuthorizeInteractionResponseGenerator/AuthorizeInteractionResponseGeneratorTests_Login.cs @@ -258,13 +258,13 @@ public async Task prompt_select_account_should_sign_in() } [Fact] - public async Task prompt_for_signin_should_remove_prompt_from_raw_url() + public async Task prompt_for_signin_should_not_remove_prompt_from_raw_url() { var request = new ValidatedAuthorizeRequest { ClientId = "foo", Subject = new IdentityServerUser("123").CreatePrincipal(), - PromptModes = new[] { OidcConstants.PromptModes.Login }, + PromptModes = [OidcConstants.PromptModes.Login], Raw = new NameValueCollection { { OidcConstants.AuthorizeRequest.Prompt, OidcConstants.PromptModes.Login } @@ -273,6 +273,6 @@ public async Task prompt_for_signin_should_remove_prompt_from_raw_url() var result = await _subject.ProcessLoginAsync(request); - request.Raw.AllKeys.Should().NotContain(OidcConstants.AuthorizeRequest.Prompt); + request.Raw.AllKeys.Should().Contain(OidcConstants.AuthorizeRequest.Prompt); } } \ No newline at end of file From 4b2aa693d970181404d7cd2d667870862739d724 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 15:29:31 +0200 Subject: [PATCH 12/25] Fixed copy/paste name error of test. --- .../Endpoints/Authorize/AuthorizeTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs index d86c82fee..8217f541f 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs @@ -1223,7 +1223,7 @@ public async Task prompt_create_and_login_should_return_error() [Fact] [Trait("Category", Category)] - public async Task prompt_create_should_show_login_page() + public async Task prompt_create_should_show_create_account_page() { _mockPipeline.OnPreConfigureServices += services => { From 1266386b23c91dc935de97236996717ce66c2d56 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Mon, 3 Aug 2026 10:38:06 +0200 Subject: [PATCH 13/25] Add failing test for when prompt parameter is passed in a request object. --- .../Authorize/JwtRequestAuthorizeTests.cs | 63 +++++++++++++++---- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/JwtRequestAuthorizeTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/JwtRequestAuthorizeTests.cs index 9dc34351f..53dc0223e 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/JwtRequestAuthorizeTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/JwtRequestAuthorizeTests.cs @@ -2,25 +2,26 @@ // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Security.Claims; -using System.Security.Cryptography.X509Certificates; -using System.Text.Json; -using System.Threading.Tasks; using AwesomeAssertions; using IdentityServer.IntegrationTests.Common; using IdentityServer.IntegrationTests.Utility; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Logging; +using Microsoft.IdentityModel.Tokens; using Open.IdentityServer; using Open.IdentityServer.Configuration; using Open.IdentityServer.Models; using Open.IdentityServer.Test; -using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Logging; -using Microsoft.IdentityModel.Tokens; using Open.IdentityServer.Utility; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using System.Threading.Tasks; using Xunit; namespace IdentityServer.IntegrationTests.Endpoints.Authorize; @@ -1169,4 +1170,44 @@ public async Task both_request_and_request_uri_params_should_fail() _mockPipeline.JwtRequestMessageHandler.InvokeWasCalled.Should().BeFalse(); } + + [Fact] + [Trait("Category", Category)] + public async Task prompt_login_should_allow_user_to_login_and_return() + { + _mockPipeline.Options.Endpoints.EnableJwtRequestUri = true; + + var requestJwt = CreateRequestJwt( + issuer: _client.ClientId, + audience: IdentityServerPipeline.BaseUrl, + credential: new X509SigningCredentials(TestCert.Load()), + claims: + [ + new Claim("client_id", _client.ClientId), + new Claim("response_type", "id_token"), + new Claim("scope", "openid profile"), + new Claim("state", "123state"), + new Claim("nonce", "123nonce"), + new Claim("redirect_uri", "https://client/callback"), + new Claim("prompt", "login") + ]); + _mockPipeline.JwtRequestMessageHandler.Response.Content = new StringContent(requestJwt); + + await _mockPipeline.LoginAsync("bob"); + + var url = _mockPipeline.CreateAuthorizeUrl( + clientId: _client.ClientId, + responseType: "id_token", + extra: new Parameters + { + { "request", requestJwt } + }); + var response = await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); + + _mockPipeline.BrowserClient.AllowAutoRedirect = false; + response = await _mockPipeline.BrowserClient.GetAsync(IdentityServerPipeline.BaseUrl + _mockPipeline.LoginReturnUrl); + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location.ToString().Should().StartWith("https://client/callback"); + response.Headers.Location.ToString().Should().Contain("id_token="); + } } \ No newline at end of file From aae2ace10f71eae67eeef8e465e5de9de5d0bd16 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Mon, 3 Aug 2026 10:52:32 +0200 Subject: [PATCH 14/25] Changed strategy for handling that prompt and/or max_age have been processed and should not re-trigger login so that it will also work with request objects. --- src/Open.IdentityServer/src/Constants.cs | 3 + .../Endpoints/AuthorizeCallbackEndpoint.cs | 4 +- .../Default/AuthorizeRequestValidator.cs | 56 +++++++++++-------- 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/src/Open.IdentityServer/src/Constants.cs b/src/Open.IdentityServer/src/Constants.cs index 97478597f..2409bf64e 100644 --- a/src/Open.IdentityServer/src/Constants.cs +++ b/src/Open.IdentityServer/src/Constants.cs @@ -113,6 +113,9 @@ public static class SigningAlgorithms OidcConstants.PromptModes.SelectAccount }; + public const string PromptProcessed = OidcConstants.AuthorizeRequest.Prompt + "_processed"; + public const string MaxAgeProcessed = OidcConstants.AuthorizeRequest.MaxAge + "_processed"; + public static class KnownAcrValues { public const string HomeRealm = "idp:"; diff --git a/src/Open.IdentityServer/src/Endpoints/AuthorizeCallbackEndpoint.cs b/src/Open.IdentityServer/src/Endpoints/AuthorizeCallbackEndpoint.cs index 5b04d0372..28998eea4 100644 --- a/src/Open.IdentityServer/src/Endpoints/AuthorizeCallbackEndpoint.cs +++ b/src/Open.IdentityServer/src/Endpoints/AuthorizeCallbackEndpoint.cs @@ -76,8 +76,8 @@ public override async Task ProcessAsync(HttpContext context) try { - parameters.Remove(OidcConstants.AuthorizeRequest.Prompt); - parameters.Remove(OidcConstants.AuthorizeRequest.MaxAge); + parameters.Add(Constants.PromptProcessed, "true"); + parameters.Add(Constants.MaxAgeProcessed, "true"); var result = await ProcessAuthorizeRequestAsync(parameters, user, consent?.Data); diff --git a/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs b/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs index 870bcef2d..c778de650 100644 --- a/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs +++ b/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs @@ -727,27 +727,32 @@ private async Task ValidateOptionalParametersA var prompt = request.Raw.Get(OidcConstants.AuthorizeRequest.Prompt); if (prompt.IsPresent()) { - var prompts = prompt.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (prompts.All(p => _options.UserInteraction.SupportedPromptModes.Contains(p))) + var promptProcessed = request.Raw.Get(Constants.PromptProcessed); + + if (!promptProcessed.IsPresent()) { - if (prompts.Contains(OidcConstants.PromptModes.None) && prompts.Length > 1) + var prompts = prompt.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (prompts.All(p => _options.UserInteraction.SupportedPromptModes.Contains(p))) { - LogError("prompt contains 'none' and other values. 'none' should be used by itself.", request); - return Invalid(request, description: "Invalid prompt"); - } + if (prompts.Contains(OidcConstants.PromptModes.None) && prompts.Length > 1) + { + LogError("prompt contains 'none' and other values. 'none' should be used by itself.", request); + return Invalid(request, description: "Invalid prompt"); + } - if (prompts.Contains(OidcConstants.PromptModes.Create) && prompts.Length > 1) + if (prompts.Contains(OidcConstants.PromptModes.Create) && prompts.Length > 1) + { + LogError("prompt contains 'create' and other values. 'create' should be used by itself.", request); + return Invalid(request, description: "Invalid prompt"); + } + + request.PromptModes = prompts; + } + else { - LogError("prompt contains 'create' and other values. 'create' should be used by itself.", request); + LogError("prompt contains unsupported values " + prompt, request); return Invalid(request, description: "Invalid prompt"); } - - request.PromptModes = prompts; - } - else - { - LogError("prompt contains unsupported values " + prompt, request); - return Invalid(request, description: "Invalid prompt"); } } @@ -786,11 +791,21 @@ private async Task ValidateOptionalParametersA var maxAge = request.Raw.Get(OidcConstants.AuthorizeRequest.MaxAge); if (maxAge.IsPresent()) { - if (int.TryParse(maxAge, out var seconds)) + var maxAgeProcessed = request.Raw.Get(Constants.MaxAgeProcessed); + + if (!maxAgeProcessed.IsPresent()) { - if (seconds >= 0) + if (int.TryParse(maxAge, out var seconds)) { - request.MaxAge = seconds; + if (seconds >= 0) + { + request.MaxAge = seconds; + } + else + { + LogError("Invalid max_age.", request); + return Invalid(request, description: "Invalid max_age"); + } } else { @@ -798,11 +813,6 @@ private async Task ValidateOptionalParametersA return Invalid(request, description: "Invalid max_age"); } } - else - { - LogError("Invalid max_age.", request); - return Invalid(request, description: "Invalid max_age"); - } } ////////////////////////////////////////////////////////// From fb9951f394fd3cece199f7232fb39cd7b269dfd0 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 11:51:06 +0200 Subject: [PATCH 15/25] Moved constants from root to asub class. Explained the usage of the processed parameters. --- src/Open.IdentityServer/src/Constants.cs | 7 +++++-- .../src/Endpoints/AuthorizeCallbackEndpoint.cs | 5 +++-- .../src/Validation/Default/AuthorizeRequestValidator.cs | 8 ++++++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/Open.IdentityServer/src/Constants.cs b/src/Open.IdentityServer/src/Constants.cs index 2409bf64e..974743a70 100644 --- a/src/Open.IdentityServer/src/Constants.cs +++ b/src/Open.IdentityServer/src/Constants.cs @@ -113,8 +113,11 @@ public static class SigningAlgorithms OidcConstants.PromptModes.SelectAccount }; - public const string PromptProcessed = OidcConstants.AuthorizeRequest.Prompt + "_processed"; - public const string MaxAgeProcessed = OidcConstants.AuthorizeRequest.MaxAge + "_processed"; + public class ProcessedParameters + { + public const string PromptProcessed = OidcConstants.AuthorizeRequest.Prompt + "_processed"; + public const string MaxAgeProcessed = OidcConstants.AuthorizeRequest.MaxAge + "_processed"; + } public static class KnownAcrValues { diff --git a/src/Open.IdentityServer/src/Endpoints/AuthorizeCallbackEndpoint.cs b/src/Open.IdentityServer/src/Endpoints/AuthorizeCallbackEndpoint.cs index 28998eea4..413dc0d2e 100644 --- a/src/Open.IdentityServer/src/Endpoints/AuthorizeCallbackEndpoint.cs +++ b/src/Open.IdentityServer/src/Endpoints/AuthorizeCallbackEndpoint.cs @@ -76,8 +76,9 @@ public override async Task ProcessAsync(HttpContext context) try { - parameters.Add(Constants.PromptProcessed, "true"); - parameters.Add(Constants.MaxAgeProcessed, "true"); + // Add processed parameters to indicate that they have been processed + parameters.Add(Constants.ProcessedParameters.PromptProcessed, "true"); + parameters.Add(Constants.ProcessedParameters.MaxAgeProcessed, "true"); var result = await ProcessAuthorizeRequestAsync(parameters, user, consent?.Data); diff --git a/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs b/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs index c778de650..0cc17b615 100644 --- a/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs +++ b/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs @@ -727,7 +727,9 @@ private async Task ValidateOptionalParametersA var prompt = request.Raw.Get(OidcConstants.AuthorizeRequest.Prompt); if (prompt.IsPresent()) { - var promptProcessed = request.Raw.Get(Constants.PromptProcessed); + // if prompt have been processed, aka validation is called in callback, + // then we don't want to prompt the user again, so skip handling of the parameter + var promptProcessed = request.Raw.Get(Constants.ProcessedParameters.PromptProcessed); if (!promptProcessed.IsPresent()) { @@ -791,7 +793,9 @@ private async Task ValidateOptionalParametersA var maxAge = request.Raw.Get(OidcConstants.AuthorizeRequest.MaxAge); if (maxAge.IsPresent()) { - var maxAgeProcessed = request.Raw.Get(Constants.MaxAgeProcessed); + // if max_age have been processed, aka validation is called in callback, + // then we don't want to prompt the user again, so skip handling of the parameter + var maxAgeProcessed = request.Raw.Get(Constants.ProcessedParameters.MaxAgeProcessed); if (!maxAgeProcessed.IsPresent()) { From 0bff2aff1e9df6ad67256577cba5017a665e1a34 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 11:58:24 +0200 Subject: [PATCH 16/25] Added documentation of options. --- docs/reference/options.rst | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/reference/options.rst b/docs/reference/options.rst index 040115b8d..6c4fcdcc4 100644 --- a/docs/reference/options.rst +++ b/docs/reference/options.rst @@ -73,12 +73,14 @@ Allows setting length restrictions on various protocol parameters like client id UserInteraction ^^^^^^^^^^^^^^^ -* ``LoginUrl``, ``LogoutUrl``, ``ConsentUrl``, ``ErrorUrl``, ``DeviceVerificationUrl`` - Sets the URLs for the login, logout, consent, error and device verification pages. +* ``LoginUrl``, ``LogoutUrl``, ``CreateAccountUrl``, ``ConsentUrl``, ``ErrorUrl``, ``DeviceVerificationUrl`` + Sets the URLs for the login, logout, create account, consent, error and device verification pages. * ``LoginReturnUrlParameter`` Sets the name of the return URL parameter passed to the login page. Defaults to *returnUrl*. * ``LogoutIdParameter`` Sets the name of the logout message id parameter passed to the logout page. Defaults to *logoutId*. +* ``CreateAccountIdParameter`` + Sets the name of the return URL parameter passed to the create account page. Defaults to *returnUrl*. * ``ConsentReturnUrlParameter`` Sets the name of the return URL parameter passed to the consent page. Defaults to *returnUrl*. * ``ErrorIdParameter`` @@ -93,6 +95,10 @@ UserInteraction The value sets the maximum number of message cookies of any type that will be created. The oldest message cookies will be purged once the limit has been reached. This effectively indicates how many tabs can be opened by a user when using IdentityServer. +* ``SupportedPromptModes`` + Sets the prompt modes that are supported by IdentityServer. + Defaults to *login*, *consent*, *select_account* and *none*. + When *CreateAccountUrl* is set, then *create* is also added to the supported prompt modes. Caching ^^^^^^^ From a941d3e672e350f171a79a2cf072d5875db879bc Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 12:08:28 +0200 Subject: [PATCH 17/25] Added unit tests for CreateAccountPageResult --- .../Results/CreateAccountPageResultTests.cs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/CreateAccountPageResultTests.cs diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/CreateAccountPageResultTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/CreateAccountPageResultTests.cs new file mode 100644 index 000000000..a4fb39c0f --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/CreateAccountPageResultTests.cs @@ -0,0 +1,97 @@ +using AwesomeAssertions; +using Moq; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Endpoints.Results; +using Open.IdentityServer.Models; +using Open.IdentityServer.Stores; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Endpoints.Results; + +public class CreateAccountPageResultTests : ReturnUrlResultTestBase +{ + protected override string ExpectedReturnUrlParameterName => Constants.UIConstants.DefaultRoutePathParams.CreateAccount; + protected override string ExpectedRedirectUrlPath => "/create-account"; + + protected override IdentityServerOptions CreateOptions() => new() + { + UserInteraction = new UserInteractionOptions + { + CreateAccountUrl = ExpectedRedirectUrlPath, + CreateAccountReturnUrlParameter = ExpectedReturnUrlParameterName + } + }; + + protected override CreateAccountPageResult CreateSut(IAuthorizationParametersMessageStore messageStore = null) + => new(TestAuthorizeRequest, Options, messageStore); + + [Fact] + public async Task ExecuteAsync_WithLocalCreateAccountUrl_ShouldUseRelativeReturnUrl() + { + Options.UserInteraction.CreateAccountUrl = "/account/create"; + var sut = CreateSut(messageStore: null); + + await sut.ExecuteAsync(Context); + + var urlDecoded = DecodeLocation(); + urlDecoded.Should().StartWith("https://server/account/create"); + urlDecoded.Should().NotContain($"{ExpectedReturnUrlParameterName}=https://server"); + } + + [Fact] + public async Task ExecuteAsync_WithExternalCreateAccountUrl_ShouldUseAbsoluteReturnUrl() + { + Options.UserInteraction.CreateAccountUrl = "https://external-login.com/account/create"; + var sut = CreateSut(messageStore: null); + + await sut.ExecuteAsync(Context); + + var location = RawLocation(); + location.Should().StartWith("https://external-login.com/account/create"); + location.Should().Contain("https%3A%2F%2Fserver"); + } + + [Fact] + public async Task ExecuteAsync_WithExternalCreateAccountUrlAndMessageStore_ShouldUseAbsoluteReturnUrlWithMessageId() + { + var expectedId = "ext_msg_id"; + Options.UserInteraction.CreateAccountUrl = "https://external-login.com/account/create"; + Mock.Get(MessageStore) + .Setup(x => x.WriteAsync(It.IsAny>>())) + .ReturnsAsync(expectedId); + + var sut = CreateSut(MessageStore); + + await sut.ExecuteAsync(Context); + + var location = RawLocation(); + location.Should().StartWith("https://external-login.com/account/create"); + location.Should().Contain("https%3A%2F%2Fserver"); + location.Should().Contain(expectedId); + } + + [Fact] + public async Task ExecuteAsync_ShouldUseConfiguredCreateAccountReturnUrlParameter() + { + Options.UserInteraction.CreateAccountReturnUrlParameter = "customReturnUrl"; + var sut = CreateSut(messageStore: null); + + await sut.ExecuteAsync(Context); + + var urlDecoded = DecodeLocation(); + urlDecoded.Should().Contain("customReturnUrl="); + urlDecoded.Should().NotContain($"{Constants.UIConstants.DefaultRoutePathParams.CreateAccount}="); + } + + [Fact] + public void Constructor_WithNullRequest_ShouldThrowArgumentNullException() + { + var act = () => new CreateAccountPageResult(null); + + act.Should().Throw() + .And.ParamName.Should().Be("request"); + } +} From a5aa746f1f8c89ac5daf760326dcbc420db3a773 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 12:12:18 +0200 Subject: [PATCH 18/25] Added cancellation token to remove warning. --- .../Endpoints/Authorize/AuthorizeTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs index 8217f541f..0d6c4b109 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs @@ -1297,7 +1297,7 @@ public async Task prompt_login_should_allow_user_to_login_and_return() await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); _mockPipeline.BrowserClient.AllowAutoRedirect = false; - var response = await _mockPipeline.BrowserClient.GetAsync(IdentityServerPipeline.BaseUrl + _mockPipeline.LoginReturnUrl); + var response = await _mockPipeline.BrowserClient.GetAsync(IdentityServerPipeline.BaseUrl + _mockPipeline.LoginReturnUrl, TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.Redirect); response.Headers.Location.ToString().Should().StartWith("https://client1/callback"); response.Headers.Location.ToString().Should().Contain("id_token="); @@ -1348,7 +1348,7 @@ public async Task max_age_0_should_allow_user_to_login_and_return() await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); _mockPipeline.BrowserClient.AllowAutoRedirect = false; - var response = await _mockPipeline.BrowserClient.GetAsync(IdentityServerPipeline.BaseUrl + _mockPipeline.LoginReturnUrl); + var response = await _mockPipeline.BrowserClient.GetAsync(IdentityServerPipeline.BaseUrl + _mockPipeline.LoginReturnUrl, TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.Redirect); response.Headers.Location.ToString().Should().StartWith("https://client1/callback"); response.Headers.Location.ToString().Should().Contain("id_token="); From 999d70083e2c31d59a9ddb7b0c6ac04ace721379 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 12:13:34 +0200 Subject: [PATCH 19/25] Added cancellation token to resolve warning. --- .../Endpoints/Authorize/JwtRequestAuthorizeTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/JwtRequestAuthorizeTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/JwtRequestAuthorizeTests.cs index 53dc0223e..9e0902d99 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/JwtRequestAuthorizeTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/JwtRequestAuthorizeTests.cs @@ -1205,7 +1205,7 @@ public async Task prompt_login_should_allow_user_to_login_and_return() var response = await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); _mockPipeline.BrowserClient.AllowAutoRedirect = false; - response = await _mockPipeline.BrowserClient.GetAsync(IdentityServerPipeline.BaseUrl + _mockPipeline.LoginReturnUrl); + response = await _mockPipeline.BrowserClient.GetAsync(IdentityServerPipeline.BaseUrl + _mockPipeline.LoginReturnUrl, TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.Redirect); response.Headers.Location.ToString().Should().StartWith("https://client/callback"); response.Headers.Location.ToString().Should().Contain("id_token="); From e8f05af951b14b5c7f007487687aaf3b26fc0000 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 12:18:33 +0200 Subject: [PATCH 20/25] Added unit test to ensure that AuthorizeEndpointBase handles IsCreateAccount. --- .../Endpoints/Authorize/AuthorizeEndpointBaseTests.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Authorize/AuthorizeEndpointBaseTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Authorize/AuthorizeEndpointBaseTests.cs index e8cced64a..d778bcb25 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Authorize/AuthorizeEndpointBaseTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Authorize/AuthorizeEndpointBaseTests.cs @@ -140,6 +140,17 @@ public async Task interaction_produces_login_result_should_trigger_login() result.Should().BeOfType(); } + [Fact] + [Trait("Category", Category)] + public async Task interaction_produces_create_result_should_trigger_create_account() + { + _stubInteractionGenerator.Response.IsCreateAccount = true; + + var result = await _subject.ProcessAuthorizeRequestAsync(_params, _user, null); + + result.Should().BeOfType(); + } + [Fact] [Trait("Category", Category)] public async Task ProcessAuthorizeRequestAsync_custom_interaction_redirect_result_should_issue_redirect() From 56af53edbc5e5553a145949e39dcc00fa7a81762 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 12:32:51 +0200 Subject: [PATCH 21/25] Added AuthorizeInteractionResponseGenerator tests for prompt=Create --- ...teractionResponseGeneratorTests_Consent.cs | 18 +++++ ...nteractionResponseGeneratorTests_Create.cs | 66 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/AuthorizeInteractionResponseGenerator/AuthorizeInteractionResponseGeneratorTests_Create.cs diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/AuthorizeInteractionResponseGenerator/AuthorizeInteractionResponseGeneratorTests_Consent.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/AuthorizeInteractionResponseGenerator/AuthorizeInteractionResponseGeneratorTests_Consent.cs index 0e7289378..f0356fb5f 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/AuthorizeInteractionResponseGenerator/AuthorizeInteractionResponseGeneratorTests_Consent.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/AuthorizeInteractionResponseGenerator/AuthorizeInteractionResponseGeneratorTests_Consent.cs @@ -172,6 +172,24 @@ public async Task ProcessConsentAsync_PromptModeIsSelectAccount_Throws() (await act.Should().ThrowAsync()).And.Message.Should().Contain("PromptMode"); } + [Fact] + public async Task ProcessConsentAsync_PromptModeIsCreate_Throws() + { + RequiresConsent(true); + var request = new ValidatedAuthorizeRequest + { + ResponseMode = OidcConstants.ResponseModes.Fragment, + State = "12345", + RedirectUri = "https://client.com/callback", + PromptModes = [OidcConstants.PromptModes.Create], + RequestedScopes = ["openid", "read", "write"], + ValidatedResources = GetValidatedResources("openid", "read", "write"), + }; + + Func act = () => _subject.ProcessConsentAsync(request); + + (await act.Should().ThrowAsync()).And.Message.Should().Contain("PromptMode"); + } [Fact] public async Task ProcessConsentAsync_RequiresConsentButPromptModeIsNone_ReturnsErrorResult() diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/AuthorizeInteractionResponseGenerator/AuthorizeInteractionResponseGeneratorTests_Create.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/AuthorizeInteractionResponseGenerator/AuthorizeInteractionResponseGeneratorTests_Create.cs new file mode 100644 index 000000000..0901a4177 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/AuthorizeInteractionResponseGenerator/AuthorizeInteractionResponseGeneratorTests_Create.cs @@ -0,0 +1,66 @@ +using AwesomeAssertions; +using Moq; +using Open.IdentityServer.Services; +using Open.IdentityServer.UnitTests.Common; +using Open.IdentityServer.Validation; +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Open.IdentityServer.UnitTests.ResponseHandling.AuthorizeInteractionResponseGenerator; + +public class AuthorizeInteractionResponseGeneratorTests_Create +{ + private readonly IdentityServer.ResponseHandling.AuthorizeInteractionResponseGenerator _subject; + private readonly MockConsentService _mockConsentService = new MockConsentService(); + private readonly StubClock _clock = new StubClock(); + private readonly Mock _telemetry = new Mock(); + + public AuthorizeInteractionResponseGeneratorTests_Create() + { + _subject = new IdentityServer.ResponseHandling.AuthorizeInteractionResponseGenerator( + _clock, + TestLogger.Create(), + _mockConsentService, + new MockProfileService(), + _telemetry.Object); + } + + [Fact] + public async Task ProcessCreateAsync_PromptModeIsCreate_ReturnsCreateAccountResult() + { + var request = new ValidatedAuthorizeRequest + { + ResponseMode = OidcConstants.ResponseModes.Fragment, + State = "12345", + RedirectUri = "https://client.com/callback", + PromptModes = [OidcConstants.PromptModes.Create] + }; + + var result = await _subject.ProcessCreateAsync(request); + result.IsCreateAccount.Should().BeTrue(); + } + + [Fact] + public async Task ProcessCreateAsync_PromptModeIsNotCreate_ReturnsEmptyResult() + { + var request = new ValidatedAuthorizeRequest + { + ResponseMode = OidcConstants.ResponseModes.Fragment, + State = "12345", + RedirectUri = "https://client.com/callback" + }; + + var result = await _subject.ProcessCreateAsync(request); + result.IsCreateAccount.Should().BeFalse(); + } + + [Fact] + public async Task ProcessCreateAsync_WithNullRequest_ShouldThrowArgumentNullException() + { + var act = () => _subject.ProcessCreateAsync(null); + + await act.Should().ThrowAsync(); + } + +} From d67d000dd9f690b256b2724d9097a126334ae25c Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 12:39:23 +0200 Subject: [PATCH 22/25] Added missing copyright. --- .../Endpoints/Results/CreateAccountPageResultTests.cs | 5 ++++- .../AuthorizeInteractionResponseGeneratorTests_Create.cs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/CreateAccountPageResultTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/CreateAccountPageResultTests.cs index a4fb39c0f..b9140302c 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/CreateAccountPageResultTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/CreateAccountPageResultTests.cs @@ -1,4 +1,7 @@ -using AwesomeAssertions; +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using AwesomeAssertions; using Moq; using Open.IdentityServer.Configuration; using Open.IdentityServer.Endpoints.Results; diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/AuthorizeInteractionResponseGenerator/AuthorizeInteractionResponseGeneratorTests_Create.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/AuthorizeInteractionResponseGenerator/AuthorizeInteractionResponseGeneratorTests_Create.cs index 0901a4177..c2b9f1a9b 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/AuthorizeInteractionResponseGenerator/AuthorizeInteractionResponseGeneratorTests_Create.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/AuthorizeInteractionResponseGenerator/AuthorizeInteractionResponseGeneratorTests_Create.cs @@ -1,4 +1,7 @@ -using AwesomeAssertions; +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using AwesomeAssertions; using Moq; using Open.IdentityServer.Services; using Open.IdentityServer.UnitTests.Common; From ff5c12c17de04ef2fdd800ce50896c5cdc9cd140 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 13:46:17 +0200 Subject: [PATCH 23/25] Added unit tests for IdentityServerApplicationBuilderExtensions --- .../Options/UserInteractionOptions.cs | 2 +- ...ServerApplicationBuilderExtensionsTests.cs | 267 ++++++++++++++++++ .../Open.IdentityServer.UnitTests.csproj | 16 +- 3 files changed, 276 insertions(+), 9 deletions(-) create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/IdentityServerApplicationBuilderExtensionsTests.cs diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/UserInteractionOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/UserInteractionOptions.cs index c76d66a3e..6ad54a929 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/UserInteractionOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/UserInteractionOptions.cs @@ -131,5 +131,5 @@ public class UserInteractionOptions /// /// The supported prompt modes. /// - public List SupportedPromptModes { get; set; } = Constants.SupportedPromptModes; + public List SupportedPromptModes { get; set; } = new(Constants.SupportedPromptModes); } \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/IdentityServerApplicationBuilderExtensionsTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/IdentityServerApplicationBuilderExtensionsTests.cs new file mode 100644 index 000000000..1c1966c29 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/IdentityServerApplicationBuilderExtensionsTests.cs @@ -0,0 +1,267 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using System; +using AwesomeAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Stores; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Configuration; + +public class IdentityServerApplicationBuilderExtensionsTests +{ + [Fact] + public void UseIdentityServer_WhenRequiredServicesAreRegistered_ShouldNotThrow() + { + var app = BuildAppBuilder(); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().NotThrow(); + } + + [Fact] + public void UseIdentityServer_WithLoggerFactoryMissing_ShouldThrowArgumentNullException() + { + var app = BuildAppBuilder(registerLoggerFactory: false); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithParameterName("loggerFactory"); + } + + [Fact] + public void UseIdentityServer_WithPersistedGrantStoreMissing_ShouldThrowInvalidOperationException() + { + var app = BuildAppBuilder(registerPersistedGrantStore: false); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("No storage mechanism for grants specified. Use the 'AddInMemoryPersistedGrants' extension method to register a development version."); + } + + [Fact] + public void UseIdentityServer_WithClientStoreMissing_ShouldThrowInvalidOperationException() + { + var app = BuildAppBuilder(registerClientStore: false); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("No storage mechanism for clients specified. Use the 'AddInMemoryClients' extension method to register a development version."); + } + + [Fact] + public void UseIdentityServer_WithResourceStoreMissing_ShouldThrowInvalidOperationException() + { + var app = BuildAppBuilder(registerResourceStore: false); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("No storage mechanism for resources specified. Use the 'AddInMemoryIdentityResources' or 'AddInMemoryApiResources' extension method to register a development version."); + } + + [Fact] + public void UseIdentityServer_WithLogoutIdParameterMissing_ShouldThrowInvalidOperationException() + { + var options = new IdentityServerOptions(); + options.UserInteraction.LogoutIdParameter = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("LogoutIdParameter is not configured"); + } + + [Fact] + public void UseIdentityServer_WithErrorUrlMissing_ShouldThrowInvalidOperationException() + { + var options = new IdentityServerOptions(); + options.UserInteraction.ErrorUrl = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("ErrorUrl is not configured"); + } + + [Fact] + public void UseIdentityServer_WithErrorIdParameterMissing_ShouldThrowInvalidOperationException() + { + var options = new IdentityServerOptions(); + options.UserInteraction.ErrorIdParameter = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("ErrorIdParameter is not configured"); + } + + [Fact] + public void UseIdentityServer_WithConsentUrlMissing_ShouldThrowInvalidOperationException() + { + var options = new IdentityServerOptions(); + options.UserInteraction.ConsentUrl = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("ConsentUrl is not configured"); + } + + [Fact] + public void UseIdentityServer_WithConsentReturnUrlParameterMissing_ShouldThrowInvalidOperationException() + { + var options = new IdentityServerOptions(); + options.UserInteraction.ConsentReturnUrlParameter = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("ConsentReturnUrlParameter is not configured"); + } + + [Fact] + public void UseIdentityServer_WithCustomRedirectReturnUrlParameterMissing_ShouldThrowInvalidOperationException() + { + var options = new IdentityServerOptions(); + options.UserInteraction.CustomRedirectReturnUrlParameter = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("CustomRedirectReturnUrlParameter is not configured"); + } + + [Fact] + public void UseIdentityServer_WithCheckSessionCookieNameMissing_ShouldThrowInvalidOperationException() + { + var options = new IdentityServerOptions(); + options.Authentication.CheckSessionCookieName = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("CheckSessionCookieName is not configured"); + } + + [Fact] + public void UseIdentityServer_WithCorsPolicyNameMissing_ShouldThrowInvalidOperationException() + { + var options = new IdentityServerOptions(); + options.Cors.CorsPolicyName = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("CorsPolicyName is not configured"); + } + + [Fact] + public void UseIdentityServer_WithCreateAccountUrlMissing_ShouldNotSupportPromptCreate() + { + var options = new IdentityServerOptions(); + options.UserInteraction.CreateAccountUrl = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + options.UserInteraction.SupportedPromptModes.Should().NotContain(OidcConstants.PromptModes.Create); + } + + [Fact] + public void UseIdentityServer_WithCreateAccountUrl_ShouldSupportPromptCreate() + { + var options = new IdentityServerOptions(); + options.UserInteraction.CreateAccountUrl = "/account/create"; + + var app = BuildAppBuilder(identityServerOptions: options); + + app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + options.UserInteraction.SupportedPromptModes.Should().Contain(OidcConstants.PromptModes.Create); + } + + [Fact] + public void UseIdentityServer_WithCreateAccountUrlButCreateAccountReturnUrlParameterMissing_ShouldThrowInvalidOperationException() + { + var options = new IdentityServerOptions(); + options.UserInteraction.CreateAccountUrl = "/account/create"; + options.UserInteraction.CreateAccountReturnUrlParameter = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("CreateAccountReturnUrlParameter is not configured"); + } + + private static IdentityServerMiddlewareOptions CreateNoOpMiddlewareOptions() + => new() + { + AuthenticationMiddleware = _ => { } + }; + + private static IApplicationBuilder BuildAppBuilder( + bool registerLoggerFactory = true, + bool registerPersistedGrantStore = true, + bool registerClientStore = true, + bool registerResourceStore = true, + IdentityServerOptions identityServerOptions = null) + { + var services = new ServiceCollection(); + + if (registerLoggerFactory) + { + services.AddSingleton(); + } + + services.AddAuthenticationCore(); + services.AddCors(); + + services.AddSingleton(identityServerOptions ?? new IdentityServerOptions()); + + if (registerPersistedGrantStore) + { + services.AddSingleton(Mock.Of()); + } + + if (registerClientStore) + { + services.AddSingleton(Mock.Of()); + } + + if (registerResourceStore) + { + services.AddSingleton(Mock.Of()); + } + + var serviceProvider = services.BuildServiceProvider(); + return new ApplicationBuilder(serviceProvider); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj index 46644c63d..50baae4c4 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj @@ -12,18 +12,18 @@ - + - + - - - - - + + + + + @@ -36,6 +36,6 @@ - + From c9eecf1e75b9af32d3eb30886b68d87e73ced8e4 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 13:53:56 +0200 Subject: [PATCH 24/25] Moved two validation tests from integration tests to unit tests. --- .../Endpoints/Authorize/AuthorizeTests.cs | 53 ------------------- .../Authorize_ProtocolValidation_Invalid.cs | 42 +++++++++++++++ 2 files changed, 42 insertions(+), 53 deletions(-) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs index 0d6c4b109..f1aae1a11 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs @@ -1168,59 +1168,6 @@ public async Task code_flow_with_fragment_response_type_should_be_allowed() _mockPipeline.LoginWasCalled.Should().BeTrue(); } - [Fact] - [Trait("Category", Category)] - public async Task unsupported_prompt_should_return_error() - { - var url = _mockPipeline.CreateAuthorizeUrl( - clientId: "client1", - responseType: "id_token", - scope: "openid profile", - redirectUri: "https://client1/callback", - state: "123_state", - nonce: "123_nonce", - extra: new Parameters - { - { "prompt", "unsupported" }, - } - ); - await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - - _mockPipeline.ErrorWasCalled.Should().BeTrue(); - } - - [Fact] - [Trait("Category", Category)] - public async Task prompt_create_and_login_should_return_error() - { - _mockPipeline.OnPreConfigureServices += services => - { - services.PostConfigure(options => - { - options.UserInteraction.SupportedPromptModes.Add(OidcConstants.PromptModes.Create); - }); - }; - _mockPipeline.Initialize(); - - await _mockPipeline.LoginAsync("bob"); - - var url = _mockPipeline.CreateAuthorizeUrl( - clientId: "client1", - responseType: "id_token", - scope: "openid profile", - redirectUri: "https://client1/callback", - state: "123_state", - nonce: "123_nonce", - extra: new Parameters - { - { "prompt", "create login" }, - } - ); - await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - - _mockPipeline.ErrorWasCalled.Should().BeTrue(); - } - [Fact] [Trait("Category", Category)] public async Task prompt_create_should_show_create_account_page() diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Invalid.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Invalid.cs index 9efe0f801..d7c2e8c9e 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Invalid.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Invalid.cs @@ -445,4 +445,46 @@ public async Task prompt_none_and_other_values_should_fail() result.IsError.Should().BeTrue(); result.Error.Should().Be(OidcConstants.AuthorizeErrors.InvalidRequest); } + + [Fact] + [Trait("Category", Category)] + public async Task prompt_create_and_other_values_should_fail() + { + var parameters = new NameValueCollection + { + { OidcConstants.AuthorizeRequest.ClientId, "codeclient" }, + { OidcConstants.AuthorizeRequest.Scope, "openid" }, + { OidcConstants.AuthorizeRequest.RedirectUri, "https://server/cb" }, + { OidcConstants.AuthorizeRequest.ResponseType, OidcConstants.ResponseTypes.Code }, + { OidcConstants.AuthorizeRequest.ResponseMode, OidcConstants.ResponseModes.Fragment }, + { OidcConstants.AuthorizeRequest.Prompt, "create login" } + }; + + var validator = Factory.CreateAuthorizeRequestValidator(); + var result = await validator.ValidateAsync(parameters); + + result.IsError.Should().BeTrue(); + result.Error.Should().Be(OidcConstants.AuthorizeErrors.InvalidRequest); + } + + [Fact] + [Trait("Category", Category)] + public async Task prompt_unsupported_values_should_fail() + { + var parameters = new NameValueCollection + { + { OidcConstants.AuthorizeRequest.ClientId, "codeclient" }, + { OidcConstants.AuthorizeRequest.Scope, "openid" }, + { OidcConstants.AuthorizeRequest.RedirectUri, "https://server/cb" }, + { OidcConstants.AuthorizeRequest.ResponseType, OidcConstants.ResponseTypes.Code }, + { OidcConstants.AuthorizeRequest.ResponseMode, OidcConstants.ResponseModes.Fragment }, + { OidcConstants.AuthorizeRequest.Prompt, "unsupported" } + }; + + var validator = Factory.CreateAuthorizeRequestValidator(); + var result = await validator.ValidateAsync(parameters); + + result.IsError.Should().BeTrue(); + result.Error.Should().Be(OidcConstants.AuthorizeErrors.InvalidRequest); + } } \ No newline at end of file From 0044a735145f785b46577855721a2226f37b95e1 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 14:00:45 +0200 Subject: [PATCH 25/25] Added unit tests for processed prompt/max_age. --- .../Authorize_ProtocolValidation_Invalid.cs | 1 + .../Authorize_ProtocolValidation_Valid.cs | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Invalid.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Invalid.cs index d7c2e8c9e..211d1257f 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Invalid.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Invalid.cs @@ -1,4 +1,5 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Valid.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Valid.cs index 373d100d6..6dc3a57c5 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Valid.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeRequest Validation/Authorize_ProtocolValidation_Valid.cs @@ -1,4 +1,5 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. @@ -204,4 +205,48 @@ public async Task multiple_prompt_values_should_be_accepted() result.ValidatedRequest.PromptModes.Should().Contain(OidcConstants.PromptModes.Login); result.ValidatedRequest.PromptModes.Should().Contain(OidcConstants.PromptModes.Consent); } + + [Fact] + [Trait("Category", Category)] + public async Task processed_prompt_values_should_not_be_processed_again() + { + var parameters = new NameValueCollection + { + { OidcConstants.AuthorizeRequest.ClientId, "codeclient" }, + { OidcConstants.AuthorizeRequest.Scope, "openid" }, + { OidcConstants.AuthorizeRequest.RedirectUri, "https://server/cb" }, + { OidcConstants.AuthorizeRequest.ResponseType, OidcConstants.ResponseTypes.Code }, + { OidcConstants.AuthorizeRequest.ResponseMode, OidcConstants.ResponseModes.Fragment }, + { OidcConstants.AuthorizeRequest.Prompt, "login" }, + { Constants.ProcessedParameters.PromptProcessed, "true" } + }; + + var validator = Factory.CreateAuthorizeRequestValidator(); + var result = await validator.ValidateAsync(parameters); + + result.IsError.Should().BeFalse(); + result.ValidatedRequest.PromptModes.Should().BeEmpty(); + } + + [Fact] + [Trait("Category", Category)] + public async Task processed_max_age_should_not_be_processed_again() + { + var parameters = new NameValueCollection + { + { OidcConstants.AuthorizeRequest.ClientId, "codeclient" }, + { OidcConstants.AuthorizeRequest.Scope, "openid" }, + { OidcConstants.AuthorizeRequest.RedirectUri, "https://server/cb" }, + { OidcConstants.AuthorizeRequest.ResponseType, OidcConstants.ResponseTypes.Code }, + { OidcConstants.AuthorizeRequest.ResponseMode, OidcConstants.ResponseModes.Fragment }, + { OidcConstants.AuthorizeRequest.MaxAge, "0" }, + { Constants.ProcessedParameters.MaxAgeProcessed, "true" } + }; + + var validator = Factory.CreateAuthorizeRequestValidator(); + var result = await validator.ValidateAsync(parameters); + + result.IsError.Should().BeFalse(); + result.ValidatedRequest.MaxAge.Should().BeNull(); + } } \ No newline at end of file