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