From db4fb6fc370e57e46d9eb1dac8b19dc0d4e9b0fe Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 10:27:36 +0200 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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"); - } } //////////////////////////////////////////////////////////