diff --git a/src/AspNetIdentity/src/Open.IdentityServer.AspNetIdentity.csproj b/src/AspNetIdentity/src/Open.IdentityServer.AspNetIdentity.csproj index 8b1b7c519..51a946a61 100644 --- a/src/AspNetIdentity/src/Open.IdentityServer.AspNetIdentity.csproj +++ b/src/AspNetIdentity/src/Open.IdentityServer.AspNetIdentity.csproj @@ -42,7 +42,6 @@ - diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs index 7cd2d4007..b70da40a0 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs @@ -93,7 +93,7 @@ public static IIdentityServerBuilder AddDefaultEndpoints(this IIdentityServerBui builder.AddEndpoint(EndpointNames.Revocation, ProtocolRoutePaths.Revocation.EnsureLeadingSlash()); builder.AddEndpoint(EndpointNames.Token, ProtocolRoutePaths.Token.EnsureLeadingSlash()); builder.AddEndpoint(EndpointNames.UserInfo, ProtocolRoutePaths.UserInfo.EnsureLeadingSlash()); - + builder.AddEndpoint(EndpointNames.PushedAuthorizationRequest, ProtocolRoutePaths.PushedAuthorizationRequest.EnsureLeadingSlash()); return builder; } @@ -182,6 +182,7 @@ public static IIdentityServerBuilder AddPluggableServices(this IIdentityServerBu builder.Services.TryAddTransient(); builder.Services.TryAddTransient(); builder.Services.TryAddTransient(); + builder.Services.TryAddTransient(); builder.Services.TryAddSingleton(); @@ -218,11 +219,15 @@ public static IIdentityServerBuilder AddValidators(this IIdentityServerBuilder b builder.Services.TryAddTransient(); builder.Services.TryAddTransient(); builder.Services.TryAddTransient(); - + builder.Services.TryAddTransient(); // optional builder.Services.TryAddTransient(); builder.Services.TryAddTransient(); + // PAR support + builder.Services + .AddTransientDecorator(); + return builder; } @@ -241,7 +246,8 @@ public static IIdentityServerBuilder AddResponseGenerators(this IIdentityServerB builder.Services.TryAddTransient(); builder.Services.TryAddTransient(); builder.Services.TryAddTransient(); - + builder.Services.TryAddTransient(); + return builder; } diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/InMemory.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/InMemory.cs index c12840c4b..02f18b825 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/InMemory.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/InMemory.cs @@ -163,7 +163,7 @@ public static IIdentityServerBuilder AddInMemoryPersistedGrants(this IIdentitySe { builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); - + builder.Services.TryAddSingleton(); return builder; } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/EndpointOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/EndpointOptions.cs index 4d4cd4385..799828069 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/EndpointOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/EndpointOptions.cs @@ -85,4 +85,9 @@ public class EndpointsOptions /// true if the device authorization endpoint is enabled; otherwise, false. /// public bool EnableDeviceAuthorizationEndpoint { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the pushed authorization endpoint is enabled. + /// + public bool EnablePushedAuthorizationRequestEndpoint { get; set; } = true; } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs index 0df4a320f..df4cffa4a 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs @@ -141,4 +141,9 @@ public class IdentityServerOptions /// Gets or sets the enable authorise response issuer param option /// public bool EnableAuthorizeResponseIssuerParam { get; set; } = false; + + /// + /// PAR authorization options + /// + public PushedAuthorizationOptions PushedAuthorization {get;} = new PushedAuthorizationOptions(); } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/PushedAuthorizationOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/PushedAuthorizationOptions.cs new file mode 100644 index 000000000..9770d2379 --- /dev/null +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/PushedAuthorizationOptions.cs @@ -0,0 +1,21 @@ +// 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; + +namespace Open.IdentityServer.Configuration; + +/// +/// PAR options +/// +public class PushedAuthorizationOptions +{ + /// + /// Enforce PAR for all authorization requests + /// + public bool Required { get; set; } = false; + + /// + /// The lifetime of a PAR request_uri + /// + public TimeSpan Expiration { get; set; } = TimeSpan.FromSeconds(60); +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Constants.cs b/src/Open.IdentityServer/src/Constants.cs index 487ef5540..2b11ae9b2 100644 --- a/src/Open.IdentityServer/src/Constants.cs +++ b/src/Open.IdentityServer/src/Constants.cs @@ -205,6 +205,7 @@ public static class EndpointNames public const string EndSession = "Endsession"; public const string CheckSession = "Checksession"; public const string UserInfo = "Userinfo"; + public const string PushedAuthorizationRequest = "PushedAuthorizationRequest"; } public static class ProtocolRoutePaths @@ -212,6 +213,7 @@ public static class ProtocolRoutePaths public const string ConnectPathPrefix = "connect"; public const string Authorize = ConnectPathPrefix + "/authorize"; + public const string PushedAuthorizationRequest = ConnectPathPrefix + "/par"; public const string AuthorizeCallback = Authorize + "/callback"; public const string DiscoveryConfiguration = ".well-known/openid-configuration"; public const string DiscoveryWebKeys = DiscoveryConfiguration + "/jwks"; diff --git a/src/Open.IdentityServer/src/Endpoints/AuthorizeEndpointBase.cs b/src/Open.IdentityServer/src/Endpoints/AuthorizeEndpointBase.cs index 9e795e041..9e290d0b5 100644 --- a/src/Open.IdentityServer/src/Endpoints/AuthorizeEndpointBase.cs +++ b/src/Open.IdentityServer/src/Endpoints/AuthorizeEndpointBase.cs @@ -83,6 +83,11 @@ internal async Task ProcessAuthorizeRequestAsync(NameValueColle } var request = result.ValidatedRequest; + return await ProcessValidatedRequest(consent, request); + } + + private async Task ProcessValidatedRequest(ConsentResponse consent, ValidatedAuthorizeRequest request) + { LogRequest(request); // determine user interaction diff --git a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs new file mode 100644 index 000000000..6c8fadd68 --- /dev/null +++ b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs @@ -0,0 +1,117 @@ +// 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 System.Collections.Generic; +using System.Collections.Specialized; +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Endpoints.Results; +using Open.IdentityServer.Hosting; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.ResponseHandling; +using Open.IdentityServer.Services; +using Open.IdentityServer.Validation; + +#nullable enable +namespace Open.IdentityServer.Endpoints; + +internal class PushedAuthorizationRequestEndpoint( + IdentityServerOptions options, + IClientSecretValidator clientSecretValidator, + IPushedAuthorizationRequestValidator validator , + IPushedAuthorizationResponseGenerator responseGenerator, + ITelemetryService telemetry, + ILogger logger) : IEndpointHandler +{ + public async Task ProcessAsync(HttpContext requestContext) + { + using ITrace trace = telemetry.Trace(TelemetryConstants.TraceCategories.Basic, this); + + if (options.Endpoints.EnablePushedAuthorizationRequestEndpoint == false) + { + return new StatusCodeResult(HttpStatusCode.NotFound); + } + + logger.LogDebug("Start processing pushed authorization request"); + if (!HttpMethods.IsPost(requestContext.Request.Method)) + { + return Error(OidcConstants.TokenErrors.InvalidRequest); + } + + ClientSecretValidationResult? clientValidationResult = await clientSecretValidator.ValidateAsync(requestContext); + if (clientValidationResult.IsError) + { + return Error(OidcConstants.TokenErrors.InvalidClient); + } + + trace?.AddTag(TelemetryConstants.TagConstants.Client, clientValidationResult.Client.ClientId); + + NameValueCollection? parParameters = await ParseForm(requestContext.Request); + if (parParameters == null) + { + return Error(OidcConstants.TokenErrors.InvalidRequest); + } + var validationContext = new PushedAuthorizationRequestValidationContext(parParameters); + return await ProcessRequest(requestContext, validationContext); + } + + private async Task ProcessRequest( + HttpContext requestContext, + PushedAuthorizationRequestValidationContext validationContext) + { + PushAuthorizationRequestValidationResult result = await validator + .ValidateAsync(validationContext, requestContext.RequestAborted); + + telemetry.CountPushedAuthorizationRequest( + result.ValidatedAuthorizeRequest.ClientId , + result.IsError ? result.Error : null); + + if (result.IsError) + { + logger.LogError("Bad PAR request from {0}: {1}", + result.ValidatedAuthorizeRequest.ClientId, + result.Error); + + return new BadRequestResult(result.Error, result.ErrorDescription); + } + + PushedAuthorizationResponse response = await responseGenerator + .CreateResponseAsync(result.ValidatedAuthorizeRequest); + + logger.LogTrace("End processing pushed authorization request"); + return new PushedAuthorizationResult(response); + } + + private async Task ParseForm(HttpRequest request) + { + try + { + IFormCollection form = await request.ReadFormAsync(); + NameValueCollection parParameters = form.AsNameValueCollection(); + + return parParameters; + } + catch (InvalidOperationException ) + { + return null; + } + } + + private TokenErrorResult Error(string error, string? errorDescription = null, Dictionary? custom = null) + { + var response = new TokenErrorResponse + { + Error = error, + ErrorDescription = errorDescription, + Custom = custom + }; + + logger.LogError("PushedAuthorizationRequest error: {error}:{errorDescriptions}", error, error ?? "-no message-"); + + return new TokenErrorResult(response); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs b/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs new file mode 100644 index 000000000..aaeee2edf --- /dev/null +++ b/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs @@ -0,0 +1,22 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Hosting; +using Open.IdentityServer.ResponseHandling; + +namespace Open.IdentityServer.Endpoints.Results; + +internal record PushedAuthorizationResult(PushedAuthorizationResponse Response) : IEndpointResult +{ + public async Task ExecuteAsync(HttpContext context) + { + context.Response.StatusCode = StatusCodes.Status201Created; + context.Response.ContentType = "application/json"; + context.Response.SetNoCache(); + + await context.Response.WriteAsJsonAsync(Response); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/IdentityServerConstants.cs b/src/Open.IdentityServer/src/IdentityServerConstants.cs index 7d10fdfdc..3bbb6dd05 100644 --- a/src/Open.IdentityServer/src/IdentityServerConstants.cs +++ b/src/Open.IdentityServer/src/IdentityServerConstants.cs @@ -17,6 +17,14 @@ public static class IdentityServerConstants public const string DefaultCheckSessionCookieName = "idsrv.session"; public const string AccessTokenAudience = "{0}resources"; + public static class PushedAuthorizationRequest + { + /// + /// Standard prefix for the generated URI for a Pushed Authorization Request + /// + public static readonly string UriRequestPrefix = "urn:ietf:params:oauth:request_uri:"; + } + public const string JwtRequestClientKey = "idsrv.jwtrequesturi.client"; /// diff --git a/src/Open.IdentityServer/src/OidcConstants.cs b/src/Open.IdentityServer/src/OidcConstants.cs index b78b49e07..e368cafb2 100644 --- a/src/Open.IdentityServer/src/OidcConstants.cs +++ b/src/Open.IdentityServer/src/OidcConstants.cs @@ -933,6 +933,12 @@ public static class Discovery // DPoP /// JSON array of JWS signing algorithms supported by the OP for DPoP proofs. public const string DPoPSigningAlgorithmsSupported = "dpop_signing_alg_values_supported"; + + // PAR + /// The endpoint to use for PAR + public const string PushedAuthorizationRequestEndpoint = "pushed_authorization_request_endpoint"; + /// Used to indicate to a client that it MUST use PAR to perform authorization code flow + public const string RequirePushedAuthorizationRequests = "require_pushed_authorization_requests"; } /// diff --git a/src/Open.IdentityServer/src/Open.IdentityServer.csproj b/src/Open.IdentityServer/src/Open.IdentityServer.csproj index ef20d7378..3c4925cc0 100644 --- a/src/Open.IdentityServer/src/Open.IdentityServer.csproj +++ b/src/Open.IdentityServer/src/Open.IdentityServer.csproj @@ -48,5 +48,9 @@ + + + + \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Properties/AssemblyInfo.cs b/src/Open.IdentityServer/src/Properties/AssemblyInfo.cs index b19dbc41b..e94a33452 100644 --- a/src/Open.IdentityServer/src/Properties/AssemblyInfo.cs +++ b/src/Open.IdentityServer/src/Properties/AssemblyInfo.cs @@ -5,4 +5,6 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Open.IdentityServer.UnitTests, PublicKey = 002400000480000094000000060200000024000052534131000400000100010057b24455efc2a317afb0644a2169c05644e439985c42cf4eb98706779651801add1da073da8b5e253e8d4335d59b3197bb941ebe943c63f7efbc3005c428f0d69b809e86bdc828fa431fae4b71005f26b52a26a3ee5cf0f6fdf744d4534a7a503683123f58e1082828b018245d2e40d8542f72a623c01490d73a5d3ff94a88c5")] -[assembly: InternalsVisibleTo("Open.IdentityServer.IntegrationTests, PublicKey = 002400000480000094000000060200000024000052534131000400000100010057b24455efc2a317afb0644a2169c05644e439985c42cf4eb98706779651801add1da073da8b5e253e8d4335d59b3197bb941ebe943c63f7efbc3005c428f0d69b809e86bdc828fa431fae4b71005f26b52a26a3ee5cf0f6fdf744d4534a7a503683123f58e1082828b018245d2e40d8542f72a623c01490d73a5d3ff94a88c5")] \ No newline at end of file +[assembly: InternalsVisibleTo("Open.IdentityServer.IntegrationTests, PublicKey = 002400000480000094000000060200000024000052534131000400000100010057b24455efc2a317afb0644a2169c05644e439985c42cf4eb98706779651801add1da073da8b5e253e8d4335d59b3197bb941ebe943c63f7efbc3005c428f0d69b809e86bdc828fa431fae4b71005f26b52a26a3ee5cf0f6fdf744d4534a7a503683123f58e1082828b018245d2e40d8542f72a623c01490d73a5d3ff94a88c5")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo( + "DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] \ No newline at end of file diff --git a/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs index 7439cbfbc..72934bab7 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs @@ -208,9 +208,12 @@ string ConstructMtlsEndpoint(string endpoint) return $"https://{Options.MutualTls.DomainName}.{parts[1]}{endpoint}"; } } + + } + AddPushedAuthorizationRequestEndpoint(baseUrl, entries); } - + // logout if (Options.Endpoints.EnableEndSessionEndpoint) { @@ -369,10 +372,32 @@ where scope.ShowInDiscoveryDocument } } } + return entries; } + private void AddPushedAuthorizationRequestEndpoint(string baseUrl, Dictionary entries) + { + if (Options.Endpoints.EnablePushedAuthorizationRequestEndpoint == false) + { + return; + } + + string parPath = $"{baseUrl}{Constants.ProtocolRoutePaths.PushedAuthorizationRequest}"; + + entries.Add(OidcConstants.Discovery.PushedAuthorizationRequestEndpoint, parPath); + + // If PAR is enabled + if (Options.Endpoints.EnablePushedAuthorizationRequestEndpoint) + { + entries.Add( + OidcConstants.Discovery.RequirePushedAuthorizationRequests, + Options.PushedAuthorization.Required + ); + } + } + /// /// Creates the JWK document. /// diff --git a/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs new file mode 100644 index 000000000..88b432f3b --- /dev/null +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs @@ -0,0 +1,36 @@ +// 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 System.Threading.Tasks; +using Open.IdentityServer.Services; +using Open.IdentityServer.Validation; + +namespace Open.IdentityServer.ResponseHandling; + +#nullable enable + +/// +/// Default implementation of the pushed authorization response generator +/// +/// The service used to manage the storing of the pushed authorization request for later retrieval +public class PushedAuthorizationResponseGenerator(IPushedAuthorizationRequestService service) : IPushedAuthorizationResponseGenerator +{ + /// + /// Generates the Pushed Authorization Request response + /// + /// The request for which to generate a response + /// The generated response + public async Task CreateResponseAsync(ValidatedAuthorizeRequest request) + { + try + { + PushedAuthorization response = await service.CreateAsync(request.Client,request.Raw); + + return new PushedAuthorizationResponse(response.Key, (long)response.ExpiresIn.TotalSeconds); + } + catch (Exception) + { + return null; + } + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/ResponseHandling/IPushedAuthorizationResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/IPushedAuthorizationResponseGenerator.cs new file mode 100644 index 000000000..1540cb70c --- /dev/null +++ b/src/Open.IdentityServer/src/ResponseHandling/IPushedAuthorizationResponseGenerator.cs @@ -0,0 +1,19 @@ +// 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.Threading.Tasks; +using Open.IdentityServer.Validation; + +namespace Open.IdentityServer.ResponseHandling; + +/// +/// Used to create a PAR response +/// +public interface IPushedAuthorizationResponseGenerator +{ + /// + /// Creates a response to the pushed authorization request, generating the Unique URI for the request. + /// + /// The validated authorization request + /// A response that can be returned to the client + Task CreateResponseAsync(ValidatedAuthorizeRequest request); +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/ResponseHandling/Models/PushedAuthorizationResponse.cs b/src/Open.IdentityServer/src/ResponseHandling/Models/PushedAuthorizationResponse.cs new file mode 100644 index 000000000..6c91091be --- /dev/null +++ b/src/Open.IdentityServer/src/ResponseHandling/Models/PushedAuthorizationResponse.cs @@ -0,0 +1,26 @@ +// 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 System.Text.Json.Serialization; + +namespace Open.IdentityServer.ResponseHandling; + +/// +/// Represents the JSON object for a successful PAR result +/// +/// The URI that represents the PAR +/// The lifetime of the URI in seconds +public class PushedAuthorizationResponse(Uri uri , long lifetime) +{ + /// + /// The URN to send to the authorization endpoint to obtain the authcode, instead of parametes + /// + [JsonPropertyName(OidcConstants.AuthorizeRequest.RequestUri)] + public string Uri { get; } = uri.ToString(); + + /// + /// The lifetime in seconds of the URN + /// + [JsonPropertyName(OidcConstants.AuthorizeResponse.ExpiresIn)] + public long Lifetime { get; } = lifetime; +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultPushedAuthorizationRequestService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultPushedAuthorizationRequestService.cs new file mode 100644 index 000000000..3e9b0fa86 --- /dev/null +++ b/src/Open.IdentityServer/src/Services/Default/DefaultPushedAuthorizationRequestService.cs @@ -0,0 +1,95 @@ +// 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 System.Collections.Generic; +using System.Collections.Specialized; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Models; +using Open.IdentityServer.Storage.Models; +using Open.IdentityServer.Stores; + +#nullable enable +namespace Open.IdentityServer.Services.Default; + +internal class DefaultPushedAuthorizationRequestService( + TimeProvider clock, + IHandleGenerationService handleGeneration, + IdentityServerOptions options, + IPushedAuthorizationRequestStore store, + ILogger logger) : IPushedAuthorizationRequestService +{ + private static readonly List AuthenticationParameters = + ["client_secret", "client_assertion","client_assertion_type"]; + + public async Task CreateAsync(Client client , NameValueCollection parameters) + { + try + { + parameters = RemoveAnyAuthenticationParameters(parameters); + + string keyBody = await handleGeneration.GenerateAsync(); + string key = $"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}{keyBody}"; + + TimeSpan duration = options.PushedAuthorization.Expiration; + if (client.PushedAuthorizationLifetime != null) + { + duration = TimeSpan.FromSeconds(client.PushedAuthorizationLifetime.Value); + } + + await store.StorePushedAuthorizationRequestAsync( + new PushedAuthorizationMemento( + key.Sha256(), + clock.GetUtcNow().Add(duration), + parameters)); + + return new PushedAuthorization(new Uri(key), duration); + } + catch (PushedAuthorizationRequestStoreException e) + { + logger.LogError("Failed to store PAR request for client {clientId}:{exception}", client.ClientId, e.Message); + throw; + } + catch (Exception e) + { + logger.LogError("Failed to create PAR request for client {clientId}:{exception}",client.ClientId,e.Message); + throw; + } + + } + + private NameValueCollection RemoveAnyAuthenticationParameters(NameValueCollection src) + { + var dest = new NameValueCollection(src); + + AuthenticationParameters.ForEach(dest.Remove); + + return dest; + } + + public async Task ConsumeAsync(string key) + { + try + { + PushedAuthorizationMemento? memento = await store.ConsumePushedAuthorizationRequestAsync(key.Sha256()); + + if (memento?.ValidUntil < clock.GetUtcNow()) + { + return null; + } + + return memento?.Parameters; + } + catch (PushedAuthorizationRequestStoreException e) + { + logger.LogError("Failed to consume PAR request store error {key}:{exception}",key,e.Message); + throw; + } + catch (Exception e) + { + logger.LogError("Failed to consume PAR request {key}:{exception}",key,e.Message); + throw; + } + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/IPushAuthorizationedRequestService.cs b/src/Open.IdentityServer/src/Services/IPushAuthorizationedRequestService.cs new file mode 100644 index 000000000..2f271d68d --- /dev/null +++ b/src/Open.IdentityServer/src/Services/IPushAuthorizationedRequestService.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Specialized; +using System.Threading.Tasks; +using Open.IdentityServer.Models; +using Open.IdentityServer.ResponseHandling; + +namespace Open.IdentityServer.Services; +#nullable enable + +/// +/// Represents a PAR response +/// +/// The Identifier for the response +/// The time for which the response is valid +public record PushedAuthorization(Uri Key, TimeSpan ExpiresIn); + +/// +/// Manages the creation and storage of a PAR request, along with the ability +/// to obtain the original parameters, to perform an AuthCode flow +/// +public interface IPushedAuthorizationRequestService +{ + /// + /// Create a PAR response bound to the supplied parameters + /// + /// The client making the request + /// the parameters to store, and to be used for a subsequence AuthCode flow + /// An expiring response, used to obtain the parameters during an AuthCode flow + Task CreateAsync(Client client,NameValueCollection parameters); + + /// + /// Returns a NameValue collection associated with the key assuming it has not expired + /// + /// The Key returned as a part of a CreateResponse + /// The parameters associated with the key, or null if the response has expired or was never created + Task ConsumeAsync(string key); +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPushedAuthorizationRequestStore.cs b/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPushedAuthorizationRequestStore.cs new file mode 100644 index 000000000..7ef2b31d8 --- /dev/null +++ b/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPushedAuthorizationRequestStore.cs @@ -0,0 +1,50 @@ +// 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 System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading.Tasks; +using Open.IdentityServer.Storage.Models; + +namespace Open.IdentityServer.Stores; + +#nullable enable +/// +/// In Memory implementation of a PAR store +/// +public class InMemoryPushedAuthorizationRequestStore : IPushedAuthorizationRequestStore +{ + + private ConcurrentDictionary requestsMap = + new(); + + /// + /// Stores the PAR request in volatile storage, not to be used for load balancing + /// + /// The parameters to keep as part of the PAR request, later to be used in auth code flow + /// A task that completes when the value is stored, for in memory thats immediatly + public Task StorePushedAuthorizationRequestAsync(PushedAuthorizationMemento requestInformation) + { + if (requestsMap.TryAdd(requestInformation.Key, requestInformation) == false) + { + throw new InvalidOperationException("PAR request already exists"); + } + return Task.CompletedTask; + } + /// + /// Consumes a PAR request previously stored, and + /// + /// + /// Returns the stored parameters or null if they no longer exist or have expired + public Task ConsumePushedAuthorizationRequestAsync(string id) + { + if (requestsMap.TryRemove(id, out PushedAuthorizationMemento? request)) + { + return Task.FromResult(request); + } + + return Task.FromResult(null); + + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Validation/Contexts/PushedAuthorizationRequestValidationContext.cs b/src/Open.IdentityServer/src/Validation/Contexts/PushedAuthorizationRequestValidationContext.cs new file mode 100644 index 000000000..09fec61af --- /dev/null +++ b/src/Open.IdentityServer/src/Validation/Contexts/PushedAuthorizationRequestValidationContext.cs @@ -0,0 +1,11 @@ +// 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.Collections.Specialized; + +namespace Open.IdentityServer.Validation; + +/// +/// Encapsulates the context for the Pushed Authorization Request, used for validation +/// +public record PushedAuthorizationRequestValidationContext(NameValueCollection RequestParameters) { } diff --git a/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs b/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs index b098abe5a..ad8472007 100644 --- a/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs +++ b/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs @@ -2,7 +2,6 @@ // 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.Configuration; using Open.IdentityServer.Extensions; using Open.IdentityServer.Models; diff --git a/src/Open.IdentityServer/src/Validation/Default/AuthorizeUsingPushedAuthorizationRequestValidator.cs b/src/Open.IdentityServer/src/Validation/Default/AuthorizeUsingPushedAuthorizationRequestValidator.cs new file mode 100644 index 000000000..d4667dc54 --- /dev/null +++ b/src/Open.IdentityServer/src/Validation/Default/AuthorizeUsingPushedAuthorizationRequestValidator.cs @@ -0,0 +1,67 @@ +// 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.Collections.Specialized; +using System.Security.Claims; +using System.Threading.Tasks; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Configuration.DependencyInjection; +using Open.IdentityServer.Services; +using Open.IdentityServer.Storage.Models; +using Open.IdentityServer.Stores; + +#nullable enable +namespace Open.IdentityServer.Validation; + +internal class AuthorizeUsingPushedAuthorizationRequestValidator( + Decorator toDecorate, + IdentityServerOptions options, + IPushedAuthorizationRequestService parService) + : IAuthorizeRequestValidator +{ + public async Task ValidateAsync(NameValueCollection parameters, ClaimsPrincipal? subject = null) + { + string[]? requestUris = parameters.GetValues(OidcConstants.AuthorizeRequest.RequestUri); + + if (requestUris == null || + requestUris[0].StartsWith(IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix) == false) + { + return await ValidateNonParRequest(parameters, subject); + } + + if (requestUris.Length > 1) + { + return new AuthorizeRequestValidationResult(new ValidatedAuthorizeRequest(), "Too many request Uris", + "Only one request uri is allowed"); + } + + NameValueCollection? request = await parService.ConsumeAsync(requestUris[0]); + if (request == null) + { + return new AuthorizeRequestValidationResult(OidcConstants.AuthorizeErrors.InvalidRequest); + } + + if (request.Get(OidcConstants.AuthorizeRequest.ClientId) != + parameters.Get(OidcConstants.AuthorizeRequest.ClientId)) + { + return new AuthorizeRequestValidationResult(OidcConstants.AuthorizeErrors.InvalidRequest); + } + + AuthorizeRequestValidationResult result = await toDecorate.Instance.ValidateAsync(request, subject); + + return result; + } + + + private async Task ValidateNonParRequest(NameValueCollection parameters, ClaimsPrincipal? subject) + { + AuthorizeRequestValidationResult result = await toDecorate.Instance.ValidateAsync(parameters, subject); + if (result.ValidatedRequest?.Client?.RequirePushedAuthorization == true || options.PushedAuthorization.Required) + { + return new AuthorizeRequestValidationResult(result.ValidatedRequest, "PAR required", + "Client is configured for PAR only"); + } + + return result; + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Validation/Default/PushedAuthorizationRequestValidator.cs b/src/Open.IdentityServer/src/Validation/Default/PushedAuthorizationRequestValidator.cs new file mode 100644 index 000000000..8b3884fcf --- /dev/null +++ b/src/Open.IdentityServer/src/Validation/Default/PushedAuthorizationRequestValidator.cs @@ -0,0 +1,35 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Open.IdentityServer.Validation; + +internal class PushedAuthorizationRequestValidator( + IAuthorizeRequestValidator authorizeRequestValidator, + ILogger logger) : IPushedAuthorizationRequestValidator +{ + public async Task ValidateAsync(PushedAuthorizationRequestValidationContext validationContext, CancellationToken ct) + { + logger.LogDebug("Starting pushed authorization request validation"); + + if (validationContext.RequestParameters.GetValues(OidcConstants.AuthorizeRequest.RequestUri) != null) + { + return new PushAuthorizationRequestValidationResult( $"{OidcConstants.AuthorizeRequest.RequestUri} not allowed" , $"{OidcConstants.AuthorizeRequest.RequestUri} can only be used at the authorization endpoint"); + } + + AuthorizeRequestValidationResult authorizeRequestValidationResult = await authorizeRequestValidator.ValidateAsync(validationContext.RequestParameters, null); + + if (authorizeRequestValidationResult.IsError) + { + return new PushAuthorizationRequestValidationResult(authorizeRequestValidationResult.Error, + authorizeRequestValidationResult.ErrorDescription); + } + + logger.LogTrace("Pushed authorization request validation completed. Success."); + + return new PushAuthorizationRequestValidationResult(authorizeRequestValidationResult.ValidatedRequest); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Validation/IPushedAuthorizationRequestValidator.cs b/src/Open.IdentityServer/src/Validation/IPushedAuthorizationRequestValidator.cs new file mode 100644 index 000000000..5eb2b850d --- /dev/null +++ b/src/Open.IdentityServer/src/Validation/IPushedAuthorizationRequestValidator.cs @@ -0,0 +1,23 @@ +// 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.Threading; +using System.Threading.Tasks; + +namespace Open.IdentityServer.Validation; + +/// +/// Validates a Push Authorization Request +/// +public interface IPushedAuthorizationRequestValidator +{ + /// + /// + /// + /// Context encapsulating the authorization request + /// Cancellation token to cancel the validation + /// + Task ValidateAsync( + PushedAuthorizationRequestValidationContext validationContext, + CancellationToken ct); +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Validation/Models/AuthorizeRequestValidationResult.cs b/src/Open.IdentityServer/src/Validation/Models/AuthorizeRequestValidationResult.cs index e542871ed..2ca960765 100644 --- a/src/Open.IdentityServer/src/Validation/Models/AuthorizeRequestValidationResult.cs +++ b/src/Open.IdentityServer/src/Validation/Models/AuthorizeRequestValidationResult.cs @@ -33,6 +33,16 @@ public AuthorizeRequestValidationResult(ValidatedAuthorizeRequest request, strin ErrorDescription = errorDescription; } + /// + /// Create a result that only contains an error, and en empty validated authorize request + /// + /// + /// + public AuthorizeRequestValidationResult(string error, string errorDescription = null) : this( + new ValidatedAuthorizeRequest(), error, errorDescription) + { + } + /// /// Gets or sets the validated request. /// diff --git a/src/Open.IdentityServer/src/Validation/Models/PushAuthorizationRequestValidationResult.cs b/src/Open.IdentityServer/src/Validation/Models/PushAuthorizationRequestValidationResult.cs new file mode 100644 index 000000000..7e4123855 --- /dev/null +++ b/src/Open.IdentityServer/src/Validation/Models/PushAuthorizationRequestValidationResult.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +namespace Open.IdentityServer.Validation; +#nullable enable + +/// +/// +/// +public class PushAuthorizationRequestValidationResult : ValidationResult +{ + /// + /// Returns a validated authorization request, will be an empty object if validation failed + /// + public ValidatedAuthorizeRequest ValidatedAuthorizeRequest { get; } = new ValidatedAuthorizeRequest(); + + /// + /// Create a result representing a failed validation + /// + /// + /// + public PushAuthorizationRequestValidationResult(string error, string errorDescription) + { + IsError = true; + Error = error; + ErrorDescription = errorDescription; + } + + /// + /// Create a fully validated authorization request + /// + /// + public PushAuthorizationRequestValidationResult(ValidatedAuthorizeRequest validatedAuthorizeRequest) + { + IsError = false; + ValidatedAuthorizeRequest = validatedAuthorizeRequest; + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Validation/Models/ValidatedAuthorizeRequest.cs b/src/Open.IdentityServer/src/Validation/Models/ValidatedAuthorizeRequest.cs index 6f714f4da..d399650db 100644 --- a/src/Open.IdentityServer/src/Validation/Models/ValidatedAuthorizeRequest.cs +++ b/src/Open.IdentityServer/src/Validation/Models/ValidatedAuthorizeRequest.cs @@ -211,4 +211,5 @@ public ValidatedAuthorizeRequest() RequestedResourceIndicators = []; AuthenticationContextReferenceClasses = []; } + } \ No newline at end of file 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..86f795fac 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs @@ -49,10 +49,11 @@ public class IdentityServerPipeline public const string EndSessionEndpoint = BaseUrl + "/connect/endsession"; public const string EndSessionCallbackEndpoint = BaseUrl + "/connect/endsession/callback"; public const string CheckSessionEndpoint = BaseUrl + "/connect/checksession"; - + public const string PushedAuthorizatioRequestEndpoint = BaseUrl + "/connect/par"; + public const string FederatedSignOutPath = "/signout-oidc"; public const string FederatedSignOutUrl = BaseUrl + FederatedSignOutPath; - + public IdentityServerOptions? Options { get; set; } public List Clients { get; set; } = new List(); public List IdentityScopes { get; set; } = new List(); @@ -77,13 +78,22 @@ public class IdentityServerPipeline public Func>? OnFederatedSignout; public void Initialize(string? basePath = null, bool enableLogging = false) + { + Initialize(_ => { }, basePath, enableLogging); + } + + public void Initialize(Action configureServices , string? basePath = null, bool enableLogging = false) { var hostBuilder = new HostBuilder() .ConfigureWebHost(webBuilder => { webBuilder.UseTestServer(); - webBuilder.ConfigureServices(ConfigureServices); + webBuilder.ConfigureServices(sc => + { + configureServices(sc); + ConfigureServices(sc); + }); webBuilder.Configure(app => { if (basePath != null) @@ -329,7 +339,7 @@ public string CreateAuthorizeUrl( { var url = new RequestUrl(AuthorizeEndpoint).CreateAuthorizeUrl( clientId: clientId, - responseType: responseType, + responseType: responseType ?? "", scope: scope, redirectUri: redirectUri, state: state, @@ -383,6 +393,18 @@ public async Task RequestAuthorizationEndpointAsync( return new AuthorizeResponse(redirect); } + + public string? CreateParUrl(string clientId, string requestUri) + { + var url = new RequestUrl(AuthorizeEndpoint); + + var requestUriParam = new KeyValuePair(OidcConstants.AuthorizeRequest.RequestUri, requestUri); + var clientIdParam = new KeyValuePair(OidcConstants.AuthorizeRequest.ClientId, clientId); + + IEnumerable> parameters = [ clientIdParam,requestUriParam]; + + return url.Create(new Parameters(parameters)); + } } public class MockMessageHandler : DelegatingHandler diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/PushedAuthorization/PushBasedAuthorizationRequestTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/PushedAuthorization/PushBasedAuthorizationRequestTests.cs new file mode 100644 index 000000000..7da32d290 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/PushedAuthorization/PushBasedAuthorizationRequestTests.cs @@ -0,0 +1,221 @@ +// 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 System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using IdentityServer.IntegrationTests.Common; +using IdentityServer.IntegrationTests.Utility; +using Open.IdentityServer.Models; +using Open.IdentityServer.Test; +using Xunit; +using AwesomeAssertions; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Open.IdentityServer; +// using Open.IdentityServer.ResponseHandling; +using Open.IdentityServer.Validation; + +#nullable enable + +namespace IdentityServer.IntegrationTests.Endpoints.PushedAuthorization; + +public class PushBasedAuthorizationRequestTests +{ + private const string Category = "PAR endpoint"; + + private readonly IdentityServerPipeline mockPipeline = new IdentityServerPipeline(); + + private readonly Client parTestClient; + + public PushBasedAuthorizationRequestTests() + { + parTestClient = new Client + { + ClientId = "par Test Client", + ClientSecrets = [ new Secret("secret".Sha256())], + AllowedGrantTypes = GrantTypes.Code, + RequireClientSecret = true, + RequireConsent = false, + RequirePkce = false, + AllowedScopes = new List { "openid", "profile", "api1", "api2" }, + RedirectUris = new List { "https://app.com/callback" }, + }; + + mockPipeline.Clients.Add(parTestClient); + + mockPipeline.Users.Add(new TestUser + { + SubjectId = "bob", + Username = "bob", + Claims = + [ + new Claim("name", "Bob Loblaw"), + new Claim("email", "bob@loblaw.com"), + new Claim("role", "Attorney") + ] + }); + + mockPipeline.IdentityScopes.AddRange([ + new IdentityResources.OpenId(), + new IdentityResources.Profile(), + new IdentityResources.Email() + ]); + mockPipeline.ApiResources.AddRange([ + new ApiResource + { + Name = "api", + Scopes = { "api1", "api2" } + } + ]); + mockPipeline.ApiScopes.AddRange([ + new ApiScope + { + Name = "api1" + }, + new ApiScope + { + Name = "api2" + } + ]); + + mockPipeline.Initialize(sc => + { + // sc.TryAddTransient(); + }); + } + + [Fact] + [Trait("Category", Category)] + public async Task post_request_without_form_should_return_bad_request() + { + HttpClient? client = mockPipeline.BackChannelClient; + client.Should().NotBeNull(); + + HttpResponseMessage response = await client.PostAsync( + IdentityServerPipeline.PushedAuthorizatioRequestEndpoint, + new StringContent("foo"), + TestContext.Current?.CancellationToken ?? CancellationToken.None) ?? new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + [Trait("Category", Category)] + public async Task post_request_should_return_201() + { + HttpClient? client = mockPipeline.BackChannelClient; + client.Should().NotBeNull(); + + var response = await SendRequestForUri(client,"api1","api2"); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + // need to verify content-type is application/json + response.Content.Headers.ContentType?.MediaType.Should().Be("application/json"); + // need to verify the response body has a json property called request_uri + string jsonAsString = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + var json = System.Text.Json.JsonDocument.Parse(jsonAsString); + + json.RootElement.GetProperty("request_uri").GetString().Should().NotBeNullOrWhiteSpace(); + json.RootElement.GetProperty("expires_in").GetInt32().Should().BeGreaterThan(0); + } + + + + [Fact] + public async Task post_request_and_get_auth_code_should_return_redirect_with_code() + { + HttpClient? client = mockPipeline.BackChannelClient; + BrowserClient? browser = mockPipeline.BrowserClient; + + browser.Should().NotBeNull(); + client.Should().NotBeNull(); + IEnumerable requestedScopes = ["api1", "api2"]; + var response = await SendRequestForUri(client,requestedScopes); + + string jsonAsString = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + var json = System.Text.Json.JsonDocument.Parse(jsonAsString); + + string? requestUri = json.RootElement.GetProperty("request_uri").GetString(); + requestUri.Should().NotBeNull(); + + await mockPipeline.LoginAsync("bob"); + + browser.AllowAutoRedirect = false; + + var url = mockPipeline.CreateParUrl(parTestClient.ClientId, requestUri); + + var authCodeResponse = await browser.GetAsync(url, TestContext.Current.CancellationToken); + + string redirectLocation = authCodeResponse.Headers.Location!.ToString(); + + authCodeResponse.StatusCode.Should().Be(HttpStatusCode.Redirect); + authCodeResponse.Headers.Location.Should().NotBeNull(); + redirectLocation.Should().StartWith(parTestClient.RedirectUris.First()); + + var authorization = new AuthorizeResponse(authCodeResponse.Headers.Location.ToString()); + authorization.IsError.Should().BeFalse(); + authorization.State.Should().Be("1234567890"); + authorization.Code.Should().NotBeEmpty(); + + // Exchange the code for a token + + Uri redirectUri = new Uri(redirectLocation); + + var tokenRequestParameters = new Dictionary + { + { "grant_type", "authorization_code" }, + { "client_id", parTestClient.ClientId }, + { "client_secret","secret" }, + { OidcConstants.TokenRequest.RedirectUri , redirectUri.GetLeftPart(UriPartial.Path)}, + { "code", authorization.Code }, + }; + var tokenRequest = new FormUrlEncodedContent(tokenRequestParameters); + HttpResponseMessage tokenResponse = await client.PostAsync( + IdentityServerPipeline.TokenEndpoint, + tokenRequest, + TestContext.Current.CancellationToken); + + string tokenBody = await tokenResponse + .Content + .ReadAsStringAsync(CancellationToken.None); + + var tokenBodyAsJson = System.Text.Json.JsonDocument.Parse(tokenBody); + + string? token = tokenBodyAsJson.RootElement.GetProperty("access_token").GetString(); + + + var tokenParser = new JwtSecurityTokenHandler(); + var jwt = tokenParser.ReadJwtToken(token); // parse only, no signature validation + + var scopes = jwt.Claims.Where(c => c.Type == "scope") + .Select(c => c.Value).ToList(); + + scopes.Should().BeEquivalentTo(requestedScopes); + + return; + } + + private async Task SendRequestForUri(HttpClient client , params IEnumerable scopes) + { + HttpResponseMessage response = await client.PostAsync( + IdentityServerPipeline.PushedAuthorizatioRequestEndpoint, + new FormUrlEncodedContent( new Dictionary() + { + [OidcConstants.AuthorizeRequest.ClientId] = parTestClient.ClientId, + [OidcConstants.TokenRequest.ClientSecret] = "secret", + [OidcConstants.AuthorizeRequest.RedirectUri] = parTestClient.RedirectUris.First(), + [OidcConstants.AuthorizeRequest.ResponseType] = OidcConstants.ResponseTypes.Code, + [OidcConstants.AuthorizeRequest.Scope] = String.Join(" ",scopes), + [OidcConstants.AuthorizeRequest.State] = "1234567890", + }), + TestContext.Current.CancellationToken); + return response; + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs new file mode 100644 index 000000000..f08cd50fd --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs @@ -0,0 +1,351 @@ +// 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 System.Collections.Specialized; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Moq; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Endpoints; +using Open.IdentityServer.Endpoints.Results; +using Open.IdentityServer.Hosting; +using Open.IdentityServer.Models; +using Open.IdentityServer.ResponseHandling; +using Open.IdentityServer.Services; +using Open.IdentityServer.UnitTests.Common; +using Open.IdentityServer.Validation; +using Xunit; + +#nullable enable +namespace Open.IdentityServer.UnitTests.Endpoints.PushedAuthorization; + +public class PushedAuthorizationTests +{ + private readonly IdentityServerOptions options = new(); + private readonly Mock pushedAuthorizationRequestValidator = new(); + private readonly Mock pushedAuthorizationResponseGenerator = new(); + private readonly Mock clientSecretValidator = new(); + private readonly Mock> logger = new(); + private readonly Mock telemetry = new(); + private readonly MockHttpContextAccessor mockHttpContext = new(); + private readonly Mock trace = new(); + private readonly PushAuthorizationRequestValidationResult parErrorValidationResult = new ("error", "error_description"); + private readonly PushAuthorizationRequestValidationResult validatedAuthorizeRequest = new (new ValidatedAuthorizeRequest()); + + public PushedAuthorizationTests() + { + clientSecretValidator.Setup(csv => csv.ValidateAsync(It.IsAny())) + .ReturnsAsync(new ClientSecretValidationResult() + { + IsError = false, + Client = new Client() + }); + + telemetry.Setup(t => t.Trace(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(trace.Object); + } + + [Fact] + public async Task ProcessAsync_should_log_start_processing() + { + var sut = CreateSut(); + HttpContext context = CreateHttpContext(); + + var _ = await sut.ProcessAsync(context); + + logger.Verify(x => x.Log(LogLevel.Debug, It.IsAny(), It.Is((v, t) => v.ToString()!.Contains("Start processing pushed authorization request")), It.IsAny(), (Func)It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProcessAsync_should_log_end_processing() + { + var sut = CreateSut(); + HttpContext context = CreateHttpContext(); + + AddRequest(new NameValueCollection()); + StubValidateAsync(context, validatedAuthorizeRequest); + + var _ = await sut.ProcessAsync(context); + + logger.Verify(x => x.Log(LogLevel.Trace, It.IsAny(), It.Is((v, t) => v.ToString()!.Contains("End processing pushed authorization request")), It.IsAny(), (Func)It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProcessAsync_should_fail_if_client_validation_fails() + { + var sut = CreateSut(); + HttpContext context = CreateHttpContext(); + + AddRequest(new NameValueCollection()); + // Stub failed validation + clientSecretValidator.Setup(csv => csv.ValidateAsync(context)) + .ReturnsAsync(new ClientSecretValidationResult()); + + TokenErrorResult result = (TokenErrorResult)(await sut.ProcessAsync(context)); + + result.Response.Error.Should().Be(OidcConstants.TokenErrors.InvalidClient); + } + + + [Theory] + [InlineData("GET")] + [InlineData("PUT")] + [InlineData("DELETE")] + [InlineData("PATCH")] + public async Task ProcessAsync_should_not_support_the_following_http_verbs(string verb) + { + var sut = CreateSut(); + var context = CreateHttpContext(verb); + + IEndpointResult result = await sut.ProcessAsync(context); + ResultShouldBeTokenErrorResult(result, OidcConstants.TokenErrors.InvalidRequest); + + clientSecretValidator.Verify(csv => csv.ValidateAsync(context), Times.Never); + } + + [Fact] + public async Task ProcessAsync_should_support_http_verb_post() + { + var sut = CreateSut(); + var context = CreateHttpContext("POST"); + + IEndpointResult result = await sut.ProcessAsync(context); + clientSecretValidator.Verify(csv => csv.ValidateAsync(context), Times.Once); + } + + [Fact] + public async Task ProcessAsync_should_return_bad_request_when_no_form_body_in_request() + { + var sut = CreateSut(); + var context = CreateHttpContext(); + + IEndpointResult result = await sut.ProcessAsync(context); + ResultShouldBeTokenErrorResult(result, OidcConstants.TokenErrors.InvalidRequest); + } + + [Fact] + public async Task ProcessAsync_when_called_with_post_form_body_should_validate_request() + { + var sut = CreateSut(); + var context = CreateHttpContext(); + + NameValueCollection parameters = new NameValueCollection() + { + { "scope", "profile" } + }; + + StubValidateAsync(context, validatedAuthorizeRequest); + AddRequest(parameters); + + IEndpointResult result = await sut.ProcessAsync(context); + + pushedAuthorizationRequestValidator + .Verify(parv => parv.ValidateAsync( + It.Is(parvc => IsNameCollectionEquivalent(parvc.RequestParameters,parameters)), + context.RequestAborted), Times.Once); + } + + [Fact] + public async Task ProcessAsync_when_called_with_invalid_request_should_return_bad_request() + { + var sut = CreateSut(); + var context = CreateHttpContext(); + string expectedError = "Invalid scope"; + string expectedErrorDescription = "The requested scope is invalid, unknown, or malformed."; + + NameValueCollection parameters = new NameValueCollection(); + AddRequest(parameters); + + StubValidateAsync( + context, + new PushAuthorizationRequestValidationResult(expectedError, expectedErrorDescription)); + + IEndpointResult result = await sut.ProcessAsync(context); + + result.Should() + .BeOfType() + .And.BeEquivalentTo(new BadRequestResult(expectedError, expectedErrorDescription)); + } + + [Fact] + public async Task ProcessAsync_when_PAR_is_disabled_should_return_404() + { + options.Endpoints.EnablePushedAuthorizationRequestEndpoint = false; + + var sut = CreateSut(); + var context = CreateHttpContext(); + + IEndpointResult result = await sut.ProcessAsync(context); + + ResultShouldBeStatusCodeOf(result, HttpStatusCode.NotFound); + } + + + [Fact] + public async Task ProcessAsync_when_called_with_valid_request_should_generate_ok_response() + { + var sut = CreateSut(); + var context = CreateHttpContext(); + var requestValidatorResult = new PushAuthorizationRequestValidationResult(new ValidatedAuthorizeRequest()); + var expectedResult = new PushedAuthorizationResponse(new Uri("urn:foo"), 10); + + SetupRequestResponse(context, requestValidatorResult, expectedResult); + + PushedAuthorizationResult result = (PushedAuthorizationResult)await sut.ProcessAsync(context); + + result.Response.Should().Be(expectedResult); + } + + [Fact] + public async Task ProcessAsync_when_called_with_valid_request_should_increment_par_count_no_error() + { + string expectedClientId = "parClient"; + + var sut = CreateSut(); + var context = CreateHttpContext(); + var requestValidatorResult = CreatePushAuthorizationRequestValidationResult(expectedClientId); + var expectedResult = new PushedAuthorizationResponse(new Uri("urn:foo"), 10); + + SetupRequestResponse(context, requestValidatorResult, expectedResult); + + PushedAuthorizationResult result = (PushedAuthorizationResult)await sut.ProcessAsync(context); + + telemetry.Verify(t=>t.CountPushedAuthorizationRequest(expectedClientId),Times.Once); + } + + [Fact] + public async Task ProcessAsync_when_called_with_invalid_request_should_increment_par_count_with_error() + { + string expectedClientId = "parClient"; + string expectedError = "very bad request"; + + var sut = CreateSut(); + var context = CreateHttpContext(); + var requestValidatorResult = CreatePushAuthorizationRequestValidationResult(expectedClientId, expectedError); + + SetupRequestResponse(context, requestValidatorResult, null); + + var result = (BadRequestResult)await sut.ProcessAsync(context); + + telemetry.Verify(t=>t.CountPushedAuthorizationRequest(expectedClientId,expectedError),Times.Once); + } + + private static PushAuthorizationRequestValidationResult CreatePushAuthorizationRequestValidationResult(string clientId, string? error = null) + { + return new PushAuthorizationRequestValidationResult(new ValidatedAuthorizeRequest() + { + ClientId = clientId + }) + { + IsError = error != null, + Error = error + }; + } + + [Fact] + public async Task ProcessAsync_when_called_should_begin_telemetry() + { + var sut = CreateSut(); + + _ = await sut.ProcessAsync(CreateHttpContext()); + + telemetry.Verify(t => t.Trace( + TelemetryConstants.TraceCategories.Basic, + It.IsAny(),nameof(PushedAuthorizationRequestEndpoint.ProcessAsync)), + Times.Once); + + trace.Verify(t=>t.Dispose(),Times.Once); + } + + [Fact] + public async Task ProcessAsync_when_called_with_valid_client_id_should_add_trace_tag() + { + string expectedClientId = "parClient"; + var sut = CreateSut(); + HttpContext requestContext = CreateHttpContext(); + + clientSecretValidator.Setup(csv => csv.ValidateAsync(requestContext)).ReturnsAsync( + new ClientSecretValidationResult() + { + IsError = false, + Client = new Client() { ClientId = expectedClientId} + }); + _ = await sut.ProcessAsync(requestContext); + + trace.Verify(t=>t.AddTag(TelemetryConstants.TagConstants.Client,expectedClientId),Times.Once); + } + + private void SetupRequestResponse(HttpContext context, PushAuthorizationRequestValidationResult requestValidatorResult, + PushedAuthorizationResponse? expectedResult) + { + AddRequest(new NameValueCollection()); + StubValidateAsync(context,requestValidatorResult); + + pushedAuthorizationResponseGenerator + .Setup(parg => parg.CreateResponseAsync(requestValidatorResult.ValidatedAuthorizeRequest)) + .ReturnsAsync(expectedResult); + } + + private void StubValidateAsync(HttpContext context , PushAuthorizationRequestValidationResult result) + { + pushedAuthorizationRequestValidator + .Setup(parv => + parv.ValidateAsync(It.IsAny(), context.RequestAborted)) + .ReturnsAsync(result); + } + + private HttpContext CreateHttpContext(string verb = "POST") + { + var context = mockHttpContext.HttpContext!; + context.Request.Method = verb; + return context; + } + + private static bool IsNameCollectionEquivalent(NameValueCollection lhs, NameValueCollection rhs) + { + return lhs.Count == rhs.Count && + lhs.AllKeys.All(k => lhs[k] == rhs[k]); + } + + private void AddRequest(NameValueCollection formValues) + { + var formCollection = new FormCollection( + formValues.AllKeys.ToDictionary( + k => k!, + k => new Microsoft.Extensions.Primitives.StringValues(formValues[k]!) + ) + ); + + mockHttpContext.HttpContext!.Request.ContentType = "application/x-www-form-urlencoded"; + mockHttpContext.HttpContext!.Request.Method = "POST"; + mockHttpContext.HttpContext!.Request.Form = formCollection; + } + + private static void ResultShouldBeStatusCodeOf(IEndpointResult result , HttpStatusCode expectedStatusCode) + { + result.Should().BeOfType() + .Subject.StatusCode.Should().Be((int)expectedStatusCode); + } + + private static void ResultShouldBeTokenErrorResult(IEndpointResult result , string expectedError) + { + result.Should().BeOfType() + .Subject.Response.Error.Should().Be(expectedError); + } + + private PushedAuthorizationRequestEndpoint CreateSut() + { + return new PushedAuthorizationRequestEndpoint( + options, + clientSecretValidator.Object, + pushedAuthorizationRequestValidator.Object, + pushedAuthorizationResponseGenerator.Object, + telemetry.Object, + logger.Object); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/PushedAuthorizationResultTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/PushedAuthorizationResultTests.cs new file mode 100644 index 000000000..589e4fc05 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/PushedAuthorizationResultTests.cs @@ -0,0 +1,34 @@ +// 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 System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.AspNetCore.Http; +using Open.IdentityServer.Endpoints.Results; +using Open.IdentityServer.ResponseHandling; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Endpoints.Results; + +public class PushedAuthorizationResultTests +{ + private PushedAuthorizationResponse response = new(new Uri("urn:blah"), 60); + private DefaultHttpContext context = new DefaultHttpContext(); + + [Fact] + public async Task ExecuteAsync_when_called_response_header_should_contain_disable_caching_header() + { + var sut = CreateSut(); + + await sut.ExecuteAsync(context); + + context.Response.Headers.CacheControl.Single().Should().Be("no-store, no-cache, max-age=0"); + } + + PushedAuthorizationResult CreateSut() + { + return new PushedAuthorizationResult(response); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/Default/DiscoveryResponseGeneratorTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/Default/DiscoveryResponseGeneratorTests.cs index 3d0a57de3..5f4d8a3cd 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/Default/DiscoveryResponseGeneratorTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/Default/DiscoveryResponseGeneratorTests.cs @@ -331,6 +331,7 @@ public async Task CreateDiscoveryDocumentAsync_WhenShowEndpointsDisabled_ShouldN actual.Should().NotContainKey(OidcConstants.Discovery.AuthorizationEndpoint); actual.Should().NotContainKey(OidcConstants.Discovery.TokenEndpoint); actual.Should().NotContainKey(OidcConstants.Discovery.UserInfoEndpoint); + actual.Should().NotContainKey(OidcConstants.Discovery.PushedAuthorizationRequestEndpoint); } [Fact] @@ -756,4 +757,52 @@ public async Task CreateDiscoveryDocumentAsync_WhenMtlsEnabledAndShowAuthMethods .Which.Should().Contain(OidcConstants.EndpointAuthenticationMethods.TlsClientAuth) .And.Contain(OidcConstants.EndpointAuthenticationMethods.SelfSignedTlsClientAuth); } + + [Fact] + public async Task CreateDiscoveryDocumentAsync_WhenParEnabled_ShouldContainParEndpoint() + { + var sut = CreateSut(); + _options.Endpoints.EnablePushedAuthorizationRequestEndpoint = true; + + string expectedParEndpoint = $"https://open.ids.url/somepath/{Constants.ProtocolRoutePaths.PushedAuthorizationRequest}"; + + var actual = await sut.CreateDiscoveryDocumentAsync("https://open.ids.url/somepath/", "https://open.ids.url"); + + actual. + Should() + .ContainKey(OidcConstants.Discovery.PushedAuthorizationRequestEndpoint) + .WhoseValue.Should().Be(expectedParEndpoint); + } + + [Fact] + public async Task CreateDiscoveryDocumentAsync_WhenParDisabled_ShouldNotContainParEndpoint() + { + var sut = CreateSut(); + _options.Endpoints.EnablePushedAuthorizationRequestEndpoint = false; + + var actual = await sut.CreateDiscoveryDocumentAsync("https://open.ids.url/somepath", "https://open.ids.url"); + + actual. + Should() + .NotContainKey(OidcConstants.Discovery.PushedAuthorizationRequestEndpoint); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CreateDiscoveryDocumentAsync_WhenParIsEnforcedOrNot_ShouldContainEnforcementLevel(bool isEnforced) + { + var sut = CreateSut(); + _options.Endpoints.EnablePushedAuthorizationRequestEndpoint = true; + _options.PushedAuthorization.Required = isEnforced; + + var actual = await sut.CreateDiscoveryDocumentAsync("https://open.ids.url/somepath", "https://open.ids.url"); + + actual. + Should() + .ContainKey(OidcConstants.Discovery.RequirePushedAuthorizationRequests) + .WhoseValue.Should().Be(isEnforced); + } + + } \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs new file mode 100644 index 000000000..c5c23b3a2 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs @@ -0,0 +1,70 @@ +// 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 System.Collections.Specialized; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Models; +using Open.IdentityServer.ResponseHandling; +using Open.IdentityServer.Services; +using Open.IdentityServer.Storage.Models; +using Open.IdentityServer.Stores; +using Open.IdentityServer.Validation; +using Xunit; + +namespace Open.IdentityServer.UnitTests.ResponseHandling; +#nullable enable + +public class PushedAuthorizationResponseGeneratorTests +{ + private readonly Mock service = new(); + private Mock> _logger = new(); + + private ValidatedAuthorizeRequest _request = new ValidatedAuthorizeRequest() { Raw = new NameValueCollection() }; + + public PushedAuthorizationResponseGeneratorTests() + { + + } + + [Fact] + public async Task CreateResponseAsync_WhenCalled_ShouldMapRequestCorrectlyAndSendToStore() + { + Uri expectedKey = new Uri($"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}232444234"); + + _request.Client = new Client(); + + var sut = CreateSut(); + + PushedAuthorizationMemento? storedInfo = null; + + service.Setup(s => s.CreateAsync(_request.Client, _request.Raw)) + .ReturnsAsync(new PushedAuthorization(expectedKey, TimeSpan.FromSeconds(20))); + + var response = await sut.CreateResponseAsync(_request); + + response!.Lifetime.Should().Be(20); + response.Uri.Should().Be(expectedKey.ToString()); + } + + [Fact] + public async Task CreateResponseAsync_WhenCalledAndServiceThrowsException_ShouldReturnNull() + { + service.Setup(s => s.CreateAsync(It.IsAny(),It.IsAny())) + .ThrowsAsync(new Exception()); + + var sut = CreateSut(); + + PushedAuthorizationResponse? response = await sut.CreateResponseAsync(_request); + + response.Should().BeNull(); + } + + private PushedAuthorizationResponseGenerator CreateSut() + { + return new PushedAuthorizationResponseGenerator(service.Object); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultPushedAuthorizationRequestServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultPushedAuthorizationRequestServiceTests.cs new file mode 100644 index 000000000..f9aab8257 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultPushedAuthorizationRequestServiceTests.cs @@ -0,0 +1,200 @@ +using System; +using System.Linq; +using System.Collections.Specialized; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Services.Default; +using Open.IdentityServer.Storage.Models; +using Open.IdentityServer.Stores; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Services.Default; + +public class DefaultPushedAuthorizationRequestServiceTests +{ + private Mock clock = new(); + private IdentityServerOptions options = new(); + private Mock handleGeneration = new(); + private Mock store = new(); + private Mock> logger = new(); + + public DefaultPushedAuthorizationRequestServiceTests() + { + } + + [Fact] + public async Task CreateResponse_when_called_should_return_response() + { + NameValueCollection parameters = new(); + string expectedHandle = "someHandle"; + handleGeneration.Setup(hg => hg.GenerateAsync()).ReturnsAsync(expectedHandle); + + var sut = CreateSut(); + + var result = await sut.CreateAsync(new Client(),parameters); + + result.Key.Should() + .Be($"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}{expectedHandle}"); + result.ExpiresIn.Should().Be(options.PushedAuthorization.Expiration); + } + + [Fact] + public async Task CreateResponse_when_called_should_store_parameters_and_global_expiration() + { + Client client = new Client(); + DateTimeOffset now = new DateTimeOffset(2026, 6, 2, 15, 0, 0, TimeSpan.FromSeconds(0)); + DateTimeOffset expectedExpiration = now.Add(options.PushedAuthorization.Expiration); + NameValueCollection parameters = new() + { + ["scope"] = "api1", + ["scope"] = "api2", + ["client_id"] = "123445" + }; + + string expectedHandle = "someHandle"; + string expectedKey = $"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}{expectedHandle}" + .Sha256(); + + clock.Setup(c => c.GetUtcNow()).Returns(now); + + handleGeneration.Setup(hg => hg.GenerateAsync()).ReturnsAsync(expectedHandle); + + var sut = CreateSut(); + + var result = await sut.CreateAsync(client,parameters); + + store.Verify(s => s.StorePushedAuthorizationRequestAsync( + It.Is(pam => + pam.Key == expectedKey && pam.ValidUntil == expectedExpiration && + pam.Parameters.AllKeys.SequenceEqual(parameters.AllKeys) + )),Times.Once()); + + + result.ExpiresIn.Should().Be(options.PushedAuthorization.Expiration); + } + + [Fact] + public async Task CreateResponse_when_called_should_store_parameters_and_per_client_expiration() + { + Client client = new Client() { PushedAuthorizationLifetime = 70 }; + + DateTimeOffset now = new DateTimeOffset(2026, 6, 2, 15, 0, 0, TimeSpan.FromSeconds(0)); + DateTimeOffset expectedExpiration = now.Add( TimeSpan.FromSeconds(client.PushedAuthorizationLifetime.Value)); + NameValueCollection parameters = new(); + string expectedHandle = "someHandle"; + string expectedKey = $"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}{expectedHandle}"; + + expectedKey = expectedKey.Sha256(); + + clock.Setup(c => c.GetUtcNow()).Returns(now); + + handleGeneration.Setup(hg => hg.GenerateAsync()).ReturnsAsync(expectedHandle); + + var sut = CreateSut(); + + var result = await sut.CreateAsync(client,parameters); + + store.Verify(s => s.StorePushedAuthorizationRequestAsync( + It.Is(pam => + pam.Key == expectedKey && pam.ValidUntil == expectedExpiration && + pam.Parameters.AllKeys.SequenceEqual(parameters.AllKeys) + )),Times.Once()); + + result.ExpiresIn.Should().Be(TimeSpan.FromSeconds(client.PushedAuthorizationLifetime.Value)); + } + + [Fact] + public async Task CreateResponse_when_called_should_ensure_no_authentication_artifcats_are_stored() + { + Client client = new Client(); + string secretValue = "SECRET"; + + DateTimeOffset now = new DateTimeOffset(2026, 6, 2, 15, 0, 0, TimeSpan.FromSeconds(0)); + NameValueCollection parameters = new() + { + ["client_secret"] = secretValue, + ["client_assertion"] = secretValue, + ["client_assertion_type"] = secretValue + }; + + string expectedHandle = "someHandle"; + string expectedKey = $"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}{expectedHandle}"; + NameValueCollection spiedParameters = parameters; + + clock.Setup(c => c.GetUtcNow()).Returns(now); + + handleGeneration.Setup(hg => hg.GenerateAsync()).ReturnsAsync(expectedHandle); + store.Setup(s => s.StorePushedAuthorizationRequestAsync(It.IsAny())) + .Callback(pam => + { + spiedParameters = pam.Parameters; + }); + + var sut = CreateSut(); + + var result = await sut.CreateAsync(client,parameters); + + spiedParameters.AllKeys.All(k => spiedParameters[k] != secretValue).Should().BeTrue(); + } + + [Fact] + public async Task ConsumeResponse_when_called_with_non_expired_key_should_return_parameters() + { + DateTimeOffset now = new DateTimeOffset(2026, 6, 2, 15, 0, 0, TimeSpan.FromSeconds(0)); + DateTimeOffset expectedExpiration = now.Add(options.PushedAuthorization.Expiration); + + clock.Setup(c => c.GetUtcNow()).Returns(now); + + NameValueCollection parameters = new(); + string expectedHandle = "someHandle"; + string key = $"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}{expectedHandle}"; + string expectedKey = key.Sha256(); + + store.Setup(s => s.ConsumePushedAuthorizationRequestAsync(expectedKey)) + .ReturnsAsync(new PushedAuthorizationMemento(expectedKey,expectedExpiration,parameters)); + + var sut = CreateSut(); + + var result = await sut.ConsumeAsync(key); + + result.Should().Be(parameters); + } + + [Fact] + public async Task ConsumeResponse_when_called_with_an_expired_key_should_return_null() + { + DateTimeOffset issuedAt = new DateTimeOffset(2026, 6, 2, 15, 0, 0, TimeSpan.FromSeconds(0)); + DateTimeOffset expectedExpiration = issuedAt.Add(options.PushedAuthorization.Expiration); + + clock.Setup(c => c.GetUtcNow()) + .Returns(issuedAt.Add(options.PushedAuthorization.Expiration).AddSeconds(1)); + + NameValueCollection parameters = new(); + string expectedHandle = "someHandle"; + string expectedKey = $"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}{expectedHandle}"; + + store.Setup(s => s.ConsumePushedAuthorizationRequestAsync(expectedKey)) + .ReturnsAsync(new PushedAuthorizationMemento(expectedKey,expectedExpiration,parameters)); + + var sut = CreateSut(); + + var result = await sut.ConsumeAsync(expectedKey); + + result.Should().BeNull(); + } + + private DefaultPushedAuthorizationRequestService CreateSut() + { + return new DefaultPushedAuthorizationRequestService( + clock.Object, + handleGeneration.Object, + options, + store.Object, + logger.Object); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeUsingPushedAuthorizationRequestValidatorTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeUsingPushedAuthorizationRequestValidatorTests.cs new file mode 100644 index 000000000..ea3bfb059 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeUsingPushedAuthorizationRequestValidatorTests.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using Open.IdentityServer; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Configuration.DependencyInjection; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Storage.Models; +using Open.IdentityServer.Stores; +using Open.IdentityServer.Validation; +using Xunit; + +#nullable enable +namespace IdentityServer.UnitTests.Validation; + +public class AuthorizeUsingPushedAuthorizationRequestValidatorTests +{ + private readonly Mock authorizeRequestValidator = new(); + private readonly Mock> logger = new(); + private readonly Mock parService = new(); + private readonly IdentityServerOptions options = new IdentityServerOptions(); + + public AuthorizeUsingPushedAuthorizationRequestValidatorTests() + { + + } + + [Fact] + public async Task ValidateAsync_when_called_with_no_request_uri_should_forward_to_decorated_validator() + { + var expectedNameValueCollection = new NameValueCollection(); + SetupAuthorizeRequestValidationResult(expectedNameValueCollection, + new ValidatedAuthorizeRequest()); + + var sut = CreateSut(); + + AuthorizeRequestValidationResult result = await sut.ValidateAsync(expectedNameValueCollection); + + authorizeRequestValidator.Verify(arv => arv.ValidateAsync(expectedNameValueCollection),Times.Once); + } + + [Fact] + public async Task ValidateAsync_when_called_with_no_request_uri_and_client_requires_par_should_error() + { + var expectedNameValueCollection = new NameValueCollection(); + + SetupAuthorizeRequestValidationResult( expectedNameValueCollection , + new ValidatedAuthorizeRequest() + { + Client = new Client() { RequirePushedAuthorization = true } + }); + + var sut = CreateSut(); + + AuthorizeRequestValidationResult result = await sut.ValidateAsync(expectedNameValueCollection); + + result.IsError.Should().BeTrue(); + } + + [Fact] + public async Task ValidateAsync_when_called_with_no_request_uri_and_options_dictates_requires_par_should_error() + { + var expectedNameValueCollection = new NameValueCollection(); + + options.PushedAuthorization.Required = true; + + SetupAuthorizeRequestValidationResult( expectedNameValueCollection , + new ValidatedAuthorizeRequest() + { + Client = new Client() + }); + + var sut = CreateSut(); + + AuthorizeRequestValidationResult result = await sut.ValidateAsync(expectedNameValueCollection); + + result.IsError.Should().BeTrue(); + } + + + [Fact] + public async Task ValidateAsync_when_called_with_many_request_uris_should_error() + { + var expectedNameValueCollection = new NameValueCollection(); + expectedNameValueCollection.Add(OidcConstants.AuthorizeRequest.RequestUri,$"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}one"); + expectedNameValueCollection.Add(OidcConstants.AuthorizeRequest.RequestUri,$"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}two"); + + var sut = CreateSut(); + + AuthorizeRequestValidationResult result = await sut.ValidateAsync(expectedNameValueCollection); + + result.IsError.Should().BeTrue(); + } + + [Fact] + public async Task ValidateAsync_when_called_with_a_request_uri_with_non_par_prefix_should_pass_request_on() + { + var parameters = new NameValueCollection(); + var expectedValidationResult = new ValidatedAuthorizeRequest(); + + string nonParRequestUri="https://jwt.io/blah"; + + parameters.Add(OidcConstants.AuthorizeRequest.RequestUri,nonParRequestUri); + SetupAuthorizeRequestValidationResult(parameters,expectedValidationResult ); + + var sut = CreateSut(); + + AuthorizeRequestValidationResult result = await sut.ValidateAsync(parameters); + + result.ValidatedRequest.Should().Be(expectedValidationResult); + } + + [Fact] + public async Task ValidateAsync_when_called_with_an_unknown_request_uri_should_return_error() + { + var parameters = new NameValueCollection(); + + string unknownRequestUri = IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix + "blah"; + parService.Setup(s=>s.ConsumeAsync(unknownRequestUri)) + .ReturnsAsync((NameValueCollection?)null); + + parameters.Add(OidcConstants.AuthorizeRequest.RequestUri,unknownRequestUri); + + var sut = CreateSut(); + + AuthorizeRequestValidationResult result = await sut.ValidateAsync(parameters); + + result.IsError.Should().BeTrue(); + result.Error.Should().Be(OidcConstants.AuthorizeErrors.InvalidRequest); + } + + [Fact] + public async Task ValidateAsync_when_called_with_a_different_client_id_than_associted_with_the_request_uri_should_return_error() + { + string requestUri = IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix + "123"; + + var parameters = new NameValueCollection + { + { "client_id", "clientOne" }, + { OidcConstants.AuthorizeRequest.RequestUri,requestUri} + }; + var request = new NameValueCollection() { {"client_id","different" }}; + + parService.Setup(s=>s.ConsumeAsync(requestUri)) + .ReturnsAsync(request); + + var sut = CreateSut(); + + AuthorizeRequestValidationResult result = await sut.ValidateAsync(parameters); + + result.IsError.Should().BeTrue(); + result.Error.Should().Be(OidcConstants.AuthorizeErrors.InvalidRequest); + } + + [Fact] + public async Task + ValidateAsync_when_called_with_valid_request_uri_should_map_stored_info_to_validated_authorize_request() + { + // Arrange + var requestUri = IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix + "mapped-request"; + var parameters = new NameValueCollection + { + { OidcConstants.AuthorizeRequest.RequestUri, requestUri } + }; + + var stored = new NameValueCollection(); + + parService.Setup(s => s.ConsumeAsync(requestUri)) + .ReturnsAsync(stored); + + SetupAuthorizeRequestValidationResult(stored, new ValidatedAuthorizeRequest()); + + var sut = CreateSut(); + + var result = await sut.ValidateAsync(parameters); + + result.IsError.Should().BeFalse(); + result.ValidatedRequest.Should().NotBeNull(); + } + + + private void SetupAuthorizeRequestValidationResult( + NameValueCollection expectedNameValueCollection, + ValidatedAuthorizeRequest validatedAuthorizeRequest) + { + authorizeRequestValidator.Setup(arv => arv.ValidateAsync(expectedNameValueCollection)) + .ReturnsAsync(new AuthorizeRequestValidationResult(validatedAuthorizeRequest)); + } + + private AuthorizeUsingPushedAuthorizationRequestValidator CreateSut() + { + var decorator = new Decorator(authorizeRequestValidator.Object); + + return new AuthorizeUsingPushedAuthorizationRequestValidator( + decorator, + options, + parService.Object); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/PushedAuthorizationRequestValidatorTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/PushedAuthorizationRequestValidatorTests.cs new file mode 100644 index 000000000..a2c341089 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/PushedAuthorizationRequestValidatorTests.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Specialized; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using Open.IdentityServer.Validation; +using Xunit; + +#nullable enable +namespace IdentityServer.UnitTests.Validation; + +public class PushedAuthorizationRequestValidatorTests +{ + private readonly Mock authorizeRequestValidator = new(); + private readonly Mock> logger = new(); + private readonly PushedAuthorizationRequestValidationContext + validPushAuthorizationRequestValidationContext = new(new NameValueCollection()); + + [Fact] + public async Task ValidateAsync_when_called_with_request_that_contains_request_uri_should_respond_with_error() + { + var sut = CreateSut(); + + var parameters =new NameValueCollection() { { "request_uri", "urn:somethingRandom" } }; + + PushAuthorizationRequestValidationResult result = await sut.ValidateAsync(new PushedAuthorizationRequestValidationContext(parameters),CancellationToken.None); + + result.IsError.Should().BeTrue(); + } + + [Fact] + public async Task ValidateAsync_when_called_with_request_should_call_authorize_endpoint_validator() + { + var sut = CreateSut(); + var parameters = new NameValueCollection(); + + StubAuthorizeRequestValidatorSuccess(parameters); + + PushAuthorizationRequestValidationResult result = await sut + .ValidateAsync(new PushedAuthorizationRequestValidationContext(parameters),CancellationToken.None); + + authorizeRequestValidator.Verify(arv=>arv.ValidateAsync( + parameters, + null),Times.Once); + } + + private void StubAuthorizeRequestValidatorSuccess(NameValueCollection? parameters = null) + { + authorizeRequestValidator.Setup(arv=>arv.ValidateAsync( + parameters ?? new NameValueCollection(), + null)) + .ReturnsAsync(new AuthorizeRequestValidationResult(new ValidatedAuthorizeRequest())); + } + + [Fact] + public async Task ValidateAsync_when_called_with_valid_request_should_return_good_validation_result() + { + var sut = CreateSut(); + var parameters = new NameValueCollection(); + var expectedValidatedAuthorizeRequest = new ValidatedAuthorizeRequest(); + + authorizeRequestValidator.Setup(arv=>arv.ValidateAsync( + parameters, + null)) + .ReturnsAsync(new AuthorizeRequestValidationResult(expectedValidatedAuthorizeRequest)); + + PushAuthorizationRequestValidationResult result = await sut + .ValidateAsync(new PushedAuthorizationRequestValidationContext(parameters),CancellationToken.None); + + result.ValidatedAuthorizeRequest.Should().Be(expectedValidatedAuthorizeRequest); + result.IsError.Should().BeFalse(); + } + + [Fact] + public async Task ValidateAsync_when_called_with_invalid_request_should_return_validation_result_with_error() + { + var sut = CreateSut(); + var parameters = new NameValueCollection(); + var expectedValidatedAuthorizeRequest = new ValidatedAuthorizeRequest(); + var expectedError = "authorize validation failed"; + var expectedErrorDescription = "authorize validation failed description"; + + authorizeRequestValidator.Setup(arv=>arv.ValidateAsync( + parameters, + null)) + .ReturnsAsync(new AuthorizeRequestValidationResult(expectedValidatedAuthorizeRequest,expectedError,expectedErrorDescription)); + + PushAuthorizationRequestValidationResult result = await sut + .ValidateAsync(new PushedAuthorizationRequestValidationContext(parameters), + CancellationToken.None); + + result.IsError.Should().BeTrue(); + result.Error.Should().Be(expectedError); + result.ErrorDescription.Should().Be(expectedErrorDescription); + } + + [Fact] + public async Task ValidateAsync_when_called_should_log_starting_and_completing_validation() + { + var sut = CreateSut(); + + StubAuthorizeRequestValidatorSuccess(); + + PushAuthorizationRequestValidationResult result = await sut.ValidateAsync( + validPushAuthorizationRequestValidationContext,CancellationToken.None); + + VerifyLog(LogLevel.Debug,"Starting pushed authorization request validation"); + VerifyLog(LogLevel.Trace,"Pushed authorization request validation completed. Success."); + } + + + private void VerifyLog(LogLevel logLevel, string expectedMessage) + { + logger.Verify(x => x.Log( + logLevel, + It.IsAny(), + It.Is((value, _) => + value.ToString() != null && + value.ToString()!.Contains(expectedMessage)), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + private PushedAuthorizationRequestValidator CreateSut() + { + return new PushedAuthorizationRequestValidator(authorizeRequestValidator.Object, logger.Object); + } +} \ No newline at end of file diff --git a/src/Storage/src/Models/PushedAuthorizationMemento.cs b/src/Storage/src/Models/PushedAuthorizationMemento.cs new file mode 100644 index 000000000..660b3fe8e --- /dev/null +++ b/src/Storage/src/Models/PushedAuthorizationMemento.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Specialized; + +namespace Open.IdentityServer.Storage.Models; + +/// +/// Used to serialize the data necessary to store a pushed authorization request. +/// +/// +public record PushedAuthorizationMemento(string Key , DateTimeOffset ValidUntil , NameValueCollection Parameters ) { } + diff --git a/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs b/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs new file mode 100644 index 000000000..c72f8267b --- /dev/null +++ b/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs @@ -0,0 +1,29 @@ +// 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.Threading.Tasks; +using Open.IdentityServer.Storage.Models; + +namespace Open.IdentityServer.Stores; + +#nullable enable + +/// +/// Models the persistence of a pushed authorization request. +/// +public interface IPushedAuthorizationRequestStore +{ + /// + /// Stores the passed pushed authorization request against the id used as a key. + /// + /// The pushed authorization request information to store + /// A task indicating the async lifetime of the method + Task StorePushedAuthorizationRequestAsync(PushedAuthorizationMemento requestInformation); + + /// + /// Retrieves and consumes a pushed authorization request. The stored request cannot be retrieved again. + /// + /// The id of the stored request to retrieve + /// The stored request of null if no consumable request matches the passed id + Task ConsumePushedAuthorizationRequestAsync(string id); +} \ No newline at end of file diff --git a/src/Storage/src/Stores/PushedAuthorizationRequestStoreException.cs b/src/Storage/src/Stores/PushedAuthorizationRequestStoreException.cs new file mode 100644 index 000000000..e6d9d667d --- /dev/null +++ b/src/Storage/src/Stores/PushedAuthorizationRequestStoreException.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. +#nullable enable +using System; + +namespace Open.IdentityServer.Stores; + +/// +/// Raised by a PAR store +/// +public class PushedAuthorizationRequestStoreException : Exception +{ + /// + /// PAR Store Exception + /// + /// The error message + public PushedAuthorizationRequestStoreException(string message):base(message) { } + + /// + /// Par Store Exception + /// + /// The error message + /// Inner exception + public PushedAuthorizationRequestStoreException(string message, Exception inner) : base(message, inner) { } +} \ No newline at end of file