From 7cadb10f0997702b27ac52569aba80a05438a677 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 13:13:55 +0200 Subject: [PATCH 01/28] 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 5233bb94f357ac63efc068bede89aeb298da5c2d Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 13:26:41 +0200 Subject: [PATCH 02/28] 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 38016a6ccdce6a6fdc47b8255d878dcd10d08d8c Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 13:32:28 +0200 Subject: [PATCH 03/28] 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 6e3eac5f05189ffe5648d7b4507115a695003f4b Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 13:36:50 +0200 Subject: [PATCH 04/28] 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 52caf18e189bb1ce4d1631b44932f2113823d047 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 13:43:50 +0200 Subject: [PATCH 05/28] 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 4ee3f600cd06e56c6b9846ee103abd463787b8fd Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 14:11:18 +0200 Subject: [PATCH 06/28] 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 4993d472ba07a7969c65975417da5bdd58131b7d Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 14:49:40 +0200 Subject: [PATCH 07/28] 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 21a2a545a627843991ccbe1c7ebb54d28bc296fb Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 14:53:30 +0200 Subject: [PATCH 08/28] 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 956ac6fad959d6666c9c8ec501b979bcd21bb0f8 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 14:57:56 +0200 Subject: [PATCH 09/28] 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 98db167aad57af3aea50b5c87124d71c10a940e9 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 15:15:34 +0200 Subject: [PATCH 10/28] 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 4b68a5f162ac6743f0322ead61a4367dc05c988d Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sat, 1 Aug 2026 15:29:31 +0200 Subject: [PATCH 11/28] 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 d81e02173c10e3bb274a7c7edbc45b189ee7e418 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Mon, 3 Aug 2026 10:38:06 +0200 Subject: [PATCH 12/28] 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 aaa928007cd4afb4d5a3fc7e34b4e769a0f96964 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Mon, 3 Aug 2026 10:52:32 +0200 Subject: [PATCH 13/28] 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 a10f6dafcc0ab2ce4449ac2f09052e20f7834283 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 11:51:06 +0200 Subject: [PATCH 14/28] 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 9b92b8c99e9530157c4ce457ea4a75452da70ef0 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 11:58:24 +0200 Subject: [PATCH 15/28] 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 371061fce6645b00ffd1c0bcc3c43223053196ab Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 12:08:28 +0200 Subject: [PATCH 16/28] 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 ae7257bdcc868ae04b2c2135d397b20cec3f205f Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 12:12:18 +0200 Subject: [PATCH 17/28] 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 9d3d9fde72765557c1776f7ac17628041fee8d57 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 12:13:34 +0200 Subject: [PATCH 18/28] 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 ba10a1eb533328af953ced5ce69db527b307edfe Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 12:18:33 +0200 Subject: [PATCH 19/28] 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 e133ee9fa910e1e3e6dd8c9d0d24aa430ddb631b Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 12:32:51 +0200 Subject: [PATCH 20/28] 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 35e23e40a1b1856b8bcae1fea07135244292cd31 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 12:39:23 +0200 Subject: [PATCH 21/28] 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 31260a7dc6117c1bac0a90b72b4ad2cc7c402c58 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 13:46:17 +0200 Subject: [PATCH 22/28] Added unit tests for IdentityServerApplicationBuilderExtensions --- .../Options/UserInteractionOptions.cs | 2 +- ...ServerApplicationBuilderExtensionsTests.cs | 267 ++++++++++++++++++ 2 files changed, 268 insertions(+), 1 deletion(-) 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 From 8d025e6a6c44cf6c5a38d101a83d8fa7495ad1fc Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 13:53:56 +0200 Subject: [PATCH 23/28] 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 599cef11b31e21cf591e72c5228b6c9e3b0c532b Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 4 Aug 2026 14:00:45 +0200 Subject: [PATCH 24/28] 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 From 6a71bc75f37e698e3163d8fa70f3de6907d59ccc Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Thu, 3 Sep 2026 15:15:34 +0200 Subject: [PATCH 25/28] fix: max_age is kept, except for when it 0 and we're in the callback --- .../Default/AuthorizeRequestValidator.cs | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs b/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs index 0cc17b615..08caae873 100644 --- a/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs +++ b/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs @@ -793,23 +793,19 @@ private async Task ValidateOptionalParametersA var maxAge = request.Raw.Get(OidcConstants.AuthorizeRequest.MaxAge); if (maxAge.IsPresent()) { - // 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()) + if (int.TryParse(maxAge, out var seconds)) { - if (int.TryParse(maxAge, out var seconds)) + if (seconds >= 0) { - if (seconds >= 0) - { + // if max_age have been processed, aka validation is called in callback, + // then we don't want to prompt the user again in the case where max_age = 0, + // so skip handling of the parameter + var maxAgeProcessed = request.Raw.Get(Constants.ProcessedParameters.MaxAgeProcessed); + + if (!(seconds == 0 && maxAgeProcessed.IsPresent())) + { request.MaxAge = seconds; } - else - { - LogError("Invalid max_age.", request); - return Invalid(request, description: "Invalid max_age"); - } } else { @@ -817,6 +813,11 @@ 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 44cee7bfbf288ce4fea308e7b60d8221e99c5173 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Thu, 3 Sep 2026 15:22:00 +0200 Subject: [PATCH 26/28] feat: Added ProcessedPromptModes property used in callback endpoint. --- .../Default/AuthorizeRequestValidator.cs | 50 ++++++++++--------- .../Models/ValidatedAuthorizeRequest.cs | 9 ++++ 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs b/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs index 08caae873..ff1bb99ec 100644 --- a/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs +++ b/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs @@ -727,35 +727,39 @@ private async Task ValidateOptionalParametersA var prompt = request.Raw.Get(OidcConstants.AuthorizeRequest.Prompt); if (prompt.IsPresent()) { - // 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()) + var prompts = prompt.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (prompts.All(p => _options.UserInteraction.SupportedPromptModes.Contains(p))) { - var prompts = prompt.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (prompts.All(p => _options.UserInteraction.SupportedPromptModes.Contains(p))) + if (prompts.Contains(OidcConstants.PromptModes.None) && prompts.Length > 1) { - 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) - { - LogError("prompt contains 'create' and other values. 'create' should be used by itself.", request); - return Invalid(request, description: "Invalid prompt"); - } - - request.PromptModes = prompts; + LogError("prompt contains 'none' and other values. 'none' should be used by itself.", request); + return Invalid(request, description: "Invalid prompt"); } - else + + if (prompts.Contains(OidcConstants.PromptModes.Create) && prompts.Length > 1) { - LogError("prompt contains unsupported values " + prompt, request); + LogError("prompt contains 'create' and other values. 'create' should be used by itself.", request); return Invalid(request, description: "Invalid prompt"); } } + else + { + LogError("prompt contains unsupported values " + prompt, request); + return Invalid(request, description: "Invalid prompt"); + } + + // 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()) + { + request.ProcessedPromptModes = prompts; + } + else + { + request.PromptModes = prompts; + } } ////////////////////////////////////////////////////////// @@ -803,7 +807,7 @@ private async Task ValidateOptionalParametersA var maxAgeProcessed = request.Raw.Get(Constants.ProcessedParameters.MaxAgeProcessed); if (!(seconds == 0 && maxAgeProcessed.IsPresent())) - { + { request.MaxAge = seconds; } } diff --git a/src/Open.IdentityServer/src/Validation/Models/ValidatedAuthorizeRequest.cs b/src/Open.IdentityServer/src/Validation/Models/ValidatedAuthorizeRequest.cs index 6f714f4da..9eeed3db9 100644 --- a/src/Open.IdentityServer/src/Validation/Models/ValidatedAuthorizeRequest.cs +++ b/src/Open.IdentityServer/src/Validation/Models/ValidatedAuthorizeRequest.cs @@ -141,6 +141,15 @@ public class ValidatedAuthorizeRequest : ValidatedRequest /// public IEnumerable PromptModes { get; set; } = Enumerable.Empty(); + /// + /// Gets or sets the collection of processed prompt modes. + /// + /// + /// A prompt is processed if it has been handled by the authorization endpoint and should not be processed again. + /// This is used to prevent infinite loops when the user is redirected back to the authorization endpoint after a prompt has been handled. + /// + public IEnumerable ProcessedPromptModes { get; set; } = Enumerable.Empty(); + /// /// Gets or sets the maximum age. /// From c7dca567be77834fba6563e4f8e877f5ec560690 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Thu, 3 Sep 2026 15:22:34 +0200 Subject: [PATCH 27/28] test: Added test for max_age > 0 and processed prompt modes. --- .../Authorize_ProtocolValidation_Valid.cs | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) 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 6dc3a57c5..7563b0c47 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 @@ -217,7 +217,7 @@ public async Task processed_prompt_values_should_not_be_processed_again() { OidcConstants.AuthorizeRequest.RedirectUri, "https://server/cb" }, { OidcConstants.AuthorizeRequest.ResponseType, OidcConstants.ResponseTypes.Code }, { OidcConstants.AuthorizeRequest.ResponseMode, OidcConstants.ResponseModes.Fragment }, - { OidcConstants.AuthorizeRequest.Prompt, "login" }, + { OidcConstants.AuthorizeRequest.Prompt, OidcConstants.PromptModes.Login }, { Constants.ProcessedParameters.PromptProcessed, "true" } }; @@ -226,11 +226,12 @@ public async Task processed_prompt_values_should_not_be_processed_again() result.IsError.Should().BeFalse(); result.ValidatedRequest.PromptModes.Should().BeEmpty(); + result.ValidatedRequest.ProcessedPromptModes.Should().Contain(OidcConstants.PromptModes.Login); } [Fact] [Trait("Category", Category)] - public async Task processed_max_age_should_not_be_processed_again() + public async Task processed_max_age_should_not_be_processed_again_when_zero() { var parameters = new NameValueCollection { @@ -249,4 +250,26 @@ public async Task processed_max_age_should_not_be_processed_again() result.IsError.Should().BeFalse(); result.ValidatedRequest.MaxAge.Should().BeNull(); } + + [Fact] + [Trait("Category", Category)] + public async Task processed_max_age_should_be_processed_again_when_not_zero() + { + 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, "10" }, + { Constants.ProcessedParameters.MaxAgeProcessed, "true" } + }; + + var validator = Factory.CreateAuthorizeRequestValidator(); + var result = await validator.ValidateAsync(parameters); + + result.IsError.Should().BeFalse(); + result.ValidatedRequest.MaxAge.Should().Be(10); + } } \ No newline at end of file From 224c97716b38f4b03ead21e0ef967cb3a0bced41 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Tue, 8 Sep 2026 12:29:07 +0200 Subject: [PATCH 28/28] docs: Updated docs about SupportedPromptModes. --- docs/reference/options.rst | 1 + .../DependencyInjection/Options/UserInteractionOptions.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/reference/options.rst b/docs/reference/options.rst index 6c4fcdcc4..6d1d3adaa 100644 --- a/docs/reference/options.rst +++ b/docs/reference/options.rst @@ -99,6 +99,7 @@ UserInteraction 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. + Prompts that are not in this list will cause authorization requests to fail with an invalid_request error. Caching ^^^^^^^ diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/UserInteractionOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/UserInteractionOptions.cs index 6ad54a929..07e4e5785 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/UserInteractionOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/UserInteractionOptions.cs @@ -126,7 +126,7 @@ public class UserInteractionOptions public string CreateAccountReturnUrlParameter { get; set; } = Constants.UIConstants.DefaultRoutePathParams.CreateAccount; /// - /// Gets or sets the supported prompt modes. + /// Gets or sets the supported prompt modes. Prompts that are not in this list will cause authorization requests to fail with an invalid_request error. /// /// /// The supported prompt modes.