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..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. @@ -12,6 +13,7 @@ using System; using System.Reflection; using System.Threading.Tasks; +using Open.IdentityServer; namespace Microsoft.AspNetCore.Builder; @@ -132,6 +134,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..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:"; @@ -177,6 +180,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/AuthorizeCallbackEndpoint.cs b/src/Open.IdentityServer/src/Endpoints/AuthorizeCallbackEndpoint.cs index 8555cd39a..28998eea4 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.Add(Constants.PromptProcessed, "true"); + parameters.Add(Constants.MaxAgeProcessed, "true"); + 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/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/Extensions/StringsExtensions.cs b/src/Open.IdentityServer/src/Extensions/StringsExtensions.cs index 31f86870b..d12d60a53 100644 --- a/src/Open.IdentityServer/src/Extensions/StringsExtensions.cs +++ b/src/Open.IdentityServer/src/Extensions/StringsExtensions.cs @@ -7,10 +7,13 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; using System.Text.Encodings.Web; +#nullable enable + namespace Open.IdentityServer.Extensions; internal static class StringExtensions @@ -47,7 +50,7 @@ public static IEnumerable FromSpaceSeparatedString(this string input) return input.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries).ToList(); } - public static List ParseScopesString(this string scopes) + public static List? ParseScopesString(this string? scopes) { if (scopes.IsMissing()) { @@ -67,13 +70,13 @@ public static List ParseScopesString(this string scopes) } [DebuggerStepThrough] - public static bool IsMissing(this string value) + public static bool IsMissing([NotNullWhen(false)] this string? value) { return string.IsNullOrWhiteSpace(value); } [DebuggerStepThrough] - public static bool IsMissingOrTooLong(this string value, int maxLength) + public static bool IsMissingOrTooLong(this string? value, int maxLength) { if (string.IsNullOrWhiteSpace(value)) { @@ -89,13 +92,13 @@ public static bool IsMissingOrTooLong(this string value, int maxLength) } [DebuggerStepThrough] - public static bool IsPresent(this string value) + public static bool IsPresent([NotNullWhen(true)] this string? value) { return !string.IsNullOrWhiteSpace(value); } [DebuggerStepThrough] - public static string EnsureLeadingSlash(this string url) + public static string? EnsureLeadingSlash(this string? url) { if (url != null && !url.StartsWith("/")) { @@ -106,7 +109,7 @@ public static string EnsureLeadingSlash(this string url) } [DebuggerStepThrough] - public static string EnsureTrailingSlash(this string url) + public static string? EnsureTrailingSlash(this string? url) { if (url != null && !url.EndsWith("/")) { @@ -117,7 +120,7 @@ public static string EnsureTrailingSlash(this string url) } [DebuggerStepThrough] - public static string RemoveLeadingSlash(this string url) + public static string? RemoveLeadingSlash(this string? url) { if (url != null && url.StartsWith("/")) { @@ -128,7 +131,7 @@ public static string RemoveLeadingSlash(this string url) } [DebuggerStepThrough] - public static string RemoveTrailingSlash(this string url) + public static string? RemoveTrailingSlash(this string? url) { if (url != null && url.EndsWith("/")) { @@ -139,9 +142,9 @@ public static string RemoveTrailingSlash(this string url) } [DebuggerStepThrough] - public static string CleanUrlPath(this string url) + public static string CleanUrlPath(this string? url) { - if (String.IsNullOrWhiteSpace(url)) url = "/"; + if (string.IsNullOrWhiteSpace(url)) url = "/"; if (url != "/" && url.EndsWith("/")) { @@ -153,7 +156,7 @@ public static string CleanUrlPath(this string url) [DebuggerStepThrough] // Clone of UrlHelperBase.CheckIsLocalUrl from https://github.com/dotnet/aspnetcore/blob/3f1acb59718cadf111a0a796681e3d3509bb3381/src/Mvc/Mvc.Core/src/Routing/UrlHelperBase.cs - public static bool IsLocalUrl(this string url) + public static bool IsLocalUrl(this string? url) { if (string.IsNullOrEmpty(url)) { @@ -246,7 +249,7 @@ public static string AddHashFragment(this string url, string query) } [DebuggerStepThrough] - public static NameValueCollection ReadQueryStringAsNameValueCollection(this string url) + public static NameValueCollection ReadQueryStringAsNameValueCollection(this string? url) { if (url != null) { @@ -266,7 +269,7 @@ public static NameValueCollection ReadQueryStringAsNameValueCollection(this stri return new NameValueCollection(); } - public static string GetOrigin(this string url) + public static string? GetOrigin(this string? url) { if (url != null) { diff --git a/src/Open.IdentityServer/src/Extensions/ValidatedAuthorizeRequestExtensions.cs b/src/Open.IdentityServer/src/Extensions/ValidatedAuthorizeRequestExtensions.cs index 4d64fc3a5..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. @@ -18,16 +19,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); - } - /// /// 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..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 }; } @@ -135,16 +139,12 @@ protected internal virtual async Task ProcessLoginAsync(Val { 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 }; } // unauthenticated user var isAuthenticated = request.Subject.IsAuthenticated(); - + // user de-activated bool isActive = false; @@ -152,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; } @@ -206,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)) { @@ -231,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. /// @@ -294,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 b098abe5a..c778de650 100644 --- a/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs +++ b/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs @@ -727,20 +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 => Constants.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); + 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; + } + else + { + LogError("prompt contains unsupported values " + prompt, request); return Invalid(request, description: "Invalid prompt"); } - - request.PromptModes = prompts; - } - else - { - _logger.LogDebug("Unsupported prompt mode - ignored: " + prompt); } } @@ -779,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 { @@ -791,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"); - } } ////////////////////////////////////////////////////////// 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..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 @@ -36,6 +37,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,11 +185,16 @@ public void ConfigureApp(IApplicationBuilder app) { path.Run(ctx => OnError(ctx)); }); + app.Map(CreatePageRelative, path => + { + path.Run(ctx => OnCreate(ctx)); + }); OnPostConfigure(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 +209,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) @@ -277,6 +286,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 15320f0b0..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 @@ -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. @@ -18,6 +19,7 @@ using Open.IdentityServer.Test; using Microsoft.Extensions.DependencyInjection; using Xunit; +using Open.IdentityServer.Configuration; namespace IdentityServer.IntegrationTests.Endpoints.Authorize; @@ -1166,6 +1168,89 @@ 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() + { + _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)] @@ -1174,19 +1259,98 @@ 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 { - { "popup", "login" }, + { "prompt", "login" }, } ); await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); _mockPipeline.LoginWasCalled.Should().BeTrue(); + _mockPipeline.LoginRequest.PromptModes.Should().Contain("login"); + } + + [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() + { + 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.LoginWasCalled.Should().BeTrue(); + _mockPipeline.LoginRequest.Parameters.Get(OidcConstants.AuthorizeRequest.MaxAge).Should().Be("0"); + } + + [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 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 diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/InternalStringExtensions.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/InternalStringExtensions.cs index 06fc17f4d..c3fce0580 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/InternalStringExtensions.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/InternalStringExtensions.cs @@ -1,4 +1,5 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. #nullable enable @@ -11,13 +12,13 @@ namespace IdentityServer.IntegrationTests.Utility; internal static class InternalStringExtensions { [DebuggerStepThrough] - public static bool IsMissing(this string value) + public static bool IsMissing([NotNullWhen(false)] this string? value) { return string.IsNullOrWhiteSpace(value); } [DebuggerStepThrough] - public static bool IsPresent(this string value) + public static bool IsPresent([NotNullWhen(true)] this string? value) { return !(value.IsMissing()); } diff --git a/src/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 diff --git a/src/Storage/src/Extensions/StringsExtensions.cs b/src/Storage/src/Extensions/StringsExtensions.cs index 4aec2bf9b..8c46339be 100644 --- a/src/Storage/src/Extensions/StringsExtensions.cs +++ b/src/Storage/src/Extensions/StringsExtensions.cs @@ -1,21 +1,25 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +#nullable enable namespace Open.IdentityServer.Extensions; internal static class StringExtensions { [DebuggerStepThrough] - public static bool IsMissing(this string value) + public static bool IsMissing([NotNullWhen(false)] this string? value) { return string.IsNullOrWhiteSpace(value); } [DebuggerStepThrough] - public static bool IsPresent(this string value) + public static bool IsPresent([NotNullWhen(true)] this string? value) { return !string.IsNullOrWhiteSpace(value); }