From 87951c6543e0e246b877d320ca66da110963546e Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Fri, 5 Jun 2026 09:18:00 +0100 Subject: [PATCH 01/30] feat: add Pushed Authorization skeleton endpoint and corresponding tests --- .../Endpoints/PushedAuthorizationEndpoint.cs | 23 +++++++++ .../Results/PushedAuthorizationResult.cs | 16 +++++++ ...pen.IdentityServer.IntegrationTests.csproj | 1 + .../PushedAuthorizationTests.cs | 47 +++++++++++++++++++ 4 files changed, 87 insertions(+) create mode 100644 src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs create mode 100644 src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs diff --git a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs new file mode 100644 index 00000000..53604b26 --- /dev/null +++ b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.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.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Open.IdentityServer.Endpoints.Results; +using Open.IdentityServer.Hosting; + +namespace Open.IdentityServer.Endpoints; + +internal class PushedAuthorizationEndpoint : IEndpointHandler +{ + public async Task ProcessAsync(HttpContext context) + { + if (!HttpMethods.IsPost(context.Request.Method)) + { + return new StatusCodeResult(HttpStatusCode.MethodNotAllowed); + } + + return new PushedAuthorizationResult(); + } +} \ 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 00000000..4dabe78e --- /dev/null +++ b/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs @@ -0,0 +1,16 @@ +// 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.Hosting; + +namespace Open.IdentityServer.Endpoints.Results; + +internal class PushedAuthorizationResult : IEndpointResult +{ + public Task ExecuteAsync(HttpContext context) + { + throw new System.NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj index f280fc5d..4ee0e31e 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj @@ -43,6 +43,7 @@ + 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 00000000..4b9fcab1 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs @@ -0,0 +1,47 @@ +// 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.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using IdentityServer.UnitTests.Common; +using Microsoft.AspNetCore.Http.HttpResults; +using Open.IdentityServer.Endpoints; +using Open.IdentityServer.Endpoints.Results; +using Open.IdentityServer.Hosting; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Endpoints.PushedAuthorization; + +public class PushedAuthorizationTests +{ + [Theory] + [InlineData("GET", false)] + [InlineData("PUT", false)] + [InlineData("DELETE", false)] + [InlineData("PATCH", false)] + [InlineData("POST", true)] + public async Task ProcessAsync_should_only_support_http_verb_POST(string verb, bool isSupported) + { + var sut = CreateSut(); + var context = new MockHttpContextAccessor().HttpContext!; + context.Request.Method = verb; + + IEndpointResult result = await sut.ProcessAsync(context); + if (!isSupported) + { + result.Should().BeOfType() + .Subject.StatusCode.Should().Be((int)HttpStatusCode.MethodNotAllowed); + } + else + { + result.Should().BeOfType(); + } + } + + private PushedAuthorizationEndpoint CreateSut() + { + return new PushedAuthorizationEndpoint(); + } +} \ No newline at end of file From 8005f8f9dfc96761a6a5ff1be61a3c515490cb83 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Sat, 6 Jun 2026 08:17:22 +0100 Subject: [PATCH 02/30] feat: Added invoking validation --- .../Endpoints/PushedAuthorizationEndpoint.cs | 49 ++++++++- .../Models/PushedAuthorizationResponse.cs | 13 +++ ...edAuthorizationRequestValidationContext.cs | 11 ++ .../IPushedAuthorizationRequestValidator.cs | 23 ++++ ...ushAuthorizationRequestValidationResult.cs | 22 ++++ .../PushedAuthorizationTests.cs | 104 +++++++++++++++++- 6 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 src/Open.IdentityServer/src/ResponseHandling/Models/PushedAuthorizationResponse.cs create mode 100644 src/Open.IdentityServer/src/Validation/Contexts/PushedAuthorizationRequestValidationContext.cs create mode 100644 src/Open.IdentityServer/src/Validation/IPushedAuthorizationRequestValidator.cs create mode 100644 src/Open.IdentityServer/src/Validation/Models/PushAuthorizationRequestValidationResult.cs diff --git a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs index 53604b26..a2d086d2 100644 --- a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs +++ b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs @@ -1,23 +1,66 @@ // 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.Net; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Open.IdentityServer.Endpoints.Results; using Open.IdentityServer.Hosting; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Validation; +#nullable enable namespace Open.IdentityServer.Endpoints; -internal class PushedAuthorizationEndpoint : IEndpointHandler +internal class PushedAuthorizationEndpoint(IPushedAuthorizationRequestValidator validator) : IEndpointHandler { - public async Task ProcessAsync(HttpContext context) + public async Task ProcessAsync(HttpContext requestContext) { - if (!HttpMethods.IsPost(context.Request.Method)) + if (!HttpMethods.IsPost(requestContext.Request.Method)) { return new StatusCodeResult(HttpStatusCode.MethodNotAllowed); } + NameValueCollection? parParameters = await ParseForm(requestContext.Request); + if (parParameters == null) + { + return new StatusCodeResult(HttpStatusCode.BadRequest); + } + PushedAuthorizationRequestValidationContext validationContext = new (parParameters); + return await ProcessRequest(requestContext, validationContext); + } + + private async Task ProcessRequest(HttpContext requestContext, + PushedAuthorizationRequestValidationContext validationContext) + { + PushAuthorizationRequestValidationResult result = await validator + .ValidateAsync(validationContext, requestContext.RequestAborted); + + if (result.IsError) + { + return new BadRequestResult(result.Error, result.ErrorDescription); + } + + // Generate URI + // Store Validated Authorize Request + return new PushedAuthorizationResult(); } + + private async Task ParseForm(HttpRequest request) + { + try + { + IFormCollection form = await request.ReadFormAsync(); + NameValueCollection parParameters = form.AsNameValueCollection(); + + return parParameters; + } + catch (InvalidOperationException ) + { + return null; + } + } } \ 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 00000000..1c4d0e4d --- /dev/null +++ b/src/Open.IdentityServer/src/ResponseHandling/Models/PushedAuthorizationResponse.cs @@ -0,0 +1,13 @@ +using System; + +namespace Open.IdentityServer.ResponseHandling; + +/// +/// +/// +/// The URI that represents the PAR +/// The lifetime of the URI in seconds +public class PushedAuthorizationResponse(Uri uri , long lifetime) +{ + +} \ 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 00000000..09fec61a --- /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/IPushedAuthorizationRequestValidator.cs b/src/Open.IdentityServer/src/Validation/IPushedAuthorizationRequestValidator.cs new file mode 100644 index 00000000..5eb2b850 --- /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/PushAuthorizationRequestValidationResult.cs b/src/Open.IdentityServer/src/Validation/Models/PushAuthorizationRequestValidationResult.cs new file mode 100644 index 00000000..791f75b3 --- /dev/null +++ b/src/Open.IdentityServer/src/Validation/Models/PushAuthorizationRequestValidationResult.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. + +namespace Open.IdentityServer.Validation; +#nullable enable + +/// +/// +/// +public class PushAuthorizationRequestValidationResult : ValidationResult +{ + /// + /// + /// + /// + /// + public PushAuthorizationRequestValidationResult(string error, string errorDescription) + { + Error = error; + ErrorDescription = errorDescription; + } +} \ 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 index 4b9fcab1..ae83b22d 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs @@ -2,20 +2,29 @@ // 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 IdentityServer.UnitTests.Common; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; +using Moq; using Open.IdentityServer.Endpoints; using Open.IdentityServer.Endpoints.Results; using Open.IdentityServer.Hosting; +using Open.IdentityServer.Validation; using Xunit; namespace Open.IdentityServer.UnitTests.Endpoints.PushedAuthorization; public class PushedAuthorizationTests { + + private readonly Mock pushedAuthorizationRequestValidator = new(); + private readonly MockHttpContextAccessor mockHttpContext = new(); + [Theory] [InlineData("GET", false)] [InlineData("PUT", false)] @@ -25,23 +34,106 @@ public class PushedAuthorizationTests public async Task ProcessAsync_should_only_support_http_verb_POST(string verb, bool isSupported) { var sut = CreateSut(); - var context = new MockHttpContextAccessor().HttpContext!; + var context = mockHttpContext.HttpContext!; context.Request.Method = verb; - + IEndpointResult result = await sut.ProcessAsync(context); if (!isSupported) { - result.Should().BeOfType() - .Subject.StatusCode.Should().Be((int)HttpStatusCode.MethodNotAllowed); + ResultShouldBeStatusCodeOf(result, HttpStatusCode.MethodNotAllowed); } else { - result.Should().BeOfType(); + ResultShouldBeStatusCodeOf(result, HttpStatusCode.BadRequest); } } + + + [Fact] + public async Task ProcessAsync_should_return_bad_request_when_no_form_body_in_request() + { + var sut = CreateSut(); + var context = mockHttpContext.HttpContext!; + context.Request.Method = "POST"; + + IEndpointResult result = await sut.ProcessAsync(context); + ResultShouldBeStatusCodeOf(result, HttpStatusCode.BadRequest); + } + + [Fact] + public async Task ProcessAsync_when_called_with_post_form_body_should_validate_request() + { + var sut = CreateSut(); + var context = mockHttpContext.HttpContext!; + + NameValueCollection parameters = new NameValueCollection() + { + { "scope", "profile" } + }; + + 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 = mockHttpContext.HttpContext!; + string expectedError = "Invalid scope"; + string expectedErrorDescription = "The requested scope is invalid, unknown, or malformed."; + + NameValueCollection parameters = new NameValueCollection(); + AddRequest(parameters); + + pushedAuthorizationRequestValidator + .Setup(parv => + parv.ValidateAsync(It.IsAny(), context.RequestAborted)) + .ReturnsAsync(new PushAuthorizationRequestValidationResult(expectedError, expectedErrorDescription)); + + IEndpointResult result = await sut.ProcessAsync(context); + + result.Should() + .BeOfType() + .And.BeEquivalentTo(new BadRequestResult(expectedError, expectedErrorDescription)); + + } + + 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 PushedAuthorizationEndpoint CreateSut() { - return new PushedAuthorizationEndpoint(); + return new PushedAuthorizationEndpoint(pushedAuthorizationRequestValidator?.Object); } } \ No newline at end of file From 95412a39f332618bd620b3ac80da470e687cf88b Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Thu, 11 Jun 2026 14:39:58 +0100 Subject: [PATCH 03/30] feat: PAR endpoint handler simple path complete --- .../Endpoints/PushedAuthorizationEndpoint.cs | 18 +++++--- .../Results/PushedAuthorizationResult.cs | 3 +- .../IAuthorizeInteractionResponseGenerator.cs | 3 +- .../IPushedAuthorizationResponseGenerator.cs | 17 +++++++ ...ushAuthorizationRequestValidationResult.cs | 18 +++++++- .../PushedAuthorizationTests.cs | 45 +++++++++++++------ 6 files changed, 81 insertions(+), 23 deletions(-) create mode 100644 src/Open.IdentityServer/src/ResponseHandling/IPushedAuthorizationResponseGenerator.cs diff --git a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs index a2d086d2..19118176 100644 --- a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs +++ b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs @@ -9,12 +9,15 @@ using Open.IdentityServer.Endpoints.Results; using Open.IdentityServer.Hosting; using Open.IdentityServer.Extensions; +using Open.IdentityServer.ResponseHandling; using Open.IdentityServer.Validation; #nullable enable namespace Open.IdentityServer.Endpoints; -internal class PushedAuthorizationEndpoint(IPushedAuthorizationRequestValidator validator) : IEndpointHandler +internal class PushedAuthorizationEndpoint( + IPushedAuthorizationRequestValidator validator , + IPushedAuthorizationResponseGenerator responseGenerator) : IEndpointHandler { public async Task ProcessAsync(HttpContext requestContext) { @@ -28,11 +31,12 @@ public async Task ProcessAsync(HttpContext requestContext) { return new StatusCodeResult(HttpStatusCode.BadRequest); } - PushedAuthorizationRequestValidationContext validationContext = new (parParameters); + var validationContext = new PushedAuthorizationRequestValidationContext(parParameters); return await ProcessRequest(requestContext, validationContext); } - private async Task ProcessRequest(HttpContext requestContext, + private async Task ProcessRequest( + HttpContext requestContext, PushedAuthorizationRequestValidationContext validationContext) { PushAuthorizationRequestValidationResult result = await validator @@ -42,11 +46,11 @@ private async Task ProcessRequest(HttpContext requestContext, { return new BadRequestResult(result.Error, result.ErrorDescription); } + + PushedAuthorizationResponse response = await responseGenerator + .CreateResponseAsync(result.ValidatedAuthorizeRequest); - // Generate URI - // Store Validated Authorize Request - - return new PushedAuthorizationResult(); + return new PushedAuthorizationResult(response); } private async Task ParseForm(HttpRequest request) diff --git a/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs b/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs index 4dabe78e..4cc54437 100644 --- a/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs +++ b/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs @@ -4,10 +4,11 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Open.IdentityServer.Hosting; +using Open.IdentityServer.ResponseHandling; namespace Open.IdentityServer.Endpoints.Results; -internal class PushedAuthorizationResult : IEndpointResult +internal record PushedAuthorizationResult(PushedAuthorizationResponse Response) : IEndpointResult { public Task ExecuteAsync(HttpContext context) { diff --git a/src/Open.IdentityServer/src/ResponseHandling/IAuthorizeInteractionResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/IAuthorizeInteractionResponseGenerator.cs index 88dcff7e..b317eda3 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/IAuthorizeInteractionResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/IAuthorizeInteractionResponseGenerator.cs @@ -20,4 +20,5 @@ public interface IAuthorizeInteractionResponseGenerator /// The consent. /// A task that resolves to an indicating whether the user must log in, consent, or can proceed with the authorization request. Task ProcessInteractionAsync(ValidatedAuthorizeRequest request, ConsentResponse consent = 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 00000000..aa1ed9b2 --- /dev/null +++ b/src/Open.IdentityServer/src/ResponseHandling/IPushedAuthorizationResponseGenerator.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; +using Open.IdentityServer.Validation; + +namespace Open.IdentityServer.ResponseHandling; + +/// +/// +/// +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/Validation/Models/PushAuthorizationRequestValidationResult.cs b/src/Open.IdentityServer/src/Validation/Models/PushAuthorizationRequestValidationResult.cs index 791f75b3..19c65cc5 100644 --- a/src/Open.IdentityServer/src/Validation/Models/PushAuthorizationRequestValidationResult.cs +++ b/src/Open.IdentityServer/src/Validation/Models/PushAuthorizationRequestValidationResult.cs @@ -10,13 +10,29 @@ namespace Open.IdentityServer.Validation; 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/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs index ae83b22d..e51c44f4 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs @@ -14,6 +14,7 @@ using Open.IdentityServer.Endpoints; using Open.IdentityServer.Endpoints.Results; using Open.IdentityServer.Hosting; +using Open.IdentityServer.ResponseHandling; using Open.IdentityServer.Validation; using Xunit; @@ -23,6 +24,8 @@ public class PushedAuthorizationTests { private readonly Mock pushedAuthorizationRequestValidator = new(); + private readonly Mock pushedAuthorizationResponseGenerator = new(); + private readonly MockHttpContextAccessor mockHttpContext = new(); [Theory] @@ -38,18 +41,9 @@ public async Task ProcessAsync_should_only_support_http_verb_POST(string verb, b context.Request.Method = verb; IEndpointResult result = await sut.ProcessAsync(context); - if (!isSupported) - { - ResultShouldBeStatusCodeOf(result, HttpStatusCode.MethodNotAllowed); - } - else - { - ResultShouldBeStatusCodeOf(result, HttpStatusCode.BadRequest); - } + ResultShouldBeStatusCodeOf(result, !isSupported ? HttpStatusCode.MethodNotAllowed : HttpStatusCode.BadRequest); } - - - + [Fact] public async Task ProcessAsync_should_return_bad_request_when_no_form_body_in_request() { @@ -103,9 +97,32 @@ public async Task ProcessAsync_when_called_with_invalid_request_should_return_ba result.Should() .BeOfType() .And.BeEquivalentTo(new BadRequestResult(expectedError, expectedErrorDescription)); - } + [Fact] + public async Task ProcessAsync_when_called_with_valid_request_should_generate_ok_response() + { + var sut = CreateSut(); + var context = mockHttpContext.HttpContext!; + var requestValidatorResult = new PushAuthorizationRequestValidationResult(new ValidatedAuthorizeRequest()); + var expectedResult = new PushedAuthorizationResponse(new Uri("urn:foo"), 10); + var parameters = new NameValueCollection(); + + AddRequest(parameters); + pushedAuthorizationRequestValidator + .Setup(parv => + parv.ValidateAsync(It.IsAny(), context.RequestAborted)) + .ReturnsAsync(requestValidatorResult); + + pushedAuthorizationResponseGenerator + .Setup(parg => parg.CreateResponseAsync(requestValidatorResult.ValidatedAuthorizeRequest)) + .ReturnsAsync(expectedResult); + + PushedAuthorizationResult result = (PushedAuthorizationResult)await sut.ProcessAsync(context); + + result.Response.Should().Be(expectedResult); + } + private static bool IsNameCollectionEquivalent(NameValueCollection lhs, NameValueCollection rhs) { return lhs.Count == rhs.Count && @@ -134,6 +151,8 @@ private static void ResultShouldBeStatusCodeOf(IEndpointResult result , HttpStat private PushedAuthorizationEndpoint CreateSut() { - return new PushedAuthorizationEndpoint(pushedAuthorizationRequestValidator?.Object); + return new PushedAuthorizationEndpoint( + pushedAuthorizationRequestValidator.Object, + pushedAuthorizationResponseGenerator.Object); } } \ No newline at end of file From 69e672bbb14036e9f1cabe3853cf08c1de4aeeb1 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Sun, 14 Jun 2026 17:28:45 +0100 Subject: [PATCH 04/30] feat: initial PushedAuthorizationEndpoint logging done --- .../Endpoints/PushedAuthorizationEndpoint.cs | 7 +- .../src/Properties/AssemblyInfo.cs | 4 +- .../PushedAuthorizationTests.cs | 71 +++++++++++++++---- 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs index 19118176..af00733e 100644 --- a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs +++ b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs @@ -6,6 +6,7 @@ using System.Net; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; using Open.IdentityServer.Endpoints.Results; using Open.IdentityServer.Hosting; using Open.IdentityServer.Extensions; @@ -17,10 +18,12 @@ namespace Open.IdentityServer.Endpoints; internal class PushedAuthorizationEndpoint( IPushedAuthorizationRequestValidator validator , - IPushedAuthorizationResponseGenerator responseGenerator) : IEndpointHandler + IPushedAuthorizationResponseGenerator responseGenerator, + ILogger logger) : IEndpointHandler { public async Task ProcessAsync(HttpContext requestContext) { + logger.LogDebug("Start processing pushed authorization request"); if (!HttpMethods.IsPost(requestContext.Request.Method)) { return new StatusCodeResult(HttpStatusCode.MethodNotAllowed); @@ -50,6 +53,8 @@ private async Task ProcessRequest( PushedAuthorizationResponse response = await responseGenerator .CreateResponseAsync(result.ValidatedAuthorizeRequest); + + logger.LogTrace("End processing pushed authorization request"); return new PushedAuthorizationResult(response); } diff --git a/src/Open.IdentityServer/src/Properties/AssemblyInfo.cs b/src/Open.IdentityServer/src/Properties/AssemblyInfo.cs index b19dbc41..e94a3345 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/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs index e51c44f4..5dd42b3e 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs @@ -10,6 +10,7 @@ using IdentityServer.UnitTests.Common; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.Extensions.Logging; using Moq; using Open.IdentityServer.Endpoints; using Open.IdentityServer.Endpoints.Results; @@ -25,8 +26,36 @@ public class PushedAuthorizationTests private readonly Mock pushedAuthorizationRequestValidator = new(); private readonly Mock pushedAuthorizationResponseGenerator = new(); - + private readonly Mock> logger = new(); private readonly MockHttpContextAccessor mockHttpContext = new(); + + private readonly PushAuthorizationRequestValidationResult parErrorValidationResult = new ("error", "error_description"); + private readonly PushAuthorizationRequestValidationResult validatedAuthorizeRequest = new (new ValidatedAuthorizeRequest()); + + [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); + } [Theory] [InlineData("GET", false)] @@ -37,8 +66,7 @@ public class PushedAuthorizationTests public async Task ProcessAsync_should_only_support_http_verb_POST(string verb, bool isSupported) { var sut = CreateSut(); - var context = mockHttpContext.HttpContext!; - context.Request.Method = verb; + var context = CreateHttpContext(verb); IEndpointResult result = await sut.ProcessAsync(context); ResultShouldBeStatusCodeOf(result, !isSupported ? HttpStatusCode.MethodNotAllowed : HttpStatusCode.BadRequest); @@ -48,8 +76,7 @@ public async Task ProcessAsync_should_only_support_http_verb_POST(string verb, b public async Task ProcessAsync_should_return_bad_request_when_no_form_body_in_request() { var sut = CreateSut(); - var context = mockHttpContext.HttpContext!; - context.Request.Method = "POST"; + var context = CreateHttpContext(); IEndpointResult result = await sut.ProcessAsync(context); ResultShouldBeStatusCodeOf(result, HttpStatusCode.BadRequest); @@ -59,13 +86,14 @@ public async Task ProcessAsync_should_return_bad_request_when_no_form_body_in_re public async Task ProcessAsync_when_called_with_post_form_body_should_validate_request() { var sut = CreateSut(); - var context = mockHttpContext.HttpContext!; + var context = CreateHttpContext(); NameValueCollection parameters = new NameValueCollection() { { "scope", "profile" } }; + StubValidateAsync(context, validatedAuthorizeRequest); AddRequest(parameters); IEndpointResult result = await sut.ProcessAsync(context); @@ -80,17 +108,16 @@ public async Task ProcessAsync_when_called_with_post_form_body_should_validate_r public async Task ProcessAsync_when_called_with_invalid_request_should_return_bad_request() { var sut = CreateSut(); - var context = mockHttpContext.HttpContext!; + var context = CreateHttpContext(); string expectedError = "Invalid scope"; string expectedErrorDescription = "The requested scope is invalid, unknown, or malformed."; NameValueCollection parameters = new NameValueCollection(); AddRequest(parameters); - pushedAuthorizationRequestValidator - .Setup(parv => - parv.ValidateAsync(It.IsAny(), context.RequestAborted)) - .ReturnsAsync(new PushAuthorizationRequestValidationResult(expectedError, expectedErrorDescription)); + StubValidateAsync( + context, + new PushAuthorizationRequestValidationResult(expectedError, expectedErrorDescription)); IEndpointResult result = await sut.ProcessAsync(context); @@ -99,11 +126,13 @@ public async Task ProcessAsync_when_called_with_invalid_request_should_return_ba .And.BeEquivalentTo(new BadRequestResult(expectedError, expectedErrorDescription)); } + + [Fact] public async Task ProcessAsync_when_called_with_valid_request_should_generate_ok_response() { var sut = CreateSut(); - var context = mockHttpContext.HttpContext!; + var context = CreateHttpContext(); var requestValidatorResult = new PushAuthorizationRequestValidationResult(new ValidatedAuthorizeRequest()); var expectedResult = new PushedAuthorizationResponse(new Uri("urn:foo"), 10); var parameters = new NameValueCollection(); @@ -123,6 +152,21 @@ public async Task ProcessAsync_when_called_with_valid_request_should_generate_ok result.Response.Should().Be(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 && @@ -153,6 +197,7 @@ private PushedAuthorizationEndpoint CreateSut() { return new PushedAuthorizationEndpoint( pushedAuthorizationRequestValidator.Object, - pushedAuthorizationResponseGenerator.Object); + pushedAuthorizationResponseGenerator.Object, + logger.Object); } } \ No newline at end of file From bf3e4d90d8fe0364b0f89eaecc544f7d0f176692 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Wed, 17 Jun 2026 16:54:22 +0100 Subject: [PATCH 05/30] feat: implement PushedAuthorizationRequestValidator with validation logic and tests --- .../PushedAuthorizationRequestValidator.cs | 35 +++++ ...ushAuthorizationRequestValidationResult.cs | 2 +- ...ushedAuthorizationRequestValidatorTests.cs | 131 ++++++++++++++++++ 3 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 src/Open.IdentityServer/src/Validation/Default/PushedAuthorizationRequestValidator.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/PushedAuthorizationRequestValidatorTests.cs 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 00000000..6a77641c --- /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("request_uri") != null) + { + return new PushAuthorizationRequestValidationResult( "request_uri not allowed" , "request_uri 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/Models/PushAuthorizationRequestValidationResult.cs b/src/Open.IdentityServer/src/Validation/Models/PushAuthorizationRequestValidationResult.cs index 19c65cc5..7e412385 100644 --- a/src/Open.IdentityServer/src/Validation/Models/PushAuthorizationRequestValidationResult.cs +++ b/src/Open.IdentityServer/src/Validation/Models/PushAuthorizationRequestValidationResult.cs @@ -26,7 +26,7 @@ public PushAuthorizationRequestValidationResult(string error, string errorDescri ErrorDescription = errorDescription; } - /// + /// /// Create a fully validated authorization request /// /// 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 00000000..a2c34108 --- /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 From 6701fd45e1774269a1dd36a1e7520e9f72928879 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Thu, 25 Jun 2026 08:14:22 +0100 Subject: [PATCH 06/30] feat: add PAR discovery document generation --- .../BuilderExtensions/Core.cs | 2 +- .../Options/EndpointOptions.cs | 5 ++ .../Options/IdentityServerOptions.cs | 5 ++ src/Open.IdentityServer/src/Constants.cs | 2 + src/Open.IdentityServer/src/OidcConstants.cs | 6 +++ .../Default/DiscoveryResponseGenerator.cs | 25 ++++++++++ .../DiscoveryResponseGeneratorTests.cs | 49 +++++++++++++++++++ 7 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs index 7cd2d400..f55f438d 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; } diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/EndpointOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/EndpointOptions.cs index 4d4cd438..6ca4fc46 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 EnablePushedAuthorizationEndpoint { 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 0df4a320..11d1f05f 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; + + /// + /// Enforce PAR for all authorization code flow requests + /// + public bool RequirePushedAuthorization { get; set; } = false; } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Constants.cs b/src/Open.IdentityServer/src/Constants.cs index 487ef554..2b11ae9b 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/OidcConstants.cs b/src/Open.IdentityServer/src/OidcConstants.cs index b78b49e0..e368cafb 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/ResponseHandling/Default/DiscoveryResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs index 7439cbfb..4f22b567 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs @@ -208,8 +208,20 @@ string ConstructMtlsEndpoint(string endpoint) return $"https://{Options.MutualTls.DomainName}.{parts[1]}{endpoint}"; } } + + AddPushedAuthorizationRequestEndpoint(baseUrl, entries); + } } + + // If PAR is enabled + if (Options.Endpoints.EnablePushedAuthorizationEndpoint) + { + entries.Add( + OidcConstants.Discovery.RequirePushedAuthorizationRequests, + Options.RequirePushedAuthorization.ToString().ToLowerInvariant() + ); + } // logout if (Options.Endpoints.EnableEndSessionEndpoint) @@ -369,10 +381,23 @@ where scope.ShowInDiscoveryDocument } } } + return entries; } + private void AddPushedAuthorizationRequestEndpoint(string baseUrl, Dictionary entries) + { + if (Options.Endpoints.EnablePushedAuthorizationEndpoint == false) + { + return; + } + + string parPath = $"{baseUrl}/{Constants.ProtocolRoutePaths.ConnectPathPrefix}/{Constants.ProtocolRoutePaths.PushedAuthorizationRequest}"; + + entries.Add(OidcConstants.Discovery.PushedAuthorizationRequestEndpoint, parPath); + } + /// /// Creates the JWK document. /// 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 3d0a57de..639a23f4 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.EnablePushedAuthorizationEndpoint = true; + + string expectedParEndpoint = $"https://open.ids.url/somepath/{Constants.ProtocolRoutePaths.ConnectPathPrefix}/{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.EnablePushedAuthorizationEndpoint = 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.RequirePushedAuthorization = isEnforced; + string expectedValue = isEnforced.ToString().ToLowerInvariant(); + var actual = await sut.CreateDiscoveryDocumentAsync("https://open.ids.url/somepath", "https://open.ids.url"); + + actual. + Should() + .ContainKey(OidcConstants.Discovery.RequirePushedAuthorizationRequests) + .WhoseValue.Should().Be(expectedValue); + } + + } \ No newline at end of file From ced36175397979a87a17a5015e43751d9c3d703b Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Thu, 25 Jun 2026 08:26:03 +0100 Subject: [PATCH 07/30] feat: adjust formatting in DiscoveryResponseGenerator for clarity --- .../ResponseHandling/Default/DiscoveryResponseGenerator.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs index 4f22b567..bb235d40 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs @@ -209,9 +209,9 @@ string ConstructMtlsEndpoint(string endpoint) } } - AddPushedAuthorizationRequestEndpoint(baseUrl, entries); - + } + AddPushedAuthorizationRequestEndpoint(baseUrl, entries); } // If PAR is enabled From 374e516597df480f8c6647db522de74babe89907 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Fri, 26 Jun 2026 07:03:00 +0100 Subject: [PATCH 08/30] feat: rename PushedAuthorizationEndpoint to PushedAuthorizationRequestEndpoint and update related logic --- .../BuilderExtensions/Core.cs | 4 +- .../Options/EndpointOptions.cs | 2 +- .../Endpoints/PushedAuthorizationEndpoint.cs | 11 +- .../Results/PushedAuthorizationResult.cs | 7 +- .../Default/DiscoveryResponseGenerator.cs | 6 +- .../Models/PushedAuthorizationResponse.cs | 13 +- .../PushedAuthorizationRequestValidator.cs | 4 +- .../Common/IdentityServerPipeline.cs | 16 +- .../PushBasedAuthorizationRequestTests.cs | 140 ++++++++++++++++++ ...pen.IdentityServer.IntegrationTests.csproj | 1 - .../PushedAuthorizationTests.cs | 24 ++- .../DiscoveryResponseGeneratorTests.cs | 9 +- 12 files changed, 211 insertions(+), 26 deletions(-) create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/PushedAuthorization/PushBasedAuthorizationRequestTests.cs diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs index f55f438d..18951203 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()); + builder.AddEndpoint(EndpointNames.PushedAuthorizationRequest, ProtocolRoutePaths.PushedAuthorizationRequest.EnsureLeadingSlash()); return builder; } @@ -218,7 +218,7 @@ 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(); diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/EndpointOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/EndpointOptions.cs index 6ca4fc46..79982806 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/EndpointOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/EndpointOptions.cs @@ -89,5 +89,5 @@ public class EndpointsOptions /// /// Gets or sets a value indicating whether the pushed authorization endpoint is enabled. /// - public bool EnablePushedAuthorizationEndpoint { get; set; } = true; + public bool EnablePushedAuthorizationRequestEndpoint { get; set; } = true; } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs index af00733e..5b9ea925 100644 --- a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs +++ b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs @@ -7,6 +7,7 @@ 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; @@ -16,13 +17,19 @@ #nullable enable namespace Open.IdentityServer.Endpoints; -internal class PushedAuthorizationEndpoint( +internal class PushedAuthorizationRequestEndpoint( + IdentityServerOptions options, IPushedAuthorizationRequestValidator validator , IPushedAuthorizationResponseGenerator responseGenerator, - ILogger logger) : IEndpointHandler + ILogger logger) : IEndpointHandler { public async Task ProcessAsync(HttpContext requestContext) { + if ( options.Endpoints.EnablePushedAuthorizationRequestEndpoint == false) + { + return new StatusCodeResult(HttpStatusCode.NotFound); + } + logger.LogDebug("Start processing pushed authorization request"); if (!HttpMethods.IsPost(requestContext.Request.Method)) { diff --git a/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs b/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs index 4cc54437..f323f995 100644 --- a/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs +++ b/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs @@ -10,8 +10,11 @@ namespace Open.IdentityServer.Endpoints.Results; internal record PushedAuthorizationResult(PushedAuthorizationResponse Response) : IEndpointResult { - public Task ExecuteAsync(HttpContext context) + public async Task ExecuteAsync(HttpContext context) { - throw new System.NotImplementedException(); + context.Response.StatusCode = StatusCodes.Status201Created; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync(Response); + } } \ 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 bb235d40..c4241bd6 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs @@ -215,11 +215,11 @@ string ConstructMtlsEndpoint(string endpoint) } // If PAR is enabled - if (Options.Endpoints.EnablePushedAuthorizationEndpoint) + if (Options.Endpoints.EnablePushedAuthorizationRequestEndpoint) { entries.Add( OidcConstants.Discovery.RequirePushedAuthorizationRequests, - Options.RequirePushedAuthorization.ToString().ToLowerInvariant() + Options.RequirePushedAuthorization ); } @@ -388,7 +388,7 @@ where scope.ShowInDiscoveryDocument private void AddPushedAuthorizationRequestEndpoint(string baseUrl, Dictionary entries) { - if (Options.Endpoints.EnablePushedAuthorizationEndpoint == false) + if (Options.Endpoints.EnablePushedAuthorizationRequestEndpoint == false) { return; } diff --git a/src/Open.IdentityServer/src/ResponseHandling/Models/PushedAuthorizationResponse.cs b/src/Open.IdentityServer/src/ResponseHandling/Models/PushedAuthorizationResponse.cs index 1c4d0e4d..db4604a9 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Models/PushedAuthorizationResponse.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Models/PushedAuthorizationResponse.cs @@ -1,4 +1,5 @@ using System; +using System.Text.Json.Serialization; namespace Open.IdentityServer.ResponseHandling; @@ -9,5 +10,15 @@ namespace Open.IdentityServer.ResponseHandling; /// 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("request_uri")] + public string Uri { get; } = uri.ToString(); + + /// + /// The lifetime in seconds of the URN + /// + [JsonPropertyName("expires_in")] + public long Lifetime { get; } = lifetime; } \ 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 index 6a77641c..8b3884fc 100644 --- a/src/Open.IdentityServer/src/Validation/Default/PushedAuthorizationRequestValidator.cs +++ b/src/Open.IdentityServer/src/Validation/Default/PushedAuthorizationRequestValidator.cs @@ -15,9 +15,9 @@ public async Task ValidateAsync(Pushed { logger.LogDebug("Starting pushed authorization request validation"); - if (validationContext.RequestParameters.GetValues("request_uri") != null) + if (validationContext.RequestParameters.GetValues(OidcConstants.AuthorizeRequest.RequestUri) != null) { - return new PushAuthorizationRequestValidationResult( "request_uri not allowed" , "request_uri can only be used at the authorization endpoint"); + 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); 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 f03d6b19..a9c65ce5 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) 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 00000000..81266282 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/PushedAuthorization/PushBasedAuthorizationRequestTests.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Claims; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using IdentityServer.IntegrationTests.Common; +using Open.IdentityServer.Models; +using Open.IdentityServer.Test; +using Xunit; +using AwesomeAssertions; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Open.IdentityServer.ResponseHandling; +using Open.IdentityServer.Validation; + +#nullable enable + +namespace Open.IdentityServer.IntegrationTests.Endpoints.PushedAuthorization; + +internal class StubbedPushAuthorizationRequestResponseGenerator : IPushedAuthorizationResponseGenerator +{ + public async Task CreateResponseAsync(ValidatedAuthorizeRequest request) + { + return new PushedAuthorizationResponse(new Uri("urn:stubbed"),60); + } +} + +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(); + + HttpResponseMessage response = await client.PostAsync( + IdentityServerPipeline.PushedAuthorizatioRequestEndpoint, + new FormUrlEncodedContent( new Dictionary() + { + [OidcConstants.AuthorizeRequest.ClientId] = parTestClient.ClientId, + [OidcConstants.AuthorizeRequest.RedirectUri] = parTestClient.RedirectUris.First(), + [OidcConstants.AuthorizeRequest.ResponseType] = OidcConstants.ResponseTypes.Code, + [OidcConstants.AuthorizeRequest.Scope] = "api1 api2", + [OidcConstants.AuthorizeRequest.State] = "1234567890", + }), + TestContext.Current.CancellationToken); + + 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); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj index 4ee0e31e..f280fc5d 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj @@ -43,7 +43,6 @@ - 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 index 5dd42b3e..c0e88116 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.Extensions.Logging; using Moq; +using Open.IdentityServer.Configuration; using Open.IdentityServer.Endpoints; using Open.IdentityServer.Endpoints.Results; using Open.IdentityServer.Hosting; @@ -23,10 +24,10 @@ 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> logger = new(); + private readonly Mock> logger = new(); private readonly MockHttpContextAccessor mockHttpContext = new(); private readonly PushAuthorizationRequestValidationResult parErrorValidationResult = new ("error", "error_description"); @@ -126,7 +127,19 @@ public async Task ProcessAsync_when_called_with_invalid_request_should_return_ba .And.BeEquivalentTo(new BadRequestResult(expectedError, expectedErrorDescription)); } - + [Fact] + public async Task ProcessAsync_when_and_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() @@ -193,9 +206,10 @@ private static void ResultShouldBeStatusCodeOf(IEndpointResult result , HttpStat .Subject.StatusCode.Should().Be((int)expectedStatusCode); } - private PushedAuthorizationEndpoint CreateSut() + private PushedAuthorizationRequestEndpoint CreateSut() { - return new PushedAuthorizationEndpoint( + return new PushedAuthorizationRequestEndpoint( + options, pushedAuthorizationRequestValidator.Object, pushedAuthorizationResponseGenerator.Object, logger.Object); 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 639a23f4..3fb0e01f 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 @@ -762,7 +762,7 @@ public async Task CreateDiscoveryDocumentAsync_WhenMtlsEnabledAndShowAuthMethods public async Task CreateDiscoveryDocumentAsync_WhenParEnabled_ShouldContainParEndpoint() { var sut = CreateSut(); - Options.Endpoints.EnablePushedAuthorizationEndpoint = true; + Options.Endpoints.EnablePushedAuthorizationRequestEndpoint = true; string expectedParEndpoint = $"https://open.ids.url/somepath/{Constants.ProtocolRoutePaths.ConnectPathPrefix}/{Constants.ProtocolRoutePaths.PushedAuthorizationRequest}"; @@ -779,7 +779,7 @@ public async Task CreateDiscoveryDocumentAsync_WhenParEnabled_ShouldContainParEn public async Task CreateDiscoveryDocumentAsync_WhenParDisabled_ShouldNotContainParEndpoint() { var sut = CreateSut(); - Options.Endpoints.EnablePushedAuthorizationEndpoint = false; + Options.Endpoints.EnablePushedAuthorizationRequestEndpoint = false; var actual = await sut.CreateDiscoveryDocumentAsync("https://open.ids.url/somepath", "https://open.ids.url"); @@ -794,14 +794,15 @@ public async Task CreateDiscoveryDocumentAsync_WhenParDisabled_ShouldNotContainP public async Task CreateDiscoveryDocumentAsync_WhenParIsEnforcedOrNot_ShouldContainEnforcementLevel(bool isEnforced) { var sut = CreateSut(); + Options.Endpoints.EnablePushedAuthorizationRequestEndpoint = true; Options.RequirePushedAuthorization = isEnforced; - string expectedValue = isEnforced.ToString().ToLowerInvariant(); + var actual = await sut.CreateDiscoveryDocumentAsync("https://open.ids.url/somepath", "https://open.ids.url"); actual. Should() .ContainKey(OidcConstants.Discovery.RequirePushedAuthorizationRequests) - .WhoseValue.Should().Be(expectedValue); + .WhoseValue.Should().Be(isEnforced); } From f058de2d356265ecc067a3481f53b5ee74ec3bf4 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Fri, 26 Jun 2026 08:47:42 +0100 Subject: [PATCH 09/30] feat: add client secret validation to PushedAuthorizationRequestEndpoint and update tests --- .../Endpoints/PushedAuthorizationEndpoint.cs | 26 +++++++- .../PushBasedAuthorizationRequestTests.cs | 1 + .../PushedAuthorizationTests.cs | 66 ++++++++++++++++--- 3 files changed, 81 insertions(+), 12 deletions(-) diff --git a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs index 5b9ea925..43c8342e 100644 --- a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs +++ b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs @@ -2,6 +2,7 @@ // 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; @@ -19,6 +20,7 @@ namespace Open.IdentityServer.Endpoints; internal class PushedAuthorizationRequestEndpoint( IdentityServerOptions options, + IClientSecretValidator clientSecretValidator, IPushedAuthorizationRequestValidator validator , IPushedAuthorizationResponseGenerator responseGenerator, ILogger logger) : IEndpointHandler @@ -33,13 +35,19 @@ public async Task ProcessAsync(HttpContext requestContext) logger.LogDebug("Start processing pushed authorization request"); if (!HttpMethods.IsPost(requestContext.Request.Method)) { - return new StatusCodeResult(HttpStatusCode.MethodNotAllowed); + return Error(OidcConstants.TokenErrors.InvalidRequest); + } + + ClientSecretValidationResult? clientValidationResult = await clientSecretValidator.ValidateAsync(requestContext); + if ( clientValidationResult.IsError) + { + return Error(OidcConstants.TokenErrors.InvalidClient); } NameValueCollection? parParameters = await ParseForm(requestContext.Request); if (parParameters == null) { - return new StatusCodeResult(HttpStatusCode.BadRequest); + return Error(OidcConstants.TokenErrors.InvalidRequest); } var validationContext = new PushedAuthorizationRequestValidationContext(parParameters); return await ProcessRequest(requestContext, validationContext); @@ -79,4 +87,18 @@ private async Task ProcessRequest( 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/test/Open.IdentityServer.IntegrationTests/Endpoints/PushedAuthorization/PushBasedAuthorizationRequestTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/PushedAuthorization/PushBasedAuthorizationRequestTests.cs index 81266282..af3e013a 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/PushedAuthorization/PushBasedAuthorizationRequestTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/PushedAuthorization/PushBasedAuthorizationRequestTests.cs @@ -120,6 +120,7 @@ public async Task post_request_should_return_201() 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] = "api1 api2", 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 index c0e88116..b8236b53 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs @@ -9,13 +9,13 @@ using AwesomeAssertions; using IdentityServer.UnitTests.Common; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; 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.Validation; using Xunit; @@ -27,12 +27,23 @@ 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 MockHttpContextAccessor mockHttpContext = 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() + }); + } + [Fact] public async Task ProcessAsync_should_log_start_processing() { @@ -58,19 +69,47 @@ public async Task ProcessAsync_should_log_end_processing() 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", false)] - [InlineData("PUT", false)] - [InlineData("DELETE", false)] - [InlineData("PATCH", false)] - [InlineData("POST", true)] - public async Task ProcessAsync_should_only_support_http_verb_POST(string verb, bool isSupported) + [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); - ResultShouldBeStatusCodeOf(result, !isSupported ? HttpStatusCode.MethodNotAllowed : HttpStatusCode.BadRequest); + ResultShouldBeTokenErrorResult(result, OidcConstants.TokenErrors.InvalidRequest); + + clientSecretValidator.Verify(csv => csv.ValidateAsync(context), Times.Never); + } + + [Fact] + public async Task ProcessAsync_should_will_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] @@ -80,7 +119,7 @@ public async Task ProcessAsync_should_return_bad_request_when_no_form_body_in_re var context = CreateHttpContext(); IEndpointResult result = await sut.ProcessAsync(context); - ResultShouldBeStatusCodeOf(result, HttpStatusCode.BadRequest); + ResultShouldBeTokenErrorResult(result, OidcConstants.TokenErrors.InvalidRequest); } [Fact] @@ -128,7 +167,7 @@ public async Task ProcessAsync_when_called_with_invalid_request_should_return_ba } [Fact] - public async Task ProcessAsync_when_and_PAR_is_disabled_should_return_404() + public async Task ProcessAsync_when_PAR_is_disabled_should_return_404() { options.Endpoints.EnablePushedAuthorizationRequestEndpoint = false; @@ -206,10 +245,17 @@ private static void ResultShouldBeStatusCodeOf(IEndpointResult result , HttpStat .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, logger.Object); From bb560266fe863b6209bf0fd84fbc3a3a86e29785 Mon Sep 17 00:00:00 2001 From: Richard Blewett Date: Tue, 28 Jul 2026 11:32:52 +0100 Subject: [PATCH 10/30] Store abstraction and response generator. Logging outstanding --- .../src/Open.IdentityServer.csproj | 4 + .../PushedAuthorizationResponseGenerator.cs | 94 +++++++ ...shedAuthorizationResponseGeneratorTests.cs | 181 +++++++++++++ .../PushedAuthorizationStoredInformation.cs | 237 ++++++++++++++++++ .../IPushedAuthorizationRequestStore.cs | 29 +++ 5 files changed, 545 insertions(+) create mode 100644 src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs create mode 100644 src/Storage/src/Models/PushedAuthorizationStoredInformation.cs create mode 100644 src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs diff --git a/src/Open.IdentityServer/src/Open.IdentityServer.csproj b/src/Open.IdentityServer/src/Open.IdentityServer.csproj index ef20d737..3c4925cc 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/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs new file mode 100644 index 00000000..cfe73002 --- /dev/null +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs @@ -0,0 +1,94 @@ +using System; +using System.Runtime.InteropServices.ComTypes; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Open.IdentityServer.Services; +using Open.IdentityServer.Storage.Models; +using Open.IdentityServer.Stores; +using Open.IdentityServer.Validation; + +namespace Open.IdentityServer.ResponseHandling.Default; + +#nullable enable + +/// +/// Default implementation of the pushed authorization response generator +/// +/// The store used to save the pushed authorization request for later retrieval +/// The service used to generate the ID for the stored state +/// The logger to record errors and debug inforation +public class PushedAuthorizationResponseGenerator(IPushedAuthorizationRequestStore store, + IHandleGenerationService handleGenerationService, + ILogger logger) : IPushedAuthorizationResponseGenerator +{ + /// + /// Standard prefix for the generated URI for a Pushed Authorization Request + /// + public static readonly string PushedAuthorizationRequestPrefix = "urn:ietf:params:oauth:request_uri:"; + + /// + /// Default lifetime for a Pushed Authorization Request + /// + public static readonly int DefaultRequestLifetimeInSeconds = 60; + + /// + /// Generates the Pushed Authorization Request response + /// + /// The request for which to generate a response + /// The generated response + public async Task CreateResponseAsync(ValidatedAuthorizeRequest request) + { + PushedAuthorizationStoredInformation storeInfo = MapRequestInformation(request); + + string generatedUniquePart = await handleGenerationService.GenerateAsync(); + + string id = PushedAuthorizationRequestPrefix + generatedUniquePart; + + try + { + await store.StorePushedAuthorizationRequestAsync(id, storeInfo); + + return new PushedAuthorizationResponse(new Uri(id), DefaultRequestLifetimeInSeconds); + } + catch (Exception e) + { + return null; + } + + } + + private PushedAuthorizationStoredInformation MapRequestInformation(ValidatedAuthorizeRequest request) + { + return new PushedAuthorizationStoredInformation + { + AccessTokenLifetime = request.AccessTokenLifetime, + ClientId = request.ClientId, + ClientSecretVerified = request.Secret != null, // If the secret is passed then it will already have been validated + CodeChallenge = request.CodeChallenge, + CodeChallengeMethod = request.CodeChallengeMethod, + RedirectUri = request.RedirectUri, + RequestedScopes = request.RequestedScopes, + Subject = request.Subject, + Confirmation = request.Confirmation, + Description = request.Description, + DisplayMode = request.DisplayMode, + GrantType = request.GrantType, + IsApiResourceRequest = request.IsApiResourceRequest, + IsOpenIdRequest = request.IsOpenIdRequest, + LoginHint = request.LoginHint, + MaxAge = request.MaxAge, + Nonce = request.Nonce, + ResponseMode = request.ResponseMode, + ResponseType = request.ResponseType, + State = request.State, + UiLocales = request.UiLocales, + SessionId = request.SessionId, + WasConsentShown = request.WasConsentShown, + ValidatedResources = request.ValidatedResources.Resources, + RequestedResourceIndicators = request.RequestedResourceIndicators, + AuthenticationContextReferenceClasses = request.AuthenticationContextReferenceClasses, + PromptModes = request.PromptModes, + RequestObjectValues = request.RequestObjectValues, + }; + } +} \ 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 00000000..7a74b93a --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Security.Claims; +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.ResponseHandling.Default; +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 _store = new(); + private readonly Mock _handleGenerationService = new(); + private Mock> _logger = new(); + private ValidatedAuthorizeRequest _request; + + public PushedAuthorizationResponseGeneratorTests() + { + _request = new ValidatedAuthorizeRequest + { + ClientId = "dfui", + GrantType = "code", + Description = "skdvibjsd", + LoginHint = "jdsfbivufe", + IsApiResourceRequest = true, + IsOpenIdRequest = true, + MaxAge = 100, + Nonce = "jdsfbivufe", + RedirectUri = "https://foo.bar", + CodeChallenge = "kdsjfbvj", + CodeChallengeMethod = "skjdehfoub", + DisplayMode = "dfjbvjk", + ResponseMode = "shjdbvhb", + ResponseType = "skjdfhvb", + State = "wrger", + UiLocales = "skjdfhvb", + RequestObject = "kjsbdvkbjj", + WasConsentShown = true, + AccessTokenLifetime = 200, + Client = new(), + Confirmation = "dsfifvbuo", + SessionId = "djcbiud", + Subject = new ClaimsPrincipal(new ClaimsIdentity(new Claim[] { new Claim("sub", "123") })), + AccessTokenType = AccessTokenType.Jwt, + Secret = new ParsedSecret(), + AuthenticationContextReferenceClasses = new List { "hvsdvc", "bsdfu" }, + RequestedResourceIndicators = new List { "efe", "ergberb" }, + RequestedScopes = new List { "yhrehq", "asdgfrh" }, + PromptModes = new List { "aerh", "gfrea" }, + ClientClaims = new List{ new Claim("foo", "bar"), new Claim("baz", "qux") }, + Options = new IdentityServerOptions(), + Raw = new NameValueCollection(), + RequestObjectValues = new Dictionary { ["jdbsv"] = "sdbvbudv" }, + ValidatedResources = new ResourceValidationResult + { + Resources = new Resources + { + OfflineAccess = true, + ApiScopes = [new ApiScope("api")], + } + }, + }; + } + + + private PushedAuthorizationResponseGenerator CreateSut() + { + return new PushedAuthorizationResponseGenerator(_store.Object, _handleGenerationService.Object, _logger.Object); + } + + [Fact] + public async Task CreateResponseAsync_WhenCalled_ShouldMapRequestCorrectlyAndSendToStore() + { + var sut = CreateSut(); + + PushedAuthorizationStoredInformation? storedInfo = null; + + _store.Setup(s => s.StorePushedAuthorizationRequestAsync(It.IsAny(), It.IsAny())) + .Callback((_, info) => storedInfo = info); + + await sut.CreateResponseAsync(_request); + + VerifyPushedAuthorizeRequestMapping(storedInfo, _request); + } + + [Fact] + public async Task CreateResponseAsync_WhenCalled_ShouldConstructUriCorrectlyAndPassToStore() + { + string generatedUniquePart = "sdufbsibdvibv"; + + _handleGenerationService.Setup(g => g.GenerateAsync()).ReturnsAsync(generatedUniquePart); + + var sut = CreateSut(); + + string? passedId = null; + + _store.Setup(s => s.StorePushedAuthorizationRequestAsync(It.IsAny(), It.IsAny())) + .Callback((id, _) => passedId = id); + + await sut.CreateResponseAsync(_request); + + passedId.Should().NotBeNull(); + passedId.Should().Be(PushedAuthorizationResponseGenerator.PushedAuthorizationRequestPrefix + generatedUniquePart); + } + + [Fact] + public async Task CreateResponseAsync_WhenCalled_ShouldGenerateResponseCorrectly() + { + string generatedUniquePart = "sdufbsibdvibv"; + string expectedUri = + $"{PushedAuthorizationResponseGenerator.PushedAuthorizationRequestPrefix}{generatedUniquePart}"; + + _handleGenerationService.Setup(g => g.GenerateAsync()).ReturnsAsync(generatedUniquePart); + + var sut = CreateSut(); + + PushedAuthorizationResponse? response = await sut.CreateResponseAsync(_request); + + response.Should().NotBeNull(); + response.Uri.Should().Be(expectedUri); + response.Lifetime.Should().Be(PushedAuthorizationResponseGenerator.DefaultRequestLifetimeInSeconds); + } + + [Fact] + public async Task CreateResponseAsync_WhenCalledAndStoreThrowsException_ShouldReturnNull() + { + _store.Setup(s => s.StorePushedAuthorizationRequestAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception()); + + var sut = CreateSut(); + + PushedAuthorizationResponse? response = await sut.CreateResponseAsync(_request); + + response.Should().BeNull(); + } + + private void VerifyPushedAuthorizeRequestMapping(PushedAuthorizationStoredInformation? storedInfo, ValidatedAuthorizeRequest request) + { + storedInfo.Should().NotBeNull(); + storedInfo.AccessTokenLifetime.Should().Be(request.AccessTokenLifetime); + storedInfo.RequestObjectValues.Should().BeEquivalentTo(request.RequestObjectValues); + storedInfo.ValidatedResources.ApiScopes.Should().BeEquivalentTo(request.ValidatedResources.Resources.ApiScopes); + storedInfo.ValidatedResources.OfflineAccess.Should().Be(request.ValidatedResources.Resources.OfflineAccess); + storedInfo.CodeChallenge.Should().Be(request.CodeChallenge); + storedInfo.CodeChallengeMethod.Should().Be(request.CodeChallengeMethod); + storedInfo.Nonce.Should().Be(request.Nonce); + storedInfo.RedirectUri.Should().Be(request.RedirectUri); + storedInfo.ClientId.Should().Be(request.ClientId); + storedInfo.Description.Should().Be(request.Description); + storedInfo.DisplayMode.Should().Be(request.DisplayMode); + storedInfo.GrantType.Should().Be(request.GrantType); + storedInfo.IsApiResourceRequest.Should().Be(request.IsApiResourceRequest); + storedInfo.LoginHint.Should().Be(request.LoginHint); + storedInfo.ResponseMode.Should().Be(request.ResponseMode); + storedInfo.Subject.Should().Be(request.Subject); + storedInfo.SessionId.Should().Be(request.SessionId); + storedInfo.WasConsentShown.Should().Be(request.WasConsentShown); + storedInfo.IsOpenIdRequest.Should().Be(request.IsOpenIdRequest); + storedInfo.ClientSecretVerified.Should().Be(request.Secret != null); + storedInfo.AuthenticationContextReferenceClasses.Should().BeEquivalentTo(request.AuthenticationContextReferenceClasses); + storedInfo.PromptModes.Should().BeEquivalentTo(request.PromptModes); + storedInfo.RequestedResourceIndicators.Should().BeEquivalentTo(request.RequestedResourceIndicators); + storedInfo.RequestedScopes.Should().BeEquivalentTo(request.RequestedScopes); + storedInfo.ResponseType.Should().Be(request.ResponseType); + storedInfo.State.Should().Be(request.State); + storedInfo.MaxAge.Should().Be(request.MaxAge); + storedInfo.UiLocales.Should().BeEquivalentTo(request.UiLocales); + } +} \ No newline at end of file diff --git a/src/Storage/src/Models/PushedAuthorizationStoredInformation.cs b/src/Storage/src/Models/PushedAuthorizationStoredInformation.cs new file mode 100644 index 00000000..7bec2629 --- /dev/null +++ b/src/Storage/src/Models/PushedAuthorizationStoredInformation.cs @@ -0,0 +1,237 @@ +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using Open.IdentityServer.Models; + +namespace Open.IdentityServer.Storage.Models; + +/// +/// Used to serialize the data necessary to store a pushed authorization request. +/// +public class PushedAuthorizationStoredInformation +{ + /// + /// Gets or sets whether the secret used to authenticate the client has been correctly verified. + /// False if no secret was supplied + /// + /// + /// The parsed secret. + /// + public bool ClientSecretVerified { get; set; } + + /// + /// Gets or sets the effective access token lifetime for the current request. + /// This value is initally read from the client configuration but can be modified in the request pipeline + /// + public int AccessTokenLifetime { get; set; } + + /// + /// Gets or sets the subject. + /// + /// + /// The subject. + /// + public ClaimsPrincipal Subject { get; set; } + + /// + /// Gets or sets the session identifier. + /// + /// + /// The session identifier. + /// + public string SessionId { get; set; } + + /// + /// Gets or sets the validated resources for the request. + /// + /// + /// The validated resources. + /// + public Resources ValidatedResources { get; set; } + + /// + /// Gets or sets the value of the confirmation method (will become the cnf claim). Must be a JSON object. + /// + /// + /// The confirmation. + /// + public string Confirmation { get; set; } + + /// + /// Gets or sets the client ID that should be used for the current request (this is useful for token exchange scenarios) + /// + /// + /// The client ID + /// + public string ClientId { get; set; } + + /// + /// Gets or sets the type of the response. + /// + /// + /// The type of the response. + /// + public string ResponseType { get; set; } + + /// + /// Gets or sets the response mode. + /// + /// + /// The response mode. + /// + public string ResponseMode { get; set; } + + /// + /// Gets or sets the grant type. + /// + /// + /// The grant type. + /// + public string GrantType { get; set; } + + /// + /// Gets or sets the redirect URI. + /// + /// + /// The redirect URI. + /// + public string RedirectUri { get; set; } + + /// + /// Gets or sets the requested scopes. + /// + /// + /// The requested scopes. + /// + // todo: consider replacing with extension method to access Raw collection; would neeed to be done wholesale for all props. + public List RequestedScopes { get; set; } + + /// + /// Gets or sets the requested resource indicators + /// + /// + /// The request resource indicators + /// + public List RequestedResourceIndicators { get; set; } + + /// + /// Gets or sets a value indicating whether consent was shown. + /// + /// + /// true if consent was shown; otherwise, false. + /// + public bool WasConsentShown { get; set; } + + /// + /// Gets the description the user assigned to the device being authorized. + /// + /// + /// The description. + /// + public string Description { get; set; } + + /// + /// Gets or sets the state. + /// + /// + /// The state. + /// + public string State { get; set; } + + /// + /// Gets or sets the UI locales. + /// + /// + /// The UI locales. + /// + public string UiLocales { get; set; } + + /// + /// Gets or sets a value indicating whether the request was an OpenID Connect request. + /// + /// + /// true if the request was an OpenID Connect request; otherwise, false. + /// + public bool IsOpenIdRequest { get; set; } + + /// + /// Gets or sets a value indicating whether this instance is API resource request. + /// + /// + /// true if this instance is API resource request; otherwise, false. + /// + public bool IsApiResourceRequest { get; set; } + + /// + /// Gets or sets the nonce. + /// + /// + /// The nonce. + /// + public string Nonce { get; set; } + + /// + /// Gets or sets the authentication context reference classes. + /// + /// + /// The authentication context reference classes. + /// + public List AuthenticationContextReferenceClasses { get; set; } + + /// + /// Gets or sets the display mode. + /// + /// + /// The display mode. + /// + public string DisplayMode { get; set; } + + /// + /// Gets or sets the collection of prompt modes. + /// + /// + /// The collection of prompt modes. + /// + public IEnumerable PromptModes { get; set; } = Enumerable.Empty(); + + /// + /// Gets or sets the maximum age. + /// + /// + /// The maximum age. + /// + public int? MaxAge { get; set; } + + /// + /// Gets or sets the login hint. + /// + /// + /// The login hint. + /// + public string LoginHint { get; set; } + + /// + /// Gets or sets the code challenge + /// + /// + /// The code challenge + /// + public string CodeChallenge { get; set; } + + /// + /// Gets or sets the code challenge method + /// + /// + /// The code challenge method + /// + public string CodeChallengeMethod { get; set; } + + /// + /// Gets or sets the validated contents of the request object (if present) + /// + /// + /// The request object values + /// + public Dictionary RequestObjectValues { get; set; } = new (); + +} \ No newline at end of file diff --git a/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs b/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs new file mode 100644 index 00000000..50781fb1 --- /dev/null +++ b/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs @@ -0,0 +1,29 @@ +using System.Threading.Tasks; +using Open.IdentityServer.Models; +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 request id to act as the key for the storage + /// The pushed authorization request information to store + /// A task indicating the async lifetime of the method + Task StorePushedAuthorizationRequestAsync(string id, PushedAuthorizationStoredInformation 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 From 14b3cd983f3cdc282b3ee4202461dfdaab190b8d Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Fri, 28 Aug 2026 14:21:22 +0100 Subject: [PATCH 11/30] Midway through wiring up the Authorization flow with request_uri. Need to re-think the storage of the PAR request so its flat and use native types so that a store can easily serialize --- .../DependencyInjection/BuilderExtensions/Core.cs | 3 ++- .../DependencyInjection/BuilderExtensions/InMemory.cs | 2 +- .../src/Endpoints/AuthorizeEndpointBase.cs | 5 +++++ .../src/IdentityServerConstants.cs | 8 ++++++++ .../Default/PushedAuthorizationResponseGenerator.cs | 11 +++-------- .../Models/PushedAuthorizationResponse.cs | 4 ++-- .../Models/AuthorizeRequestValidationResult.cs | 10 ++++++++++ .../Validation/Models/ValidatedAuthorizeRequest.cs | 1 + .../PushedAuthorizationResponseGeneratorTests.cs | 5 ++--- 9 files changed, 34 insertions(+), 15 deletions(-) diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs index 18951203..42e73dd0 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs @@ -241,7 +241,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 c12840c4..5b7106dc 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/Endpoints/AuthorizeEndpointBase.cs b/src/Open.IdentityServer/src/Endpoints/AuthorizeEndpointBase.cs index 9e795e04..9e290d0b 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/IdentityServerConstants.cs b/src/Open.IdentityServer/src/IdentityServerConstants.cs index 7d10fdfd..3bbb6dd0 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/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs index cfe73002..a361bfe2 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs @@ -7,7 +7,7 @@ using Open.IdentityServer.Stores; using Open.IdentityServer.Validation; -namespace Open.IdentityServer.ResponseHandling.Default; +namespace Open.IdentityServer.ResponseHandling; #nullable enable @@ -21,12 +21,7 @@ public class PushedAuthorizationResponseGenerator(IPushedAuthorizationRequestSto IHandleGenerationService handleGenerationService, ILogger logger) : IPushedAuthorizationResponseGenerator { - /// - /// Standard prefix for the generated URI for a Pushed Authorization Request - /// - public static readonly string PushedAuthorizationRequestPrefix = "urn:ietf:params:oauth:request_uri:"; - - /// + /// /// Default lifetime for a Pushed Authorization Request /// public static readonly int DefaultRequestLifetimeInSeconds = 60; @@ -42,7 +37,7 @@ public class PushedAuthorizationResponseGenerator(IPushedAuthorizationRequestSto string generatedUniquePart = await handleGenerationService.GenerateAsync(); - string id = PushedAuthorizationRequestPrefix + generatedUniquePart; + string id = IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix + generatedUniquePart; try { diff --git a/src/Open.IdentityServer/src/ResponseHandling/Models/PushedAuthorizationResponse.cs b/src/Open.IdentityServer/src/ResponseHandling/Models/PushedAuthorizationResponse.cs index db4604a9..80584154 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Models/PushedAuthorizationResponse.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Models/PushedAuthorizationResponse.cs @@ -13,12 +13,12 @@ public class PushedAuthorizationResponse(Uri uri , long lifetime) /// /// The URN to send to the authorization endpoint to obtain the authcode, instead of parametes /// - [JsonPropertyName("request_uri")] + [JsonPropertyName(OidcConstants.AuthorizeRequest.RequestUri)] public string Uri { get; } = uri.ToString(); /// /// The lifetime in seconds of the URN /// - [JsonPropertyName("expires_in")] + [JsonPropertyName(OidcConstants.AuthorizeResponse.ExpiresIn)] public long Lifetime { get; } = lifetime; } \ 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 e542871e..2ca96076 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/ValidatedAuthorizeRequest.cs b/src/Open.IdentityServer/src/Validation/Models/ValidatedAuthorizeRequest.cs index 6f714f4d..d399650d 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.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs index 7a74b93a..0a1136e4 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs @@ -9,7 +9,6 @@ using Open.IdentityServer.Configuration; using Open.IdentityServer.Models; using Open.IdentityServer.ResponseHandling; -using Open.IdentityServer.ResponseHandling.Default; using Open.IdentityServer.Services; using Open.IdentityServer.Storage.Models; using Open.IdentityServer.Stores; @@ -112,7 +111,7 @@ public async Task CreateResponseAsync_WhenCalled_ShouldConstructUriCorrectlyAndP await sut.CreateResponseAsync(_request); passedId.Should().NotBeNull(); - passedId.Should().Be(PushedAuthorizationResponseGenerator.PushedAuthorizationRequestPrefix + generatedUniquePart); + passedId.Should().Be(IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix + generatedUniquePart); } [Fact] @@ -120,7 +119,7 @@ public async Task CreateResponseAsync_WhenCalled_ShouldGenerateResponseCorrectly { string generatedUniquePart = "sdufbsibdvibv"; string expectedUri = - $"{PushedAuthorizationResponseGenerator.PushedAuthorizationRequestPrefix}{generatedUniquePart}"; + $"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}{generatedUniquePart}"; _handleGenerationService.Setup(g => g.GenerateAsync()).ReturnsAsync(generatedUniquePart); From 50ddc566df27963d44a306b2a71d12b9b96cda92 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Fri, 28 Aug 2026 19:36:18 +0100 Subject: [PATCH 12/30] Refactored to store NameValueColllection rather than trying to save a ValidatedAuthorizationRequest --- .../BuilderExtensions/Core.cs | 4 + .../Options/IdentityServerOptions.cs | 2 +- .../Options/PushedAuthorizationOptions.cs | 19 ++ .../Default/DiscoveryResponseGenerator.cs | 2 +- .../PushedAuthorizationResponseGenerator.cs | 58 ++--- .../DiscoveryResponseGeneratorTests.cs | 2 +- ...shedAuthorizationResponseGeneratorTests.cs | 171 +++++++------ .../src/Models/PushedAuthorizationMemento.cs | 11 + .../PushedAuthorizationStoredInformation.cs | 237 ------------------ .../IPushedAuthorizationRequestStore.cs | 4 +- 10 files changed, 144 insertions(+), 366 deletions(-) create mode 100644 src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/PushedAuthorizationOptions.cs create mode 100644 src/Storage/src/Models/PushedAuthorizationMemento.cs delete mode 100644 src/Storage/src/Models/PushedAuthorizationStoredInformation.cs diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs index 42e73dd0..053f4d95 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs @@ -223,6 +223,10 @@ public static IIdentityServerBuilder AddValidators(this IIdentityServerBuilder b builder.Services.TryAddTransient(); builder.Services.TryAddTransient(); + // PAR support + builder.Services + .AddTransientDecorator(); + return builder; } diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs index 11d1f05f..2404a402 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs @@ -145,5 +145,5 @@ public class IdentityServerOptions /// /// Enforce PAR for all authorization code flow requests /// - public bool RequirePushedAuthorization { get; set; } = false; + 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 00000000..d5bed800 --- /dev/null +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/PushedAuthorizationOptions.cs @@ -0,0 +1,19 @@ +using System; + +namespace Open.IdentityServer.Configuration; + +/// +/// PAR options +/// +public class PushedAuthorizationOptions +{ + /// + /// Enforce PAR for all authorization code flow 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/ResponseHandling/Default/DiscoveryResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs index c4241bd6..1d1f4911 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs @@ -219,7 +219,7 @@ string ConstructMtlsEndpoint(string endpoint) { entries.Add( OidcConstants.Discovery.RequirePushedAuthorizationRequests, - Options.RequirePushedAuthorization + Options.PushedAuthorization.Required ); } diff --git a/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs index a361bfe2..63b1ad62 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs @@ -2,6 +2,7 @@ using System.Runtime.InteropServices.ComTypes; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Open.IdentityServer.Configuration; using Open.IdentityServer.Services; using Open.IdentityServer.Storage.Models; using Open.IdentityServer.Stores; @@ -16,9 +17,13 @@ namespace Open.IdentityServer.ResponseHandling; /// /// The store used to save the pushed authorization request for later retrieval /// The service used to generate the ID for the stored state +/// Used to calculate absolute expiration +/// Used to calculate expiration /// The logger to record errors and debug inforation public class PushedAuthorizationResponseGenerator(IPushedAuthorizationRequestStore store, IHandleGenerationService handleGenerationService, + TimeProvider clock, + IdentityServerOptions options, ILogger logger) : IPushedAuthorizationResponseGenerator { /// @@ -33,57 +38,36 @@ public class PushedAuthorizationResponseGenerator(IPushedAuthorizationRequestSto /// The generated response public async Task CreateResponseAsync(ValidatedAuthorizeRequest request) { - PushedAuthorizationStoredInformation storeInfo = MapRequestInformation(request); - string generatedUniquePart = await handleGenerationService.GenerateAsync(); string id = IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix + generatedUniquePart; + TimeSpan validFor = RequestValidFor(request); + DateTimeOffset validUntil = clock.GetUtcNow().Add(validFor); + + var storeInfo = new PushedAuthorizationMemento(id, validUntil, request.Raw); + try { await store.StorePushedAuthorizationRequestAsync(id, storeInfo); - - return new PushedAuthorizationResponse(new Uri(id), DefaultRequestLifetimeInSeconds); + + return new PushedAuthorizationResponse(new Uri(id), (int)validFor.TotalSeconds); } - catch (Exception e) + catch (Exception) { return null; } - + } - private PushedAuthorizationStoredInformation MapRequestInformation(ValidatedAuthorizeRequest request) + private TimeSpan RequestValidFor(ValidatedAuthorizeRequest request) { - return new PushedAuthorizationStoredInformation + TimeSpan duration = options.PushedAuthorization.Expiration; + if (request.Client?.PushedAuthorizationLifetime != null) { - AccessTokenLifetime = request.AccessTokenLifetime, - ClientId = request.ClientId, - ClientSecretVerified = request.Secret != null, // If the secret is passed then it will already have been validated - CodeChallenge = request.CodeChallenge, - CodeChallengeMethod = request.CodeChallengeMethod, - RedirectUri = request.RedirectUri, - RequestedScopes = request.RequestedScopes, - Subject = request.Subject, - Confirmation = request.Confirmation, - Description = request.Description, - DisplayMode = request.DisplayMode, - GrantType = request.GrantType, - IsApiResourceRequest = request.IsApiResourceRequest, - IsOpenIdRequest = request.IsOpenIdRequest, - LoginHint = request.LoginHint, - MaxAge = request.MaxAge, - Nonce = request.Nonce, - ResponseMode = request.ResponseMode, - ResponseType = request.ResponseType, - State = request.State, - UiLocales = request.UiLocales, - SessionId = request.SessionId, - WasConsentShown = request.WasConsentShown, - ValidatedResources = request.ValidatedResources.Resources, - RequestedResourceIndicators = request.RequestedResourceIndicators, - AuthenticationContextReferenceClasses = request.AuthenticationContextReferenceClasses, - PromptModes = request.PromptModes, - RequestObjectValues = request.RequestObjectValues, - }; + duration = TimeSpan.FromSeconds((int)request.Client.PushedAuthorizationLifetime); + } + + return duration; } } \ 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 3fb0e01f..2694cb29 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 @@ -795,7 +795,7 @@ public async Task CreateDiscoveryDocumentAsync_WhenParIsEnforcedOrNot_ShouldCont { var sut = CreateSut(); Options.Endpoints.EnablePushedAuthorizationRequestEndpoint = true; - Options.RequirePushedAuthorization = isEnforced; + Options.PushedAuthorization.Required = isEnforced; var actual = await sut.CreateDiscoveryDocumentAsync("https://open.ids.url/somepath", "https://open.ids.url"); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs index 0a1136e4..18c0d9a5 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs @@ -23,60 +23,14 @@ public class PushedAuthorizationResponseGeneratorTests private readonly Mock _store = new(); private readonly Mock _handleGenerationService = new(); private Mock> _logger = new(); - private ValidatedAuthorizeRequest _request; + private Mock clock = new Mock(); + private IdentityServerOptions options = new IdentityServerOptions(); + + private ValidatedAuthorizeRequest _request = new ValidatedAuthorizeRequest() { Raw = new NameValueCollection() }; public PushedAuthorizationResponseGeneratorTests() { - _request = new ValidatedAuthorizeRequest - { - ClientId = "dfui", - GrantType = "code", - Description = "skdvibjsd", - LoginHint = "jdsfbivufe", - IsApiResourceRequest = true, - IsOpenIdRequest = true, - MaxAge = 100, - Nonce = "jdsfbivufe", - RedirectUri = "https://foo.bar", - CodeChallenge = "kdsjfbvj", - CodeChallengeMethod = "skjdehfoub", - DisplayMode = "dfjbvjk", - ResponseMode = "shjdbvhb", - ResponseType = "skjdfhvb", - State = "wrger", - UiLocales = "skjdfhvb", - RequestObject = "kjsbdvkbjj", - WasConsentShown = true, - AccessTokenLifetime = 200, - Client = new(), - Confirmation = "dsfifvbuo", - SessionId = "djcbiud", - Subject = new ClaimsPrincipal(new ClaimsIdentity(new Claim[] { new Claim("sub", "123") })), - AccessTokenType = AccessTokenType.Jwt, - Secret = new ParsedSecret(), - AuthenticationContextReferenceClasses = new List { "hvsdvc", "bsdfu" }, - RequestedResourceIndicators = new List { "efe", "ergberb" }, - RequestedScopes = new List { "yhrehq", "asdgfrh" }, - PromptModes = new List { "aerh", "gfrea" }, - ClientClaims = new List{ new Claim("foo", "bar"), new Claim("baz", "qux") }, - Options = new IdentityServerOptions(), - Raw = new NameValueCollection(), - RequestObjectValues = new Dictionary { ["jdbsv"] = "sdbvbudv" }, - ValidatedResources = new ResourceValidationResult - { - Resources = new Resources - { - OfflineAccess = true, - ApiScopes = [new ApiScope("api")], - } - }, - }; - } - - - private PushedAuthorizationResponseGenerator CreateSut() - { - return new PushedAuthorizationResponseGenerator(_store.Object, _handleGenerationService.Object, _logger.Object); + } [Fact] @@ -84,10 +38,10 @@ public async Task CreateResponseAsync_WhenCalled_ShouldMapRequestCorrectlyAndSen { var sut = CreateSut(); - PushedAuthorizationStoredInformation? storedInfo = null; + PushedAuthorizationMemento? storedInfo = null; - _store.Setup(s => s.StorePushedAuthorizationRequestAsync(It.IsAny(), It.IsAny())) - .Callback((_, info) => storedInfo = info); + _store.Setup(s => s.StorePushedAuthorizationRequestAsync(It.IsAny(), It.IsAny())) + .Callback((_, info) => storedInfo = info); await sut.CreateResponseAsync(_request); @@ -105,14 +59,74 @@ public async Task CreateResponseAsync_WhenCalled_ShouldConstructUriCorrectlyAndP string? passedId = null; - _store.Setup(s => s.StorePushedAuthorizationRequestAsync(It.IsAny(), It.IsAny())) - .Callback((id, _) => passedId = id); + _store.Setup(s => s.StorePushedAuthorizationRequestAsync(It.IsAny(), It.IsAny())) + .Callback((id, _) => passedId = id); await sut.CreateResponseAsync(_request); passedId.Should().NotBeNull(); passedId.Should().Be(IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix + generatedUniquePart); } + + [Fact] + public async Task CreateResponseAsync_WhenCalled_ShouldSetExpirationBasedOnOptions() + { + string generatedUniquePart = "sdufbsibdvibv"; + + _request.Client = new Client(); + + DateTime expectedExpiration; + DateTime now = new DateTime(2027, 3, 2, 13, 10, 20); + DateTimeOffset spiedExpiration = now; + + options.PushedAuthorization.Expiration = TimeSpan.FromSeconds(90); + + clock.Setup(c => c.GetUtcNow()).Returns(now); + expectedExpiration = now.Add(options.PushedAuthorization.Expiration); + + _handleGenerationService.Setup(g => g.GenerateAsync()).ReturnsAsync(generatedUniquePart); + + var sut = CreateSut(); + + _store.Setup(s => s.StorePushedAuthorizationRequestAsync(It.IsAny(), It.IsAny())) + .Callback((id, memento) => spiedExpiration = memento.ValidUntil); + + await sut.CreateResponseAsync(_request); + + spiedExpiration.Should().Be(expectedExpiration); + } + + [Fact] + public async Task CreateResponseAsync_WhenCalled_ShouldSetExpirationBasedOnClientProperties() + { + string generatedUniquePart = "sdufbsibdvibv"; + + _request.Client = new Client() + { + PushedAuthorizationLifetime = TimeSpan.FromSeconds(30).Seconds + }; + + DateTime expectedExpiration; + DateTime now = new DateTime(2027, 3, 2, 13, 10, 20); + DateTimeOffset spiedExpiration = now; + + clock.Setup(c => c.GetUtcNow()).Returns(now); + int expectedDuration = (int)_request.Client.PushedAuthorizationLifetime; + expectedExpiration = now.AddSeconds(expectedDuration); + + _handleGenerationService.Setup(g => g.GenerateAsync()).ReturnsAsync(generatedUniquePart); + + var sut = CreateSut(); + + _store.Setup(s => s.StorePushedAuthorizationRequestAsync(It.IsAny(), It.IsAny())) + .Callback((id, memento) => spiedExpiration = memento.ValidUntil); + + var response = await sut.CreateResponseAsync(_request); + + response.Should().NotBeNull(); + spiedExpiration.Should().Be(expectedExpiration); + response.Lifetime.Should().Be(expectedDuration); + } [Fact] public async Task CreateResponseAsync_WhenCalled_ShouldGenerateResponseCorrectly() @@ -135,7 +149,7 @@ public async Task CreateResponseAsync_WhenCalled_ShouldGenerateResponseCorrectly [Fact] public async Task CreateResponseAsync_WhenCalledAndStoreThrowsException_ShouldReturnNull() { - _store.Setup(s => s.StorePushedAuthorizationRequestAsync(It.IsAny(), It.IsAny())) + _store.Setup(s => s.StorePushedAuthorizationRequestAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new Exception()); var sut = CreateSut(); @@ -144,37 +158,20 @@ public async Task CreateResponseAsync_WhenCalledAndStoreThrowsException_ShouldRe response.Should().BeNull(); } - - private void VerifyPushedAuthorizeRequestMapping(PushedAuthorizationStoredInformation? storedInfo, ValidatedAuthorizeRequest request) + + private void VerifyPushedAuthorizeRequestMapping(PushedAuthorizationMemento? storedInfo, ValidatedAuthorizeRequest request) { storedInfo.Should().NotBeNull(); - storedInfo.AccessTokenLifetime.Should().Be(request.AccessTokenLifetime); - storedInfo.RequestObjectValues.Should().BeEquivalentTo(request.RequestObjectValues); - storedInfo.ValidatedResources.ApiScopes.Should().BeEquivalentTo(request.ValidatedResources.Resources.ApiScopes); - storedInfo.ValidatedResources.OfflineAccess.Should().Be(request.ValidatedResources.Resources.OfflineAccess); - storedInfo.CodeChallenge.Should().Be(request.CodeChallenge); - storedInfo.CodeChallengeMethod.Should().Be(request.CodeChallengeMethod); - storedInfo.Nonce.Should().Be(request.Nonce); - storedInfo.RedirectUri.Should().Be(request.RedirectUri); - storedInfo.ClientId.Should().Be(request.ClientId); - storedInfo.Description.Should().Be(request.Description); - storedInfo.DisplayMode.Should().Be(request.DisplayMode); - storedInfo.GrantType.Should().Be(request.GrantType); - storedInfo.IsApiResourceRequest.Should().Be(request.IsApiResourceRequest); - storedInfo.LoginHint.Should().Be(request.LoginHint); - storedInfo.ResponseMode.Should().Be(request.ResponseMode); - storedInfo.Subject.Should().Be(request.Subject); - storedInfo.SessionId.Should().Be(request.SessionId); - storedInfo.WasConsentShown.Should().Be(request.WasConsentShown); - storedInfo.IsOpenIdRequest.Should().Be(request.IsOpenIdRequest); - storedInfo.ClientSecretVerified.Should().Be(request.Secret != null); - storedInfo.AuthenticationContextReferenceClasses.Should().BeEquivalentTo(request.AuthenticationContextReferenceClasses); - storedInfo.PromptModes.Should().BeEquivalentTo(request.PromptModes); - storedInfo.RequestedResourceIndicators.Should().BeEquivalentTo(request.RequestedResourceIndicators); - storedInfo.RequestedScopes.Should().BeEquivalentTo(request.RequestedScopes); - storedInfo.ResponseType.Should().Be(request.ResponseType); - storedInfo.State.Should().Be(request.State); - storedInfo.MaxAge.Should().Be(request.MaxAge); - storedInfo.UiLocales.Should().BeEquivalentTo(request.UiLocales); + storedInfo.Parameters.Should().BeEquivalentTo(request.Raw); + storedInfo.Key.Should().NotBeEmpty(); + } + + private PushedAuthorizationResponseGenerator CreateSut() + { + return new PushedAuthorizationResponseGenerator(_store.Object, + _handleGenerationService.Object, + clock.Object, + options, + _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 00000000..660b3fe8 --- /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/Models/PushedAuthorizationStoredInformation.cs b/src/Storage/src/Models/PushedAuthorizationStoredInformation.cs deleted file mode 100644 index 7bec2629..00000000 --- a/src/Storage/src/Models/PushedAuthorizationStoredInformation.cs +++ /dev/null @@ -1,237 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Security.Claims; -using Open.IdentityServer.Models; - -namespace Open.IdentityServer.Storage.Models; - -/// -/// Used to serialize the data necessary to store a pushed authorization request. -/// -public class PushedAuthorizationStoredInformation -{ - /// - /// Gets or sets whether the secret used to authenticate the client has been correctly verified. - /// False if no secret was supplied - /// - /// - /// The parsed secret. - /// - public bool ClientSecretVerified { get; set; } - - /// - /// Gets or sets the effective access token lifetime for the current request. - /// This value is initally read from the client configuration but can be modified in the request pipeline - /// - public int AccessTokenLifetime { get; set; } - - /// - /// Gets or sets the subject. - /// - /// - /// The subject. - /// - public ClaimsPrincipal Subject { get; set; } - - /// - /// Gets or sets the session identifier. - /// - /// - /// The session identifier. - /// - public string SessionId { get; set; } - - /// - /// Gets or sets the validated resources for the request. - /// - /// - /// The validated resources. - /// - public Resources ValidatedResources { get; set; } - - /// - /// Gets or sets the value of the confirmation method (will become the cnf claim). Must be a JSON object. - /// - /// - /// The confirmation. - /// - public string Confirmation { get; set; } - - /// - /// Gets or sets the client ID that should be used for the current request (this is useful for token exchange scenarios) - /// - /// - /// The client ID - /// - public string ClientId { get; set; } - - /// - /// Gets or sets the type of the response. - /// - /// - /// The type of the response. - /// - public string ResponseType { get; set; } - - /// - /// Gets or sets the response mode. - /// - /// - /// The response mode. - /// - public string ResponseMode { get; set; } - - /// - /// Gets or sets the grant type. - /// - /// - /// The grant type. - /// - public string GrantType { get; set; } - - /// - /// Gets or sets the redirect URI. - /// - /// - /// The redirect URI. - /// - public string RedirectUri { get; set; } - - /// - /// Gets or sets the requested scopes. - /// - /// - /// The requested scopes. - /// - // todo: consider replacing with extension method to access Raw collection; would neeed to be done wholesale for all props. - public List RequestedScopes { get; set; } - - /// - /// Gets or sets the requested resource indicators - /// - /// - /// The request resource indicators - /// - public List RequestedResourceIndicators { get; set; } - - /// - /// Gets or sets a value indicating whether consent was shown. - /// - /// - /// true if consent was shown; otherwise, false. - /// - public bool WasConsentShown { get; set; } - - /// - /// Gets the description the user assigned to the device being authorized. - /// - /// - /// The description. - /// - public string Description { get; set; } - - /// - /// Gets or sets the state. - /// - /// - /// The state. - /// - public string State { get; set; } - - /// - /// Gets or sets the UI locales. - /// - /// - /// The UI locales. - /// - public string UiLocales { get; set; } - - /// - /// Gets or sets a value indicating whether the request was an OpenID Connect request. - /// - /// - /// true if the request was an OpenID Connect request; otherwise, false. - /// - public bool IsOpenIdRequest { get; set; } - - /// - /// Gets or sets a value indicating whether this instance is API resource request. - /// - /// - /// true if this instance is API resource request; otherwise, false. - /// - public bool IsApiResourceRequest { get; set; } - - /// - /// Gets or sets the nonce. - /// - /// - /// The nonce. - /// - public string Nonce { get; set; } - - /// - /// Gets or sets the authentication context reference classes. - /// - /// - /// The authentication context reference classes. - /// - public List AuthenticationContextReferenceClasses { get; set; } - - /// - /// Gets or sets the display mode. - /// - /// - /// The display mode. - /// - public string DisplayMode { get; set; } - - /// - /// Gets or sets the collection of prompt modes. - /// - /// - /// The collection of prompt modes. - /// - public IEnumerable PromptModes { get; set; } = Enumerable.Empty(); - - /// - /// Gets or sets the maximum age. - /// - /// - /// The maximum age. - /// - public int? MaxAge { get; set; } - - /// - /// Gets or sets the login hint. - /// - /// - /// The login hint. - /// - public string LoginHint { get; set; } - - /// - /// Gets or sets the code challenge - /// - /// - /// The code challenge - /// - public string CodeChallenge { get; set; } - - /// - /// Gets or sets the code challenge method - /// - /// - /// The code challenge method - /// - public string CodeChallengeMethod { get; set; } - - /// - /// Gets or sets the validated contents of the request object (if present) - /// - /// - /// The request object values - /// - public Dictionary RequestObjectValues { get; set; } = new (); - -} \ No newline at end of file diff --git a/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs b/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs index 50781fb1..c6998807 100644 --- a/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs +++ b/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs @@ -17,7 +17,7 @@ public interface IPushedAuthorizationRequestStore /// The request id to act as the key for the storage /// The pushed authorization request information to store /// A task indicating the async lifetime of the method - Task StorePushedAuthorizationRequestAsync(string id, PushedAuthorizationStoredInformation requestInformation); + Task StorePushedAuthorizationRequestAsync(string id, PushedAuthorizationMemento requestInformation); /// @@ -25,5 +25,5 @@ public interface IPushedAuthorizationRequestStore /// /// 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); + Task ConsumePushedAuthorizationRequestAsync(string id); } \ No newline at end of file From 4d0c28887f7e8678da7e93f227f7bc6e5282935e Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Mon, 31 Aug 2026 19:19:12 +0100 Subject: [PATCH 13/30] First end to end implementation done. still requires logging to be added --- .../Open.IdentityServer.AspNetIdentity.csproj | 3 +- .../BuilderExtensions/Core.cs | 2 +- .../PushedAuthorizationResponseGenerator.cs | 4 +- .../Common/IdentityServerPipeline.cs | 14 +- .../PushBasedAuthorizationRequestTests.cs | 121 ++++++++++++++---- ...shedAuthorizationResponseGeneratorTests.cs | 18 +-- .../IPushedAuthorizationRequestStore.cs | 3 +- 7 files changed, 126 insertions(+), 39 deletions(-) diff --git a/src/AspNetIdentity/src/Open.IdentityServer.AspNetIdentity.csproj b/src/AspNetIdentity/src/Open.IdentityServer.AspNetIdentity.csproj index 8b1b7c51..ea0f6199 100644 --- a/src/AspNetIdentity/src/Open.IdentityServer.AspNetIdentity.csproj +++ b/src/AspNetIdentity/src/Open.IdentityServer.AspNetIdentity.csproj @@ -41,7 +41,8 @@ - + + diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs index 053f4d95..3d1f30f6 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs @@ -226,7 +226,7 @@ public static IIdentityServerBuilder AddValidators(this IIdentityServerBuilder b // PAR support builder.Services .AddTransientDecorator(); - + // return builder; } diff --git a/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs index 63b1ad62..e65b24b6 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs @@ -45,11 +45,11 @@ public class PushedAuthorizationResponseGenerator(IPushedAuthorizationRequestSto TimeSpan validFor = RequestValidFor(request); DateTimeOffset validUntil = clock.GetUtcNow().Add(validFor); - var storeInfo = new PushedAuthorizationMemento(id, validUntil, request.Raw); + var memento = new PushedAuthorizationMemento(id, validUntil, request.Raw); try { - await store.StorePushedAuthorizationRequestAsync(id, storeInfo); + await store.StorePushedAuthorizationRequestAsync(memento); return new PushedAuthorizationResponse(new Uri(id), (int)validFor.TotalSeconds); } 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 a9c65ce5..86f795fa 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs @@ -339,7 +339,7 @@ public string CreateAuthorizeUrl( { var url = new RequestUrl(AuthorizeEndpoint).CreateAuthorizeUrl( clientId: clientId, - responseType: responseType, + responseType: responseType ?? "", scope: scope, redirectUri: redirectUri, state: state, @@ -393,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 index af3e013a..e1f1f2a5 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/PushedAuthorization/PushBasedAuthorizationRequestTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/PushedAuthorization/PushBasedAuthorizationRequestTests.cs @@ -1,33 +1,26 @@ 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.Text.Json; 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.ResponseHandling; +// using Open.IdentityServer.ResponseHandling; using Open.IdentityServer.Validation; #nullable enable namespace Open.IdentityServer.IntegrationTests.Endpoints.PushedAuthorization; -internal class StubbedPushAuthorizationRequestResponseGenerator : IPushedAuthorizationResponseGenerator -{ - public async Task CreateResponseAsync(ValidatedAuthorizeRequest request) - { - return new PushedAuthorizationResponse(new Uri("urn:stubbed"),60); - } -} - public class PushBasedAuthorizationRequestTests { private const string Category = "PAR endpoint"; @@ -89,7 +82,7 @@ public PushBasedAuthorizationRequestTests() mockPipeline.Initialize(sc => { - sc.TryAddTransient(); + // sc.TryAddTransient(); }); } @@ -115,18 +108,7 @@ public async Task post_request_should_return_201() HttpClient? client = mockPipeline.BackChannelClient; client.Should().NotBeNull(); - 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] = "api1 api2", - [OidcConstants.AuthorizeRequest.State] = "1234567890", - }), - TestContext.Current.CancellationToken); + var response = await SendRequestForUri(client,"api1","api2"); response.StatusCode.Should().Be(HttpStatusCode.Created); // need to verify content-type is application/json @@ -138,4 +120,97 @@ public async Task post_request_should_return_201() 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/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs index 18c0d9a5..6fdecf3c 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs @@ -40,8 +40,8 @@ public async Task CreateResponseAsync_WhenCalled_ShouldMapRequestCorrectlyAndSen PushedAuthorizationMemento? storedInfo = null; - _store.Setup(s => s.StorePushedAuthorizationRequestAsync(It.IsAny(), It.IsAny())) - .Callback((_, info) => storedInfo = info); + _store.Setup(s => s.StorePushedAuthorizationRequestAsync( It.IsAny())) + .Callback(info => storedInfo = info); await sut.CreateResponseAsync(_request); @@ -59,8 +59,8 @@ public async Task CreateResponseAsync_WhenCalled_ShouldConstructUriCorrectlyAndP string? passedId = null; - _store.Setup(s => s.StorePushedAuthorizationRequestAsync(It.IsAny(), It.IsAny())) - .Callback((id, _) => passedId = id); + _store.Setup(s => s.StorePushedAuthorizationRequestAsync( It.IsAny())) + .Callback(memento => passedId = memento.Key); await sut.CreateResponseAsync(_request); @@ -88,8 +88,8 @@ public async Task CreateResponseAsync_WhenCalled_ShouldSetExpirationBasedOnOptio var sut = CreateSut(); - _store.Setup(s => s.StorePushedAuthorizationRequestAsync(It.IsAny(), It.IsAny())) - .Callback((id, memento) => spiedExpiration = memento.ValidUntil); + _store.Setup(s => s.StorePushedAuthorizationRequestAsync( It.IsAny())) + .Callback(memento => spiedExpiration = memento.ValidUntil); await sut.CreateResponseAsync(_request); @@ -118,8 +118,8 @@ public async Task CreateResponseAsync_WhenCalled_ShouldSetExpirationBasedOnClien var sut = CreateSut(); - _store.Setup(s => s.StorePushedAuthorizationRequestAsync(It.IsAny(), It.IsAny())) - .Callback((id, memento) => spiedExpiration = memento.ValidUntil); + _store.Setup(s => s.StorePushedAuthorizationRequestAsync( It.IsAny())) + .Callback( memento => spiedExpiration = memento.ValidUntil); var response = await sut.CreateResponseAsync(_request); @@ -149,7 +149,7 @@ public async Task CreateResponseAsync_WhenCalled_ShouldGenerateResponseCorrectly [Fact] public async Task CreateResponseAsync_WhenCalledAndStoreThrowsException_ShouldReturnNull() { - _store.Setup(s => s.StorePushedAuthorizationRequestAsync(It.IsAny(), It.IsAny())) + _store.Setup(s => s.StorePushedAuthorizationRequestAsync(It.IsAny())) .ThrowsAsync(new Exception()); var sut = CreateSut(); diff --git a/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs b/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs index c6998807..e4a93caa 100644 --- a/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs +++ b/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs @@ -14,10 +14,9 @@ public interface IPushedAuthorizationRequestStore /// /// Stores the passed pushed authorization request against the id used as a key. /// - /// The request id to act as the key for the storage /// The pushed authorization request information to store /// A task indicating the async lifetime of the method - Task StorePushedAuthorizationRequestAsync(string id, PushedAuthorizationMemento requestInformation); + Task StorePushedAuthorizationRequestAsync(PushedAuthorizationMemento requestInformation); /// From 1e5bc7361fbf03241f17067c4ca8e96173510b64 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Mon, 31 Aug 2026 20:10:44 +0100 Subject: [PATCH 14/30] Supports checking client_id is the same for both the PAR and the authorize request --- ...InMemoryPushedAuthorizationRequestStore.cs | 47 ++++ ...singPushedAuthorizationRequestValidator.cs | 63 ++++++ ...ushedAuthorizationRequestValidatorTests.cs | 206 ++++++++++++++++++ 3 files changed, 316 insertions(+) create mode 100644 src/Open.IdentityServer/src/Stores/InMemory/InMemoryPushedAuthorizationRequestStore.cs create mode 100644 src/Open.IdentityServer/src/Validation/Default/AuthorizeUsingPushedAuthorizationRequestValidator.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeUsingPushedAuthorizationRequestValidatorTests.cs 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 00000000..dd2b34b5 --- /dev/null +++ b/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPushedAuthorizationRequestStore.cs @@ -0,0 +1,47 @@ +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/Default/AuthorizeUsingPushedAuthorizationRequestValidator.cs b/src/Open.IdentityServer/src/Validation/Default/AuthorizeUsingPushedAuthorizationRequestValidator.cs new file mode 100644 index 00000000..f5a29880 --- /dev/null +++ b/src/Open.IdentityServer/src/Validation/Default/AuthorizeUsingPushedAuthorizationRequestValidator.cs @@ -0,0 +1,63 @@ +using System.Collections.Specialized; +using System.Security.Claims; +using System.Threading.Tasks; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Configuration.DependencyInjection; +using Open.IdentityServer.Storage.Models; +using Open.IdentityServer.Stores; + +#nullable enable +namespace Open.IdentityServer.Validation; + +internal class AuthorizeUsingPushedAuthorizationRequestValidator( + Decorator toDecorate, + IdentityServerOptions options, + IPushedAuthorizationRequestStore store) + : 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"); + } + + PushedAuthorizationMemento? request = await store.ConsumePushedAuthorizationRequestAsync(requestUris[0]); + if (request == null) + { + return new AuthorizeRequestValidationResult(OidcConstants.AuthorizeErrors.InvalidRequest); + } + + if (request.Parameters.Get(OidcConstants.AuthorizeRequest.ClientId) != + parameters.Get(OidcConstants.AuthorizeRequest.ClientId)) + { + return new AuthorizeRequestValidationResult(OidcConstants.AuthorizeErrors.InvalidRequest); + } + + AuthorizeRequestValidationResult result = await toDecorate.Instance.ValidateAsync(request.Parameters, 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/test/Open.IdentityServer.UnitTests/Validation/AuthorizeUsingPushedAuthorizationRequestValidatorTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeUsingPushedAuthorizationRequestValidatorTests.cs new file mode 100644 index 00000000..c1781ecb --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeUsingPushedAuthorizationRequestValidatorTests.cs @@ -0,0 +1,206 @@ +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.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 store = 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"; + store.Setup(s=>s.ConsumePushedAuthorizationRequestAsync(unknownRequestUri)) + .ReturnsAsync((PushedAuthorizationMemento?)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 memento = new PushedAuthorizationMemento( + requestUri, + new DateTimeOffset(new DateTime(2027, 3, 10, 12, 3, 10)), + new NameValueCollection() { {"client_id","different" }}); + + store.Setup(s=>s.ConsumePushedAuthorizationRequestAsync(requestUri)) + .ReturnsAsync(memento); + + 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 PushedAuthorizationMemento(String.Empty, DateTime.Now, new NameValueCollection()); + + store.Setup(s => s.ConsumePushedAuthorizationRequestAsync(requestUri)) + .ReturnsAsync(stored); + + SetupAuthorizeRequestValidationResult(stored.Parameters, 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, + store.Object); + } +} \ No newline at end of file From 541896ba45f4f09ce926a3c0ed04693abf7d0ec0 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Mon, 31 Aug 2026 20:27:15 +0100 Subject: [PATCH 15/30] Add/updated copyright messages --- .../Default/PushedAuthorizationResponseGenerator.cs | 5 ++++- .../InMemory/InMemoryPushedAuthorizationRequestStore.cs | 3 +++ .../src/Validation/Default/AuthorizeRequestValidator.cs | 1 - .../AuthorizeUsingPushedAuthorizationRequestValidator.cs | 3 +++ .../PushBasedAuthorizationRequestTests.cs | 4 ++++ src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs | 4 +++- 6 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs index e65b24b6..b592f62f 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs @@ -1,5 +1,8 @@ +// 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.Runtime.InteropServices.ComTypes; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Open.IdentityServer.Configuration; diff --git a/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPushedAuthorizationRequestStore.cs b/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPushedAuthorizationRequestStore.cs index dd2b34b5..7ef2b31d 100644 --- a/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPushedAuthorizationRequestStore.cs +++ b/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPushedAuthorizationRequestStore.cs @@ -1,3 +1,6 @@ +// 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; diff --git a/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs b/src/Open.IdentityServer/src/Validation/Default/AuthorizeRequestValidator.cs index b098abe5..ad847200 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 index f5a29880..3cfc43f9 100644 --- a/src/Open.IdentityServer/src/Validation/Default/AuthorizeUsingPushedAuthorizationRequestValidator.cs +++ b/src/Open.IdentityServer/src/Validation/Default/AuthorizeUsingPushedAuthorizationRequestValidator.cs @@ -1,3 +1,6 @@ +// 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; 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 index e1f1f2a5..41447888 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/PushedAuthorization/PushBasedAuthorizationRequestTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/PushedAuthorization/PushBasedAuthorizationRequestTests.cs @@ -1,3 +1,7 @@ +// 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; diff --git a/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs b/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs index e4a93caa..dd57c650 100644 --- a/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs +++ b/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs @@ -1,5 +1,7 @@ +// 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.Models; using Open.IdentityServer.Storage.Models; namespace Open.IdentityServer.Stores; From b57bd969b37e87d761be16ef6765b3f5f2bb5ff6 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Mon, 31 Aug 2026 21:21:13 +0100 Subject: [PATCH 16/30] Removed some whitepsace --- src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs b/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs index dd57c650..c72f8267 100644 --- a/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs +++ b/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs @@ -20,7 +20,6 @@ public interface IPushedAuthorizationRequestStore /// 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. /// From d2d7899f80f69713235b9d45e02e18acef48f091 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Tue, 1 Sep 2026 13:46:13 +0100 Subject: [PATCH 17/30] Rebased off 2.0 main --- .../PushedAuthorization/PushedAuthorizationTests.cs | 1 + .../Default/DiscoveryResponseGeneratorTests.cs | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) 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 index b8236b53..5c99cb3c 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs @@ -17,6 +17,7 @@ using Open.IdentityServer.Hosting; using Open.IdentityServer.Models; using Open.IdentityServer.ResponseHandling; +using Open.IdentityServer.UnitTests.Common; using Open.IdentityServer.Validation; using Xunit; 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 2694cb29..4e6f0642 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 @@ -762,7 +762,7 @@ public async Task CreateDiscoveryDocumentAsync_WhenMtlsEnabledAndShowAuthMethods public async Task CreateDiscoveryDocumentAsync_WhenParEnabled_ShouldContainParEndpoint() { var sut = CreateSut(); - Options.Endpoints.EnablePushedAuthorizationRequestEndpoint = true; + _options.Endpoints.EnablePushedAuthorizationRequestEndpoint = true; string expectedParEndpoint = $"https://open.ids.url/somepath/{Constants.ProtocolRoutePaths.ConnectPathPrefix}/{Constants.ProtocolRoutePaths.PushedAuthorizationRequest}"; @@ -779,7 +779,7 @@ public async Task CreateDiscoveryDocumentAsync_WhenParEnabled_ShouldContainParEn public async Task CreateDiscoveryDocumentAsync_WhenParDisabled_ShouldNotContainParEndpoint() { var sut = CreateSut(); - Options.Endpoints.EnablePushedAuthorizationRequestEndpoint = false; + _options.Endpoints.EnablePushedAuthorizationRequestEndpoint = false; var actual = await sut.CreateDiscoveryDocumentAsync("https://open.ids.url/somepath", "https://open.ids.url"); @@ -794,8 +794,8 @@ public async Task CreateDiscoveryDocumentAsync_WhenParDisabled_ShouldNotContainP public async Task CreateDiscoveryDocumentAsync_WhenParIsEnforcedOrNot_ShouldContainEnforcementLevel(bool isEnforced) { var sut = CreateSut(); - Options.Endpoints.EnablePushedAuthorizationRequestEndpoint = true; - Options.PushedAuthorization.Required = isEnforced; + _options.Endpoints.EnablePushedAuthorizationRequestEndpoint = true; + _options.PushedAuthorization.Required = isEnforced; var actual = await sut.CreateDiscoveryDocumentAsync("https://open.ids.url/somepath", "https://open.ids.url"); From 3a38f4c6522274d120ed91e9ef0d45b8e8ab965a Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Tue, 1 Sep 2026 20:36:50 +0100 Subject: [PATCH 18/30] Added telemetry --- .../Endpoints/PushedAuthorizationEndpoint.cs | 11 ++ .../PushedAuthorizationTests.cs | 111 ++++++++++++++++-- 2 files changed, 110 insertions(+), 12 deletions(-) diff --git a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs index 43c8342e..4ee24d6f 100644 --- a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs +++ b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs @@ -13,6 +13,7 @@ using Open.IdentityServer.Hosting; using Open.IdentityServer.Extensions; using Open.IdentityServer.ResponseHandling; +using Open.IdentityServer.Services; using Open.IdentityServer.Validation; #nullable enable @@ -23,10 +24,13 @@ internal class PushedAuthorizationRequestEndpoint( 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); @@ -44,6 +48,8 @@ public async Task ProcessAsync(HttpContext requestContext) return Error(OidcConstants.TokenErrors.InvalidClient); } + trace?.AddTag(TelemetryConstants.TagConstants.Client, clientValidationResult.Client.ClientId); + NameValueCollection? parParameters = await ParseForm(requestContext.Request); if (parParameters == null) { @@ -60,6 +66,11 @@ private async Task ProcessRequest( PushAuthorizationRequestValidationResult result = await validator .ValidateAsync(validationContext, requestContext.RequestAborted); + telemetry.CountPushedAuthorizationRequest( + result.ValidatedAuthorizeRequest.ClientId , + result.IsError ? result.Error : null); + + if (result.IsError) { return new BadRequestResult(result.Error, result.ErrorDescription); 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 index 5c99cb3c..bb2cd1cb 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs @@ -17,6 +17,7 @@ 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; @@ -30,8 +31,9 @@ public class PushedAuthorizationTests 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()); @@ -43,6 +45,9 @@ public PushedAuthorizationTests() IsError = false, Client = new Client() }); + + telemetry.Setup(t => t.Trace(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(trace.Object); } [Fact] @@ -104,7 +109,7 @@ public async Task ProcessAsync_should_not_support_the_following_http_verbs(strin } [Fact] - public async Task ProcessAsync_should_will_support_http_verb_post() + public async Task ProcessAsync_should_support_http_verb_post() { var sut = CreateSut(); var context = CreateHttpContext("POST"); @@ -188,21 +193,102 @@ public async Task ProcessAsync_when_called_with_valid_request_should_generate_ok var context = CreateHttpContext(); var requestValidatorResult = new PushAuthorizationRequestValidationResult(new ValidatedAuthorizeRequest()); var expectedResult = new PushedAuthorizationResponse(new Uri("urn:foo"), 10); - var parameters = new NameValueCollection(); + + SetupRequestResponse(context, requestValidatorResult, expectedResult); - AddRequest(parameters); - pushedAuthorizationRequestValidator - .Setup(parv => - parv.ValidateAsync(It.IsAny(), context.RequestAborted)) - .ReturnsAsync(requestValidatorResult); + PushedAuthorizationResult result = (PushedAuthorizationResult)await sut.ProcessAsync(context); - pushedAuthorizationResponseGenerator - .Setup(parg => parg.CreateResponseAsync(requestValidatorResult.ValidatedAuthorizeRequest)) - .ReturnsAsync(expectedResult); + 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 = new PushAuthorizationRequestValidationResult(new ValidatedAuthorizeRequest() + { + ClientId = expectedClientId + }); + 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); + 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 = new PushAuthorizationRequestValidationResult(new ValidatedAuthorizeRequest() + { + ClientId = expectedClientId + }) + { + IsError = true, + Error = expectedError + }; + + var expectedResult = new PushedAuthorizationResponse(new Uri("urn:foo"), 10); + + SetupRequestResponse(context, requestValidatorResult, expectedResult); + + var result = (BadRequestResult)await sut.ProcessAsync(context); + + telemetry.Verify(t=>t.CountPushedAuthorizationRequest(expectedClientId,expectedError),Times.Once); + } + + [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) @@ -259,6 +345,7 @@ private PushedAuthorizationRequestEndpoint CreateSut() clientSecretValidator.Object, pushedAuthorizationRequestValidator.Object, pushedAuthorizationResponseGenerator.Object, + telemetry.Object, logger.Object); } } \ No newline at end of file From 8c3a6490d254677f0a738857f77bafecabef1cb4 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Tue, 1 Sep 2026 21:15:53 +0100 Subject: [PATCH 19/30] Additional logging --- .../Endpoints/PushedAuthorizationEndpoint.cs | 6 ++-- .../PushedAuthorizationTests.cs | 34 +++++++++---------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs index 4ee24d6f..273a543d 100644 --- a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs +++ b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs @@ -69,17 +69,19 @@ private async Task ProcessRequest( 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); } 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 index bb2cd1cb..f08cd50f 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/PushedAuthorization/PushedAuthorizationTests.cs @@ -7,7 +7,6 @@ using System.Net; using System.Threading.Tasks; using AwesomeAssertions; -using IdentityServer.UnitTests.Common; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Moq; @@ -22,6 +21,7 @@ using Open.IdentityServer.Validation; using Xunit; +#nullable enable namespace Open.IdentityServer.UnitTests.Endpoints.PushedAuthorization; public class PushedAuthorizationTests @@ -208,10 +208,7 @@ public async Task ProcessAsync_when_called_with_valid_request_should_increment_p var sut = CreateSut(); var context = CreateHttpContext(); - var requestValidatorResult = new PushAuthorizationRequestValidationResult(new ValidatedAuthorizeRequest() - { - ClientId = expectedClientId - }); + var requestValidatorResult = CreatePushAuthorizationRequestValidationResult(expectedClientId); var expectedResult = new PushedAuthorizationResponse(new Uri("urn:foo"), 10); SetupRequestResponse(context, requestValidatorResult, expectedResult); @@ -229,24 +226,27 @@ public async Task ProcessAsync_when_called_with_invalid_request_should_increment var sut = CreateSut(); var context = CreateHttpContext(); - var requestValidatorResult = new PushAuthorizationRequestValidationResult(new ValidatedAuthorizeRequest() - { - ClientId = expectedClientId - }) - { - IsError = true, - Error = expectedError - }; - - var expectedResult = new PushedAuthorizationResponse(new Uri("urn:foo"), 10); + var requestValidatorResult = CreatePushAuthorizationRequestValidationResult(expectedClientId, expectedError); - SetupRequestResponse(context, requestValidatorResult, expectedResult); + 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() { @@ -281,7 +281,7 @@ public async Task ProcessAsync_when_called_with_valid_client_id_should_add_trace } private void SetupRequestResponse(HttpContext context, PushAuthorizationRequestValidationResult requestValidatorResult, - PushedAuthorizationResponse expectedResult) + PushedAuthorizationResponse? expectedResult) { AddRequest(new NameValueCollection()); StubValidateAsync(context,requestValidatorResult); From e6b5399d2219820430fc66eb7bbb55049626cd95 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Wed, 2 Sep 2026 23:23:18 +0100 Subject: [PATCH 20/30] Refactored to put a service layer between the response generator and the store. This simplifies the response generator --- .../BuilderExtensions/Core.cs | 1 + .../Endpoints/PushedAuthorizationEndpoint.cs | 2 +- .../PushedAuthorizationResponseGenerator.cs | 44 ++-- ...efaultPushedAuthorizationRequestService.cs | 77 +++++++ .../IPushAuthorizationedRequestService.cs | 37 +++ ...shedAuthorizationResponseGeneratorTests.cs | 210 ++++++++---------- ...tPushedAuthorizationRequestServiceTests.cs | 153 +++++++++++++ .../IPushedAuthorizationRequestStore.cs | 20 ++ 8 files changed, 393 insertions(+), 151 deletions(-) create mode 100644 src/Open.IdentityServer/src/Services/Default/DefaultPushedAuthorizationRequestService.cs create mode 100644 src/Open.IdentityServer/src/Services/IPushAuthorizationedRequestService.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultPushedAuthorizationRequestServiceTests.cs diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs index 3d1f30f6..82c9ee68 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs @@ -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(); diff --git a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs index 273a543d..f470b267 100644 --- a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs +++ b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs @@ -49,7 +49,7 @@ public async Task ProcessAsync(HttpContext requestContext) } trace?.AddTag(TelemetryConstants.TagConstants.Client, clientValidationResult.Client.ClientId); - + NameValueCollection? parParameters = await ParseForm(requestContext.Request); if (parParameters == null) { diff --git a/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs index b592f62f..3a84a750 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs @@ -18,15 +18,9 @@ namespace Open.IdentityServer.ResponseHandling; /// /// Default implementation of the pushed authorization response generator /// -/// The store used to save the pushed authorization request for later retrieval -/// The service used to generate the ID for the stored state -/// Used to calculate absolute expiration -/// Used to calculate expiration +/// The service used to manage the storing of the pushed authorization request for later retrieval /// The logger to record errors and debug inforation -public class PushedAuthorizationResponseGenerator(IPushedAuthorizationRequestStore store, - IHandleGenerationService handleGenerationService, - TimeProvider clock, - IdentityServerOptions options, +public class PushedAuthorizationResponseGenerator(IPushedAuthorizationRequestService service, ILogger logger) : IPushedAuthorizationResponseGenerator { /// @@ -41,36 +35,26 @@ public class PushedAuthorizationResponseGenerator(IPushedAuthorizationRequestSto /// The generated response public async Task CreateResponseAsync(ValidatedAuthorizeRequest request) { - string generatedUniquePart = await handleGenerationService.GenerateAsync(); - - string id = IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix + generatedUniquePart; - - TimeSpan validFor = RequestValidFor(request); - DateTimeOffset validUntil = clock.GetUtcNow().Add(validFor); - - var memento = new PushedAuthorizationMemento(id, validUntil, request.Raw); - try { - await store.StorePushedAuthorizationRequestAsync(memento); + PushedAuthorization response = await service.CreateAsync(request.Client,request.Raw); - return new PushedAuthorizationResponse(new Uri(id), (int)validFor.TotalSeconds); + return new PushedAuthorizationResponse(response.Key, (long)response.ExpiresIn.TotalSeconds); } catch (Exception) { return null; } - } - private TimeSpan RequestValidFor(ValidatedAuthorizeRequest request) - { - TimeSpan duration = options.PushedAuthorization.Expiration; - if (request.Client?.PushedAuthorizationLifetime != null) - { - duration = TimeSpan.FromSeconds((int)request.Client.PushedAuthorizationLifetime); - } - - return duration; - } + // private TimeSpan RequestValidFor(ValidatedAuthorizeRequest request) + // { + // TimeSpan duration = options.PushedAuthorization.Expiration; + // if (request.Client?.PushedAuthorizationLifetime != null) + // { + // duration = TimeSpan.FromSeconds((int)request.Client.PushedAuthorizationLifetime); + // } + // + // return duration; + // } } \ 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 00000000..e6a9ab0d --- /dev/null +++ b/src/Open.IdentityServer/src/Services/Default/DefaultPushedAuthorizationRequestService.cs @@ -0,0 +1,77 @@ +using System; +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 +{ + public async Task CreateAsync(Client client , NameValueCollection parameters) + { + try + { + 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, + 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; + } + + } + + public async Task ConsumeAsync(string key) + { + try + { + PushedAuthorizationMemento? memento = await store.ConsumePushedAuthorizationRequestAsync(key); + + 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); + } + 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 00000000..2f271d68 --- /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/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs index 6fdecf3c..5aa4168e 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs @@ -1,7 +1,5 @@ using System; -using System.Collections.Generic; using System.Collections.Specialized; -using System.Security.Claims; using System.Threading.Tasks; using AwesomeAssertions; using Microsoft.Extensions.Logging; @@ -20,12 +18,9 @@ namespace Open.IdentityServer.UnitTests.ResponseHandling; public class PushedAuthorizationResponseGeneratorTests { - private readonly Mock _store = new(); - private readonly Mock _handleGenerationService = new(); + private readonly Mock service = new(); private Mock> _logger = new(); - private Mock clock = new Mock(); - private IdentityServerOptions options = new IdentityServerOptions(); - + private ValidatedAuthorizeRequest _request = new ValidatedAuthorizeRequest() { Raw = new NameValueCollection() }; public PushedAuthorizationResponseGeneratorTests() @@ -36,120 +31,106 @@ public PushedAuthorizationResponseGeneratorTests() [Fact] public async Task CreateResponseAsync_WhenCalled_ShouldMapRequestCorrectlyAndSendToStore() { - var sut = CreateSut(); - - PushedAuthorizationMemento? storedInfo = null; - - _store.Setup(s => s.StorePushedAuthorizationRequestAsync( It.IsAny())) - .Callback(info => storedInfo = info); - - await sut.CreateResponseAsync(_request); - - VerifyPushedAuthorizeRequestMapping(storedInfo, _request); - } - - [Fact] - public async Task CreateResponseAsync_WhenCalled_ShouldConstructUriCorrectlyAndPassToStore() - { - string generatedUniquePart = "sdufbsibdvibv"; - - _handleGenerationService.Setup(g => g.GenerateAsync()).ReturnsAsync(generatedUniquePart); + Uri expectedKey = new Uri($"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}232444234"); - var sut = CreateSut(); - - string? passedId = null; - - _store.Setup(s => s.StorePushedAuthorizationRequestAsync( It.IsAny())) - .Callback(memento => passedId = memento.Key); - - await sut.CreateResponseAsync(_request); - - passedId.Should().NotBeNull(); - passedId.Should().Be(IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix + generatedUniquePart); - } - - [Fact] - public async Task CreateResponseAsync_WhenCalled_ShouldSetExpirationBasedOnOptions() - { - string generatedUniquePart = "sdufbsibdvibv"; - _request.Client = new Client(); - DateTime expectedExpiration; - DateTime now = new DateTime(2027, 3, 2, 13, 10, 20); - DateTimeOffset spiedExpiration = now; - - options.PushedAuthorization.Expiration = TimeSpan.FromSeconds(90); - - clock.Setup(c => c.GetUtcNow()).Returns(now); - expectedExpiration = now.Add(options.PushedAuthorization.Expiration); - - _handleGenerationService.Setup(g => g.GenerateAsync()).ReturnsAsync(generatedUniquePart); - var sut = CreateSut(); - - _store.Setup(s => s.StorePushedAuthorizationRequestAsync( It.IsAny())) - .Callback(memento => spiedExpiration = memento.ValidUntil); - await sut.CreateResponseAsync(_request); - - spiedExpiration.Should().Be(expectedExpiration); - } - - [Fact] - public async Task CreateResponseAsync_WhenCalled_ShouldSetExpirationBasedOnClientProperties() - { - string generatedUniquePart = "sdufbsibdvibv"; - - _request.Client = new Client() - { - PushedAuthorizationLifetime = TimeSpan.FromSeconds(30).Seconds - }; - - DateTime expectedExpiration; - DateTime now = new DateTime(2027, 3, 2, 13, 10, 20); - DateTimeOffset spiedExpiration = now; + PushedAuthorizationMemento? storedInfo = null; - clock.Setup(c => c.GetUtcNow()).Returns(now); - int expectedDuration = (int)_request.Client.PushedAuthorizationLifetime; - expectedExpiration = now.AddSeconds(expectedDuration); - - _handleGenerationService.Setup(g => g.GenerateAsync()).ReturnsAsync(generatedUniquePart); - - var sut = CreateSut(); - - _store.Setup(s => s.StorePushedAuthorizationRequestAsync( It.IsAny())) - .Callback( memento => spiedExpiration = memento.ValidUntil); + service.Setup(s => s.CreateAsync(_request.Client, _request.Raw)) + .ReturnsAsync(new PushedAuthorization(expectedKey, TimeSpan.FromSeconds(20))); var response = await sut.CreateResponseAsync(_request); - response.Should().NotBeNull(); - spiedExpiration.Should().Be(expectedExpiration); - response.Lifetime.Should().Be(expectedDuration); - } - - [Fact] - public async Task CreateResponseAsync_WhenCalled_ShouldGenerateResponseCorrectly() - { - string generatedUniquePart = "sdufbsibdvibv"; - string expectedUri = - $"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}{generatedUniquePart}"; - - _handleGenerationService.Setup(g => g.GenerateAsync()).ReturnsAsync(generatedUniquePart); - - var sut = CreateSut(); - - PushedAuthorizationResponse? response = await sut.CreateResponseAsync(_request); - - response.Should().NotBeNull(); - response.Uri.Should().Be(expectedUri); - response.Lifetime.Should().Be(PushedAuthorizationResponseGenerator.DefaultRequestLifetimeInSeconds); + response!.Lifetime.Should().Be(20); + response.Uri.Should().Be(expectedKey.ToString()); } + + // [Fact] + // public async Task CreateResponseAsync_WhenCalled_ShouldSetExpirationBasedOnOptions() + // { + // string generatedUniquePart = "sdufbsibdvibv"; + // + // _request.Client = new Client(); + // + // DateTime expectedExpiration; + // DateTime now = new DateTime(2027, 3, 2, 13, 10, 20); + // DateTimeOffset spiedExpiration = now; + // + // options.PushedAuthorization.Expiration = TimeSpan.FromSeconds(90); + // + // clock.Setup(c => c.GetUtcNow()).Returns(now); + // expectedExpiration = now.Add(options.PushedAuthorization.Expiration); + // + // _handleGenerationService.Setup(g => g.GenerateAsync()).ReturnsAsync(generatedUniquePart); + // + // var sut = CreateSut(); + // + // _store.Setup(s => s.StorePushedAuthorizationRequestAsync( It.IsAny())) + // .Callback(memento => spiedExpiration = memento.ValidUntil); + // + // await sut.CreateResponseAsync(_request); + // + // spiedExpiration.Should().Be(expectedExpiration); + // } + + // [Fact] + // public async Task CreateResponseAsync_WhenCalled_ShouldSetExpirationBasedOnClientProperties() + // { + // string generatedUniquePart = "sdufbsibdvibv"; + // + // _request.Client = new Client() + // { + // PushedAuthorizationLifetime = TimeSpan.FromSeconds(30).Seconds + // }; + // + // DateTime expectedExpiration; + // DateTime now = new DateTime(2027, 3, 2, 13, 10, 20); + // DateTimeOffset spiedExpiration = now; + // + // clock.Setup(c => c.GetUtcNow()).Returns(now); + // int expectedDuration = (int)_request.Client.PushedAuthorizationLifetime; + // expectedExpiration = now.AddSeconds(expectedDuration); + // + // _handleGenerationService.Setup(g => g.GenerateAsync()).ReturnsAsync(generatedUniquePart); + // + // var sut = CreateSut(); + // + // _store.Setup(s => s.StorePushedAuthorizationRequestAsync( It.IsAny())) + // .Callback( memento => spiedExpiration = memento.ValidUntil); + // + // var response = await sut.CreateResponseAsync(_request); + // + // response.Should().NotBeNull(); + // spiedExpiration.Should().Be(expectedExpiration); + // response.Lifetime.Should().Be(expectedDuration); + // } + + // [Fact] + // public async Task CreateResponseAsync_WhenCalled_ShouldGenerateResponseCorrectly() + // { + // string generatedUniquePart = "sdufbsibdvibv"; + // string expectedUri = + // $"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}{generatedUniquePart}"; + // + // _handleGenerationService.Setup(g => g.GenerateAsync()).ReturnsAsync(generatedUniquePart); + // + // var sut = CreateSut(); + // + // PushedAuthorizationResponse? response = await sut.CreateResponseAsync(_request); + // + // response.Should().NotBeNull(); + // response.Uri.Should().Be(expectedUri); + // response.Lifetime.Should().Be(PushedAuthorizationResponseGenerator.DefaultRequestLifetimeInSeconds); + // } + [Fact] - public async Task CreateResponseAsync_WhenCalledAndStoreThrowsException_ShouldReturnNull() + public async Task CreateResponseAsync_WhenCalledAndServiceThrowsException_ShouldReturnNull() { - _store.Setup(s => s.StorePushedAuthorizationRequestAsync(It.IsAny())) + service.Setup(s => s.CreateAsync(It.IsAny(),It.IsAny())) .ThrowsAsync(new Exception()); var sut = CreateSut(); @@ -159,19 +140,8 @@ public async Task CreateResponseAsync_WhenCalledAndStoreThrowsException_ShouldRe response.Should().BeNull(); } - private void VerifyPushedAuthorizeRequestMapping(PushedAuthorizationMemento? storedInfo, ValidatedAuthorizeRequest request) - { - storedInfo.Should().NotBeNull(); - storedInfo.Parameters.Should().BeEquivalentTo(request.Raw); - storedInfo.Key.Should().NotBeEmpty(); - } - private PushedAuthorizationResponseGenerator CreateSut() { - return new PushedAuthorizationResponseGenerator(_store.Object, - _handleGenerationService.Object, - clock.Object, - options, - _logger.Object); + return new PushedAuthorizationResponseGenerator(service.Object, _logger.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 00000000..4b3a1878 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultPushedAuthorizationRequestServiceTests.cs @@ -0,0 +1,153 @@ +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.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(); + string expectedHandle = "someHandle"; + string expectedKey = $"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}{expectedHandle}"; + + 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( + new PushedAuthorizationMemento( + expectedKey,expectedExpiration,parameters + )),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}"; + + + 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( + new PushedAuthorizationMemento( + expectedKey,expectedExpiration,parameters + )),Times.Once); + + result.ExpiresIn.Should().Be(TimeSpan.FromSeconds(client.PushedAuthorizationLifetime.Value)); + } + + [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 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().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/Storage/src/Stores/IPushedAuthorizationRequestStore.cs b/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs index c72f8267..f37083b0 100644 --- a/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs +++ b/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs @@ -1,6 +1,7 @@ // 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.Storage.Models; @@ -8,6 +9,25 @@ namespace Open.IdentityServer.Stores; #nullable enable +/// +/// 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) { } +} + /// /// Models the persistence of a pushed authorization request. /// From a999fb07fa1428229d4e54ed2986b1ca9db2f529 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Thu, 3 Sep 2026 14:42:38 +0100 Subject: [PATCH 21/30] Ensure no sensitive authentication parameters are persisted --- ...efaultPushedAuthorizationRequestService.cs | 17 ++++++ ...tPushedAuthorizationRequestServiceTests.cs | 58 ++++++++++++++++--- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultPushedAuthorizationRequestService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultPushedAuthorizationRequestService.cs index e6a9ab0d..51eacd8f 100644 --- a/src/Open.IdentityServer/src/Services/Default/DefaultPushedAuthorizationRequestService.cs +++ b/src/Open.IdentityServer/src/Services/Default/DefaultPushedAuthorizationRequestService.cs @@ -1,4 +1,6 @@ using System; +using System.Collections; +using System.Collections.Generic; using System.Collections.Specialized; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -21,6 +23,8 @@ public async Task CreateAsync(Client client , NameValueColl { try { + parameters = RemoveAnyAuthenticationParameters(parameters); + string keyBody = await handleGeneration.GenerateAsync(); string key = $"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}{keyBody}"; @@ -51,6 +55,18 @@ await store.StorePushedAuthorizationRequestAsync( } + private static readonly List AuthenticationParameters = + ["client_secret", "client_assertion","client_assertion_type"]; + + private NameValueCollection RemoveAnyAuthenticationParameters(NameValueCollection src) + { + var dest = new NameValueCollection(src); + + AuthenticationParameters.ForEach(dest.Remove); + + return dest; + } + public async Task ConsumeAsync(string key) { try @@ -67,6 +83,7 @@ await store.StorePushedAuthorizationRequestAsync( catch (PushedAuthorizationRequestStoreException e) { logger.LogError("Failed to consume PAR request store error {key}:{exception}",key,e.Message); + throw; } catch (Exception e) { 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 index 4b3a1878..d60690df 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultPushedAuthorizationRequestServiceTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultPushedAuthorizationRequestServiceTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Collections.Specialized; using System.Threading.Tasks; using AwesomeAssertions; @@ -48,7 +49,13 @@ public async Task CreateResponse_when_called_should_store_parameters_and_global_ 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(); + NameValueCollection parameters = new() + { + ["scope"] = "api1", + ["scope"] = "api2", + ["client_id"] = "123445" + }; + string expectedHandle = "someHandle"; string expectedKey = $"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}{expectedHandle}"; @@ -61,9 +68,11 @@ public async Task CreateResponse_when_called_should_store_parameters_and_global_ var result = await sut.CreateAsync(client,parameters); store.Verify(s => s.StorePushedAuthorizationRequestAsync( - new PushedAuthorizationMemento( - expectedKey,expectedExpiration,parameters - )),Times.Once); + It.Is(pam => + pam.Key == expectedKey && pam.ValidUntil == expectedExpiration && + pam.Parameters.AllKeys.SequenceEqual(parameters.AllKeys) + )),Times.Once()); + result.ExpiresIn.Should().Be(options.PushedAuthorization.Expiration); } @@ -89,13 +98,48 @@ public async Task CreateResponse_when_called_should_store_parameters_and_per_cli var result = await sut.CreateAsync(client,parameters); store.Verify(s => s.StorePushedAuthorizationRequestAsync( - new PushedAuthorizationMemento( - expectedKey,expectedExpiration,parameters - )),Times.Once); + 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() { From 5837600970152b5fa5e5e65b85661460cb65f53c Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Fri, 4 Sep 2026 11:13:10 +0100 Subject: [PATCH 22/30] Refactored the PAR service to send a request_uri hash to the store as opposed to the raw URL. As if the request_uri is intercepted only the client_id is required for an attacker to use it, and bypass the client secret aspect of PAR. Also fixed issue that the RequestValidator was still bound to the store not the service layer --- ...efaultPushedAuthorizationRequestService.cs | 6 ++--- ...singPushedAuthorizationRequestValidator.cs | 9 +++---- ...tPushedAuthorizationRequestServiceTests.cs | 9 ++++--- ...ushedAuthorizationRequestValidatorTests.cs | 24 +++++++++---------- 4 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultPushedAuthorizationRequestService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultPushedAuthorizationRequestService.cs index 51eacd8f..9a2f853e 100644 --- a/src/Open.IdentityServer/src/Services/Default/DefaultPushedAuthorizationRequestService.cs +++ b/src/Open.IdentityServer/src/Services/Default/DefaultPushedAuthorizationRequestService.cs @@ -27,7 +27,7 @@ public async Task CreateAsync(Client client , NameValueColl string keyBody = await handleGeneration.GenerateAsync(); string key = $"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}{keyBody}"; - + TimeSpan duration = options.PushedAuthorization.Expiration; if (client.PushedAuthorizationLifetime != null) { @@ -36,7 +36,7 @@ public async Task CreateAsync(Client client , NameValueColl await store.StorePushedAuthorizationRequestAsync( new PushedAuthorizationMemento( - key, + key.Sha256(), clock.GetUtcNow().Add(duration), parameters)); @@ -71,7 +71,7 @@ private NameValueCollection RemoveAnyAuthenticationParameters(NameValueCollectio { try { - PushedAuthorizationMemento? memento = await store.ConsumePushedAuthorizationRequestAsync(key); + PushedAuthorizationMemento? memento = await store.ConsumePushedAuthorizationRequestAsync(key.Sha256()); if (memento?.ValidUntil < clock.GetUtcNow()) { diff --git a/src/Open.IdentityServer/src/Validation/Default/AuthorizeUsingPushedAuthorizationRequestValidator.cs b/src/Open.IdentityServer/src/Validation/Default/AuthorizeUsingPushedAuthorizationRequestValidator.cs index 3cfc43f9..d4667dc5 100644 --- a/src/Open.IdentityServer/src/Validation/Default/AuthorizeUsingPushedAuthorizationRequestValidator.cs +++ b/src/Open.IdentityServer/src/Validation/Default/AuthorizeUsingPushedAuthorizationRequestValidator.cs @@ -6,6 +6,7 @@ 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; @@ -15,7 +16,7 @@ namespace Open.IdentityServer.Validation; internal class AuthorizeUsingPushedAuthorizationRequestValidator( Decorator toDecorate, IdentityServerOptions options, - IPushedAuthorizationRequestStore store) + IPushedAuthorizationRequestService parService) : IAuthorizeRequestValidator { public async Task ValidateAsync(NameValueCollection parameters, ClaimsPrincipal? subject = null) @@ -34,19 +35,19 @@ public async Task ValidateAsync(NameValueColle "Only one request uri is allowed"); } - PushedAuthorizationMemento? request = await store.ConsumePushedAuthorizationRequestAsync(requestUris[0]); + NameValueCollection? request = await parService.ConsumeAsync(requestUris[0]); if (request == null) { return new AuthorizeRequestValidationResult(OidcConstants.AuthorizeErrors.InvalidRequest); } - if (request.Parameters.Get(OidcConstants.AuthorizeRequest.ClientId) != + if (request.Get(OidcConstants.AuthorizeRequest.ClientId) != parameters.Get(OidcConstants.AuthorizeRequest.ClientId)) { return new AuthorizeRequestValidationResult(OidcConstants.AuthorizeErrors.InvalidRequest); } - AuthorizeRequestValidationResult result = await toDecorate.Instance.ValidateAsync(request.Parameters, subject); + AuthorizeRequestValidationResult result = await toDecorate.Instance.ValidateAsync(request, subject); return result; } 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 index d60690df..f9aab825 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultPushedAuthorizationRequestServiceTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultPushedAuthorizationRequestServiceTests.cs @@ -57,7 +57,8 @@ public async Task CreateResponse_when_called_should_store_parameters_and_global_ }; string expectedHandle = "someHandle"; - string expectedKey = $"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}{expectedHandle}"; + string expectedKey = $"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}{expectedHandle}" + .Sha256(); clock.Setup(c => c.GetUtcNow()).Returns(now); @@ -88,6 +89,7 @@ public async Task CreateResponse_when_called_should_store_parameters_and_per_cli string expectedHandle = "someHandle"; string expectedKey = $"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}{expectedHandle}"; + expectedKey = expectedKey.Sha256(); clock.Setup(c => c.GetUtcNow()).Returns(now); @@ -150,14 +152,15 @@ public async Task ConsumeResponse_when_called_with_non_expired_key_should_return NameValueCollection parameters = new(); string expectedHandle = "someHandle"; - string expectedKey = $"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}{expectedHandle}"; + 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(expectedKey); + var result = await sut.ConsumeAsync(key); result.Should().Be(parameters); } diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeUsingPushedAuthorizationRequestValidatorTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeUsingPushedAuthorizationRequestValidatorTests.cs index c1781ecb..ea3bfb05 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeUsingPushedAuthorizationRequestValidatorTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/AuthorizeUsingPushedAuthorizationRequestValidatorTests.cs @@ -9,6 +9,7 @@ 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; @@ -21,7 +22,7 @@ public class AuthorizeUsingPushedAuthorizationRequestValidatorTests { private readonly Mock authorizeRequestValidator = new(); private readonly Mock> logger = new(); - private readonly Mock store = new(); + private readonly Mock parService = new(); private readonly IdentityServerOptions options = new IdentityServerOptions(); public AuthorizeUsingPushedAuthorizationRequestValidatorTests() @@ -120,8 +121,8 @@ public async Task ValidateAsync_when_called_with_an_unknown_request_uri_should_r var parameters = new NameValueCollection(); string unknownRequestUri = IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix + "blah"; - store.Setup(s=>s.ConsumePushedAuthorizationRequestAsync(unknownRequestUri)) - .ReturnsAsync((PushedAuthorizationMemento?)null); + parService.Setup(s=>s.ConsumeAsync(unknownRequestUri)) + .ReturnsAsync((NameValueCollection?)null); parameters.Add(OidcConstants.AuthorizeRequest.RequestUri,unknownRequestUri); @@ -143,13 +144,10 @@ public async Task ValidateAsync_when_called_with_a_different_client_id_than_asso { "client_id", "clientOne" }, { OidcConstants.AuthorizeRequest.RequestUri,requestUri} }; - var memento = new PushedAuthorizationMemento( - requestUri, - new DateTimeOffset(new DateTime(2027, 3, 10, 12, 3, 10)), - new NameValueCollection() { {"client_id","different" }}); + var request = new NameValueCollection() { {"client_id","different" }}; - store.Setup(s=>s.ConsumePushedAuthorizationRequestAsync(requestUri)) - .ReturnsAsync(memento); + parService.Setup(s=>s.ConsumeAsync(requestUri)) + .ReturnsAsync(request); var sut = CreateSut(); @@ -170,12 +168,12 @@ public async Task { OidcConstants.AuthorizeRequest.RequestUri, requestUri } }; - var stored = new PushedAuthorizationMemento(String.Empty, DateTime.Now, new NameValueCollection()); + var stored = new NameValueCollection(); - store.Setup(s => s.ConsumePushedAuthorizationRequestAsync(requestUri)) + parService.Setup(s => s.ConsumeAsync(requestUri)) .ReturnsAsync(stored); - SetupAuthorizeRequestValidationResult(stored.Parameters, new ValidatedAuthorizeRequest()); + SetupAuthorizeRequestValidationResult(stored, new ValidatedAuthorizeRequest()); var sut = CreateSut(); @@ -201,6 +199,6 @@ private AuthorizeUsingPushedAuthorizationRequestValidator CreateSut() return new AuthorizeUsingPushedAuthorizationRequestValidator( decorator, options, - store.Object); + parService.Object); } } \ No newline at end of file From ce4d5c40c5b480588f010e6e97a0fb449dac42a9 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Sat, 5 Sep 2026 14:43:00 +0100 Subject: [PATCH 23/30] Refactored namespace for test and removed commented out code --- .../PushedAuthorizationResponseGenerator.cs | 16 ---------------- .../PushBasedAuthorizationRequestTests.cs | 3 ++- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs index 3a84a750..4bf70d2e 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs @@ -23,11 +23,6 @@ namespace Open.IdentityServer.ResponseHandling; public class PushedAuthorizationResponseGenerator(IPushedAuthorizationRequestService service, ILogger logger) : IPushedAuthorizationResponseGenerator { - /// - /// Default lifetime for a Pushed Authorization Request - /// - public static readonly int DefaultRequestLifetimeInSeconds = 60; - /// /// Generates the Pushed Authorization Request response /// @@ -46,15 +41,4 @@ public class PushedAuthorizationResponseGenerator(IPushedAuthorizationRequestSer return null; } } - - // private TimeSpan RequestValidFor(ValidatedAuthorizeRequest request) - // { - // TimeSpan duration = options.PushedAuthorization.Expiration; - // if (request.Client?.PushedAuthorizationLifetime != null) - // { - // duration = TimeSpan.FromSeconds((int)request.Client.PushedAuthorizationLifetime); - // } - // - // return duration; - // } } \ No newline at end of file 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 index 41447888..7da32d29 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/PushedAuthorization/PushBasedAuthorizationRequestTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/PushedAuthorization/PushBasedAuthorizationRequestTests.cs @@ -18,12 +18,13 @@ using Xunit; using AwesomeAssertions; using Microsoft.Extensions.DependencyInjection.Extensions; +using Open.IdentityServer; // using Open.IdentityServer.ResponseHandling; using Open.IdentityServer.Validation; #nullable enable -namespace Open.IdentityServer.IntegrationTests.Endpoints.PushedAuthorization; +namespace IdentityServer.IntegrationTests.Endpoints.PushedAuthorization; public class PushBasedAuthorizationRequestTests { From ff813255ba8dd28e3cbb40ca3f16af5c192157d0 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Tue, 8 Sep 2026 06:43:47 +0100 Subject: [PATCH 24/30] Fixes PAR path in discovery document, adding an additional //connect --- .../ResponseHandling/Default/DiscoveryResponseGenerator.cs | 2 +- .../Default/DiscoveryResponseGeneratorTests.cs | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs index 1d1f4911..03846591 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs @@ -393,7 +393,7 @@ private void AddPushedAuthorizationRequestEndpoint(string baseUrl, Dictionary Date: Tue, 8 Sep 2026 17:34:21 +0100 Subject: [PATCH 25/30] Refactor Pushed Authorization components for improved readability and consistency based on PR feedback --- .../BuilderExtensions/Core.cs | 6 +- .../BuilderExtensions/InMemory.cs | 2 +- .../Options/IdentityServerOptions.cs | 2 +- .../Options/PushedAuthorizationOptions.cs | 4 +- .../Endpoints/PushedAuthorizationEndpoint.cs | 4 +- .../Results/PushedAuthorizationResult.cs | 3 +- .../PushedAuthorizationResponseGenerator.cs | 12 +-- .../IPushedAuthorizationResponseGenerator.cs | 4 +- .../Models/PushedAuthorizationResponse.cs | 4 +- ...efaultPushedAuthorizationRequestService.cs | 9 +- ...shedAuthorizationResponseGeneratorTests.cs | 83 +------------------ .../IPushedAuthorizationRequestStore.cs | 20 ----- ...ushedAuthorizationRequestStoreException.cs | 25 ++++++ 13 files changed, 52 insertions(+), 126 deletions(-) create mode 100644 src/Storage/src/Stores/PushedAuthorizationRequestStoreException.cs diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs index 82c9ee68..b70da40a 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs @@ -182,7 +182,7 @@ public static IIdentityServerBuilder AddPluggableServices(this IIdentityServerBu builder.Services.TryAddTransient(); builder.Services.TryAddTransient(); builder.Services.TryAddTransient(); - builder.Services.TryAddTransient(); + builder.Services.TryAddTransient(); builder.Services.TryAddSingleton(); @@ -227,7 +227,7 @@ public static IIdentityServerBuilder AddValidators(this IIdentityServerBuilder b // PAR support builder.Services .AddTransientDecorator(); - // + return builder; } @@ -246,7 +246,7 @@ public static IIdentityServerBuilder AddResponseGenerators(this IIdentityServerB builder.Services.TryAddTransient(); 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 5b7106dc..02f18b82 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(); + builder.Services.TryAddSingleton(); return builder; } } \ 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 2404a402..df4cffa4 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs @@ -143,7 +143,7 @@ public class IdentityServerOptions public bool EnableAuthorizeResponseIssuerParam { get; set; } = false; /// - /// Enforce PAR for all authorization code flow requests + /// 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 index d5bed800..9770d237 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/PushedAuthorizationOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/PushedAuthorizationOptions.cs @@ -1,3 +1,5 @@ +// 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; @@ -8,7 +10,7 @@ namespace Open.IdentityServer.Configuration; public class PushedAuthorizationOptions { /// - /// Enforce PAR for all authorization code flow requests + /// Enforce PAR for all authorization requests /// public bool Required { get; set; } = false; diff --git a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs index f470b267..6c8fadd6 100644 --- a/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs +++ b/src/Open.IdentityServer/src/Endpoints/PushedAuthorizationEndpoint.cs @@ -31,7 +31,7 @@ public async Task ProcessAsync(HttpContext requestContext) { using ITrace trace = telemetry.Trace(TelemetryConstants.TraceCategories.Basic, this); - if ( options.Endpoints.EnablePushedAuthorizationRequestEndpoint == false) + if (options.Endpoints.EnablePushedAuthorizationRequestEndpoint == false) { return new StatusCodeResult(HttpStatusCode.NotFound); } @@ -43,7 +43,7 @@ public async Task ProcessAsync(HttpContext requestContext) } ClientSecretValidationResult? clientValidationResult = await clientSecretValidator.ValidateAsync(requestContext); - if ( clientValidationResult.IsError) + if (clientValidationResult.IsError) { return Error(OidcConstants.TokenErrors.InvalidClient); } diff --git a/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs b/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs index f323f995..2ce98afc 100644 --- a/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs +++ b/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs @@ -14,7 +14,6 @@ public async Task ExecuteAsync(HttpContext context) { context.Response.StatusCode = StatusCodes.Status201Created; context.Response.ContentType = "application/json"; - await context.Response.WriteAsJsonAsync(Response); - + await context.Response.WriteAsJsonAsync(Response); } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs index 4bf70d2e..f2287989 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs @@ -1,14 +1,8 @@ // 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 Microsoft.Extensions.Logging; -using Open.IdentityServer.Configuration; using Open.IdentityServer.Services; -using Open.IdentityServer.Storage.Models; -using Open.IdentityServer.Stores; using Open.IdentityServer.Validation; namespace Open.IdentityServer.ResponseHandling; @@ -19,9 +13,7 @@ namespace Open.IdentityServer.ResponseHandling; /// Default implementation of the pushed authorization response generator /// /// The service used to manage the storing of the pushed authorization request for later retrieval -/// The logger to record errors and debug inforation -public class PushedAuthorizationResponseGenerator(IPushedAuthorizationRequestService service, - ILogger logger) : IPushedAuthorizationResponseGenerator +public class PushedAuthorizationResponseGenerator(IPushedAuthorizationRequestService service) : IPushedAuthorizationResponseGenerator { /// /// Generates the Pushed Authorization Request response @@ -32,7 +24,7 @@ public class PushedAuthorizationResponseGenerator(IPushedAuthorizationRequestSer { try { - PushedAuthorization response = await service.CreateAsync(request.Client,request.Raw); + PushedAuthorization response = await service.CreateAsync(request.Client,request.Raw); return new PushedAuthorizationResponse(response.Key, (long)response.ExpiresIn.TotalSeconds); } diff --git a/src/Open.IdentityServer/src/ResponseHandling/IPushedAuthorizationResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/IPushedAuthorizationResponseGenerator.cs index aa1ed9b2..1540cb70 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/IPushedAuthorizationResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/IPushedAuthorizationResponseGenerator.cs @@ -1,10 +1,12 @@ +// 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 { diff --git a/src/Open.IdentityServer/src/ResponseHandling/Models/PushedAuthorizationResponse.cs b/src/Open.IdentityServer/src/ResponseHandling/Models/PushedAuthorizationResponse.cs index 80584154..6c91091b 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Models/PushedAuthorizationResponse.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Models/PushedAuthorizationResponse.cs @@ -1,10 +1,12 @@ +// 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 diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultPushedAuthorizationRequestService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultPushedAuthorizationRequestService.cs index 9a2f853e..3e9b0fa8 100644 --- a/src/Open.IdentityServer/src/Services/Default/DefaultPushedAuthorizationRequestService.cs +++ b/src/Open.IdentityServer/src/Services/Default/DefaultPushedAuthorizationRequestService.cs @@ -1,5 +1,6 @@ +// 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; using System.Collections.Generic; using System.Collections.Specialized; using System.Threading.Tasks; @@ -19,6 +20,9 @@ internal class DefaultPushedAuthorizationRequestService( 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 @@ -54,9 +58,6 @@ await store.StorePushedAuthorizationRequestAsync( } } - - private static readonly List AuthenticationParameters = - ["client_secret", "client_assertion","client_assertion_type"]; private NameValueCollection RemoveAnyAuthenticationParameters(NameValueCollection src) { diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs index 5aa4168e..c5c23b3a 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/ResponseHandling/PushedAuthorizationResponseGeneratorTests.cs @@ -1,3 +1,5 @@ +// 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; @@ -48,85 +50,6 @@ public async Task CreateResponseAsync_WhenCalled_ShouldMapRequestCorrectlyAndSen response.Uri.Should().Be(expectedKey.ToString()); } - - // [Fact] - // public async Task CreateResponseAsync_WhenCalled_ShouldSetExpirationBasedOnOptions() - // { - // string generatedUniquePart = "sdufbsibdvibv"; - // - // _request.Client = new Client(); - // - // DateTime expectedExpiration; - // DateTime now = new DateTime(2027, 3, 2, 13, 10, 20); - // DateTimeOffset spiedExpiration = now; - // - // options.PushedAuthorization.Expiration = TimeSpan.FromSeconds(90); - // - // clock.Setup(c => c.GetUtcNow()).Returns(now); - // expectedExpiration = now.Add(options.PushedAuthorization.Expiration); - // - // _handleGenerationService.Setup(g => g.GenerateAsync()).ReturnsAsync(generatedUniquePart); - // - // var sut = CreateSut(); - // - // _store.Setup(s => s.StorePushedAuthorizationRequestAsync( It.IsAny())) - // .Callback(memento => spiedExpiration = memento.ValidUntil); - // - // await sut.CreateResponseAsync(_request); - // - // spiedExpiration.Should().Be(expectedExpiration); - // } - - // [Fact] - // public async Task CreateResponseAsync_WhenCalled_ShouldSetExpirationBasedOnClientProperties() - // { - // string generatedUniquePart = "sdufbsibdvibv"; - // - // _request.Client = new Client() - // { - // PushedAuthorizationLifetime = TimeSpan.FromSeconds(30).Seconds - // }; - // - // DateTime expectedExpiration; - // DateTime now = new DateTime(2027, 3, 2, 13, 10, 20); - // DateTimeOffset spiedExpiration = now; - // - // clock.Setup(c => c.GetUtcNow()).Returns(now); - // int expectedDuration = (int)_request.Client.PushedAuthorizationLifetime; - // expectedExpiration = now.AddSeconds(expectedDuration); - // - // _handleGenerationService.Setup(g => g.GenerateAsync()).ReturnsAsync(generatedUniquePart); - // - // var sut = CreateSut(); - // - // _store.Setup(s => s.StorePushedAuthorizationRequestAsync( It.IsAny())) - // .Callback( memento => spiedExpiration = memento.ValidUntil); - // - // var response = await sut.CreateResponseAsync(_request); - // - // response.Should().NotBeNull(); - // spiedExpiration.Should().Be(expectedExpiration); - // response.Lifetime.Should().Be(expectedDuration); - // } - - // [Fact] - // public async Task CreateResponseAsync_WhenCalled_ShouldGenerateResponseCorrectly() - // { - // string generatedUniquePart = "sdufbsibdvibv"; - // string expectedUri = - // $"{IdentityServerConstants.PushedAuthorizationRequest.UriRequestPrefix}{generatedUniquePart}"; - // - // _handleGenerationService.Setup(g => g.GenerateAsync()).ReturnsAsync(generatedUniquePart); - // - // var sut = CreateSut(); - // - // PushedAuthorizationResponse? response = await sut.CreateResponseAsync(_request); - // - // response.Should().NotBeNull(); - // response.Uri.Should().Be(expectedUri); - // response.Lifetime.Should().Be(PushedAuthorizationResponseGenerator.DefaultRequestLifetimeInSeconds); - // } - [Fact] public async Task CreateResponseAsync_WhenCalledAndServiceThrowsException_ShouldReturnNull() { @@ -142,6 +65,6 @@ public async Task CreateResponseAsync_WhenCalledAndServiceThrowsException_Should private PushedAuthorizationResponseGenerator CreateSut() { - return new PushedAuthorizationResponseGenerator(service.Object, _logger.Object); + return new PushedAuthorizationResponseGenerator(service.Object); } } \ No newline at end of file diff --git a/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs b/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs index f37083b0..c72f8267 100644 --- a/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs +++ b/src/Storage/src/Stores/IPushedAuthorizationRequestStore.cs @@ -1,7 +1,6 @@ // 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.Storage.Models; @@ -9,25 +8,6 @@ namespace Open.IdentityServer.Stores; #nullable enable -/// -/// 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) { } -} - /// /// Models the persistence of a pushed authorization request. /// diff --git a/src/Storage/src/Stores/PushedAuthorizationRequestStoreException.cs b/src/Storage/src/Stores/PushedAuthorizationRequestStoreException.cs new file mode 100644 index 00000000..e6d9d667 --- /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 From fa978810f10269d59ba31d34072806bfb3894538 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Tue, 8 Sep 2026 17:58:26 +0100 Subject: [PATCH 26/30] Reorganize Pushed Authorization request handling in discovery response generator for clarity and consistency --- .../Default/DiscoveryResponseGenerator.cs | 18 +++++++++--------- .../PushedAuthorizationResponseGenerator.cs | 2 +- .../IAuthorizeInteractionResponseGenerator.cs | 3 +-- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs index 03846591..72934bab 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/DiscoveryResponseGenerator.cs @@ -214,15 +214,6 @@ string ConstructMtlsEndpoint(string endpoint) AddPushedAuthorizationRequestEndpoint(baseUrl, entries); } - // If PAR is enabled - if (Options.Endpoints.EnablePushedAuthorizationRequestEndpoint) - { - entries.Add( - OidcConstants.Discovery.RequirePushedAuthorizationRequests, - Options.PushedAuthorization.Required - ); - } - // logout if (Options.Endpoints.EnableEndSessionEndpoint) { @@ -396,6 +387,15 @@ private void AddPushedAuthorizationRequestEndpoint(string baseUrl, Dictionary diff --git a/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs index f2287989..88b432f3 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/Default/PushedAuthorizationResponseGenerator.cs @@ -26,7 +26,7 @@ public class PushedAuthorizationResponseGenerator(IPushedAuthorizationRequestSer { PushedAuthorization response = await service.CreateAsync(request.Client,request.Raw); - return new PushedAuthorizationResponse(response.Key, (long)response.ExpiresIn.TotalSeconds); + return new PushedAuthorizationResponse(response.Key, (long)response.ExpiresIn.TotalSeconds); } catch (Exception) { diff --git a/src/Open.IdentityServer/src/ResponseHandling/IAuthorizeInteractionResponseGenerator.cs b/src/Open.IdentityServer/src/ResponseHandling/IAuthorizeInteractionResponseGenerator.cs index b317eda3..88dcff7e 100644 --- a/src/Open.IdentityServer/src/ResponseHandling/IAuthorizeInteractionResponseGenerator.cs +++ b/src/Open.IdentityServer/src/ResponseHandling/IAuthorizeInteractionResponseGenerator.cs @@ -20,5 +20,4 @@ public interface IAuthorizeInteractionResponseGenerator /// The consent. /// A task that resolves to an indicating whether the user must log in, consent, or can proceed with the authorization request. Task ProcessInteractionAsync(ValidatedAuthorizeRequest request, ConsentResponse consent = null); -} - +} \ No newline at end of file From 0915db414722c19376b9f5d2207d512938e2cb37 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Tue, 8 Sep 2026 20:57:44 +0100 Subject: [PATCH 27/30] Add cache control header to Pushed Authorization response and implement unit tests --- .../Results/PushedAuthorizationResult.cs | 2 ++ .../Results/PushedAuthorizationResultTests.cs | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/PushedAuthorizationResultTests.cs diff --git a/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs b/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs index 2ce98afc..ac9a3b2f 100644 --- a/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs +++ b/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs @@ -14,6 +14,8 @@ public async Task ExecuteAsync(HttpContext context) { context.Response.StatusCode = StatusCodes.Status201Created; context.Response.ContentType = "application/json"; + context.Response.Headers.CacheControl = "no-cache, no-store"; + await context.Response.WriteAsJsonAsync(Response); } } \ 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 00000000..8fdf670c --- /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-cache, no-store"); + } + + PushedAuthorizationResult CreateSut() + { + return new PushedAuthorizationResult(response); + } +} \ No newline at end of file From 26f2707b9fb4d81a06619295180ec1c7ddde5055 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Wed, 9 Sep 2026 10:13:01 +0100 Subject: [PATCH 28/30] Used extension method for SetNoCache rather than explictly setting the header --- .../src/Endpoints/Results/PushedAuthorizationResult.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs b/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs index ac9a3b2f..aaeee2ed 100644 --- a/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs +++ b/src/Open.IdentityServer/src/Endpoints/Results/PushedAuthorizationResult.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Open.IdentityServer.Extensions; using Open.IdentityServer.Hosting; using Open.IdentityServer.ResponseHandling; @@ -14,7 +15,7 @@ public async Task ExecuteAsync(HttpContext context) { context.Response.StatusCode = StatusCodes.Status201Created; context.Response.ContentType = "application/json"; - context.Response.Headers.CacheControl = "no-cache, no-store"; + context.Response.SetNoCache(); await context.Response.WriteAsJsonAsync(Response); } From d33f4b614706e781a302eed548f143e32b853002 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Wed, 9 Sep 2026 10:14:09 +0100 Subject: [PATCH 29/30] Forgot to push the test --- .../Endpoints/Results/PushedAuthorizationResultTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 8fdf670c..589e4fc0 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/PushedAuthorizationResultTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/PushedAuthorizationResultTests.cs @@ -24,7 +24,7 @@ public async Task ExecuteAsync_when_called_response_header_should_contain_disabl await sut.ExecuteAsync(context); - context.Response.Headers.CacheControl.Single().Should().Be("no-cache, no-store"); + context.Response.Headers.CacheControl.Single().Should().Be("no-store, no-cache, max-age=0"); } PushedAuthorizationResult CreateSut() From 4290750b9869cce314815ee263af135bdbbff7a0 Mon Sep 17 00:00:00 2001 From: andrewclymer Date: Wed, 9 Sep 2026 10:53:30 +0100 Subject: [PATCH 30/30] Reverted project reference back to a package reference --- .../src/Open.IdentityServer.AspNetIdentity.csproj | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/AspNetIdentity/src/Open.IdentityServer.AspNetIdentity.csproj b/src/AspNetIdentity/src/Open.IdentityServer.AspNetIdentity.csproj index ea0f6199..51a946a6 100644 --- a/src/AspNetIdentity/src/Open.IdentityServer.AspNetIdentity.csproj +++ b/src/AspNetIdentity/src/Open.IdentityServer.AspNetIdentity.csproj @@ -41,9 +41,7 @@ - - - +