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 ^^^^^^^ diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/UserInteractionOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/UserInteractionOptions.cs index 3d6ba568d..6ad54a929 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; } = new(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..974743a70 100644 --- a/src/Open.IdentityServer/src/Constants.cs +++ b/src/Open.IdentityServer/src/Constants.cs @@ -113,6 +113,12 @@ public static class SigningAlgorithms OidcConstants.PromptModes.SelectAccount }; + public class ProcessedParameters + { + 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 +183,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..413dc0d2e 100644 --- a/src/Open.IdentityServer/src/Endpoints/AuthorizeCallbackEndpoint.cs +++ b/src/Open.IdentityServer/src/Endpoints/AuthorizeCallbackEndpoint.cs @@ -76,6 +76,10 @@ public override async Task ProcessAsync(HttpContext context) try { + // 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); 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..0cc17b615 100644 --- a/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs +++ b/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs @@ -727,20 +727,34 @@ 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))) + // 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()) { - 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 +793,23 @@ private async Task ValidateOptionalParametersA var maxAge = request.Raw.Get(OidcConstants.AuthorizeRequest.MaxAge); if (maxAge.IsPresent()) { - if (int.TryParse(maxAge, out var seconds)) + // 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 (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 +817,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..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 @@ -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,36 @@ public async Task code_flow_with_fragment_response_type_should_be_allowed() _mockPipeline.LoginWasCalled.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 +1206,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 + { + { "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, 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="); + } + + [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 { - { "popup", "login" }, + { "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, 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="); } } \ 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..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 @@ -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, 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="); + } } \ 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/Configuration/IdentityServerApplicationBuilderExtensionsTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/IdentityServerApplicationBuilderExtensionsTests.cs new file mode 100644 index 000000000..1c1966c29 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/IdentityServerApplicationBuilderExtensionsTests.cs @@ -0,0 +1,267 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using System; +using AwesomeAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Stores; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Configuration; + +public class IdentityServerApplicationBuilderExtensionsTests +{ + [Fact] + public void UseIdentityServer_WhenRequiredServicesAreRegistered_ShouldNotThrow() + { + var app = BuildAppBuilder(); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().NotThrow(); + } + + [Fact] + public void UseIdentityServer_WithLoggerFactoryMissing_ShouldThrowArgumentNullException() + { + var app = BuildAppBuilder(registerLoggerFactory: false); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithParameterName("loggerFactory"); + } + + [Fact] + public void UseIdentityServer_WithPersistedGrantStoreMissing_ShouldThrowInvalidOperationException() + { + var app = BuildAppBuilder(registerPersistedGrantStore: false); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("No storage mechanism for grants specified. Use the 'AddInMemoryPersistedGrants' extension method to register a development version."); + } + + [Fact] + public void UseIdentityServer_WithClientStoreMissing_ShouldThrowInvalidOperationException() + { + var app = BuildAppBuilder(registerClientStore: false); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("No storage mechanism for clients specified. Use the 'AddInMemoryClients' extension method to register a development version."); + } + + [Fact] + public void UseIdentityServer_WithResourceStoreMissing_ShouldThrowInvalidOperationException() + { + var app = BuildAppBuilder(registerResourceStore: false); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("No storage mechanism for resources specified. Use the 'AddInMemoryIdentityResources' or 'AddInMemoryApiResources' extension method to register a development version."); + } + + [Fact] + public void UseIdentityServer_WithLogoutIdParameterMissing_ShouldThrowInvalidOperationException() + { + var options = new IdentityServerOptions(); + options.UserInteraction.LogoutIdParameter = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("LogoutIdParameter is not configured"); + } + + [Fact] + public void UseIdentityServer_WithErrorUrlMissing_ShouldThrowInvalidOperationException() + { + var options = new IdentityServerOptions(); + options.UserInteraction.ErrorUrl = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("ErrorUrl is not configured"); + } + + [Fact] + public void UseIdentityServer_WithErrorIdParameterMissing_ShouldThrowInvalidOperationException() + { + var options = new IdentityServerOptions(); + options.UserInteraction.ErrorIdParameter = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("ErrorIdParameter is not configured"); + } + + [Fact] + public void UseIdentityServer_WithConsentUrlMissing_ShouldThrowInvalidOperationException() + { + var options = new IdentityServerOptions(); + options.UserInteraction.ConsentUrl = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("ConsentUrl is not configured"); + } + + [Fact] + public void UseIdentityServer_WithConsentReturnUrlParameterMissing_ShouldThrowInvalidOperationException() + { + var options = new IdentityServerOptions(); + options.UserInteraction.ConsentReturnUrlParameter = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("ConsentReturnUrlParameter is not configured"); + } + + [Fact] + public void UseIdentityServer_WithCustomRedirectReturnUrlParameterMissing_ShouldThrowInvalidOperationException() + { + var options = new IdentityServerOptions(); + options.UserInteraction.CustomRedirectReturnUrlParameter = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("CustomRedirectReturnUrlParameter is not configured"); + } + + [Fact] + public void UseIdentityServer_WithCheckSessionCookieNameMissing_ShouldThrowInvalidOperationException() + { + var options = new IdentityServerOptions(); + options.Authentication.CheckSessionCookieName = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("CheckSessionCookieName is not configured"); + } + + [Fact] + public void UseIdentityServer_WithCorsPolicyNameMissing_ShouldThrowInvalidOperationException() + { + var options = new IdentityServerOptions(); + options.Cors.CorsPolicyName = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("CorsPolicyName is not configured"); + } + + [Fact] + public void UseIdentityServer_WithCreateAccountUrlMissing_ShouldNotSupportPromptCreate() + { + var options = new IdentityServerOptions(); + options.UserInteraction.CreateAccountUrl = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + options.UserInteraction.SupportedPromptModes.Should().NotContain(OidcConstants.PromptModes.Create); + } + + [Fact] + public void UseIdentityServer_WithCreateAccountUrl_ShouldSupportPromptCreate() + { + var options = new IdentityServerOptions(); + options.UserInteraction.CreateAccountUrl = "/account/create"; + + var app = BuildAppBuilder(identityServerOptions: options); + + app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + options.UserInteraction.SupportedPromptModes.Should().Contain(OidcConstants.PromptModes.Create); + } + + [Fact] + public void UseIdentityServer_WithCreateAccountUrlButCreateAccountReturnUrlParameterMissing_ShouldThrowInvalidOperationException() + { + var options = new IdentityServerOptions(); + options.UserInteraction.CreateAccountUrl = "/account/create"; + options.UserInteraction.CreateAccountReturnUrlParameter = null; + + var app = BuildAppBuilder(identityServerOptions: options); + + Action act = () => app.UseIdentityServer(CreateNoOpMiddlewareOptions()); + + act.Should().Throw() + .WithMessage("CreateAccountReturnUrlParameter is not configured"); + } + + private static IdentityServerMiddlewareOptions CreateNoOpMiddlewareOptions() + => new() + { + AuthenticationMiddleware = _ => { } + }; + + private static IApplicationBuilder BuildAppBuilder( + bool registerLoggerFactory = true, + bool registerPersistedGrantStore = true, + bool registerClientStore = true, + bool registerResourceStore = true, + IdentityServerOptions identityServerOptions = null) + { + var services = new ServiceCollection(); + + if (registerLoggerFactory) + { + services.AddSingleton(); + } + + services.AddAuthenticationCore(); + services.AddCors(); + + services.AddSingleton(identityServerOptions ?? new IdentityServerOptions()); + + if (registerPersistedGrantStore) + { + services.AddSingleton(Mock.Of()); + } + + if (registerClientStore) + { + services.AddSingleton(Mock.Of()); + } + + if (registerResourceStore) + { + services.AddSingleton(Mock.Of()); + } + + var serviceProvider = services.BuildServiceProvider(); + return new ApplicationBuilder(serviceProvider); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/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() 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..b9140302c --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/CreateAccountPageResultTests.cs @@ -0,0 +1,100 @@ +// 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; +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"); + } +} diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj index 46644c63d..50baae4c4 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj @@ -12,18 +12,18 @@ - + - + - - - - - + + + + + @@ -36,6 +36,6 @@ - + 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..c2b9f1a9b --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/AuthorizeInteractionResponseGenerator/AuthorizeInteractionResponseGeneratorTests_Create.cs @@ -0,0 +1,69 @@ +// 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; +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(); + } + +} 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/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..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. @@ -445,4 +446,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 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 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); }