Skip to content

Commit bef4dae

Browse files
authored
Support for Pushed Authorization (PAR) in OIDC Handler (#55069)
1 parent bbacc8a commit bef4dae

11 files changed

+605
-5
lines changed

src/Security/Authentication/OpenIdConnect/samples/OpenIdConnectSample/OpenIdConnectSample.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
<PropertyGroup>
44
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
5-
<UserSecretsId>aspnet5-OpenIdConnectSample-20151210110318</UserSecretsId>
5+
<UserSecretsId>aspnet5-OpenIdConnectSample-20151210110318</UserSecretsId>
66
<AspNetCoreHostingModel>OutOfProcess</AspNetCoreHostingModel>
77
</PropertyGroup>
88

src/Security/Authentication/OpenIdConnect/samples/OpenIdConnectSample/Startup.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,9 @@ public static bool DisallowsSameSiteNone(string userAgent)
6969
return true;
7070
}
7171

72-
// Cover Chrome 50-69, because some versions are broken by SameSite=None,
72+
// Cover Chrome 50-69, because some versions are broken by SameSite=None,
7373
// and none in this range require it.
74-
// Note: this covers some pre-Chromium Edge versions,
74+
// Note: this covers some pre-Chromium Edge versions,
7575
// but pre-Chromium Edge does not require SameSite=None.
7676
if (userAgent.Contains("Chrome/5") || userAgent.Contains("Chrome/6"))
7777
{

src/Security/Authentication/OpenIdConnect/src/Events/OpenIdConnectEvents.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ public class OpenIdConnectEvents : RemoteAuthenticationEvents
6161
/// </summary>
6262
public Func<UserInformationReceivedContext, Task> OnUserInformationReceived { get; set; } = context => Task.CompletedTask;
6363

64+
/// <summary>
65+
/// Invoked before authorization parameters are pushed using PAR.
66+
/// </summary>
67+
public Func<PushedAuthorizationContext, Task> OnPushAuthorization { get; set; } = context => Task.CompletedTask;
68+
6469
/// <summary>
6570
/// Invoked if exceptions are thrown during request processing. The exceptions will be re-thrown after this event unless suppressed.
6671
/// </summary>
@@ -113,4 +118,11 @@ public class OpenIdConnectEvents : RemoteAuthenticationEvents
113118
/// Invoked when user information is retrieved from the UserInfoEndpoint.
114119
/// </summary>
115120
public virtual Task UserInformationReceived(UserInformationReceivedContext context) => OnUserInformationReceived(context);
121+
122+
/// <summary>
123+
/// Invoked before authorization parameters are pushed during PAR.
124+
/// </summary>
125+
/// <param name="context"></param>
126+
/// <returns></returns>
127+
public virtual Task PushAuthorization(PushedAuthorizationContext context) => OnPushAuthorization(context);
116128
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics.CodeAnalysis;
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
7+
8+
namespace Microsoft.AspNetCore.Authentication.OpenIdConnect;
9+
10+
/// <summary>
11+
/// A context for <see cref="OpenIdConnectEvents.PushAuthorization(PushedAuthorizationContext)"/>.
12+
/// </summary>
13+
public sealed class PushedAuthorizationContext : PropertiesContext<OpenIdConnectOptions>
14+
{
15+
/// <summary>
16+
/// Initializes a new instance of <see cref="PushedAuthorizationContext"/>.
17+
/// </summary>
18+
/// <inheritdoc />
19+
public PushedAuthorizationContext(HttpContext context, AuthenticationScheme scheme, OpenIdConnectOptions options, OpenIdConnectMessage parRequest, AuthenticationProperties properties)
20+
: base(context, scheme, options, properties)
21+
{
22+
ProtocolMessage = parRequest;
23+
}
24+
25+
/// <summary>
26+
/// Gets or sets the <see cref="OpenIdConnectMessage"/> that will be sent to the PAR endpoint.
27+
/// </summary>
28+
public OpenIdConnectMessage ProtocolMessage { get; }
29+
30+
/// <summary>
31+
/// Indicates if the OnPushAuthorization event chose to handle pushing the
32+
/// authorization request. If true, the handler will not attempt to push the
33+
/// authorization request, and will instead use the RequestUri from this
34+
/// event in the subsequent authorize request.
35+
/// </summary>
36+
public bool HandledPush { [MemberNotNull("RequestUri")] get; private set; }
37+
38+
/// <summary>
39+
/// Tells the handler that the OnPushAuthorization event has handled the process of pushing
40+
/// authorization, and that the handler should use the provided request_uri
41+
/// on the subsequent authorize call.
42+
/// </summary>
43+
public void HandlePush(string requestUri)
44+
{
45+
if (SkippedPush || HandledClientAuthentication)
46+
{
47+
throw new InvalidOperationException("Only one of HandlePush, SkipPush, and HandledClientAuthentication may be called in the OnPushAuthorization event.");
48+
}
49+
HandledPush = true;
50+
RequestUri = requestUri;
51+
}
52+
53+
/// <summary>
54+
/// Indicates if the OnPushAuthorization event chose to skip pushing the
55+
/// authorization request. If true, the handler will not attempt to push the
56+
/// authorization request, and will not use pushed authorization in the
57+
/// subsequent authorize request.
58+
/// </summary>
59+
public bool SkippedPush { get; private set; }
60+
61+
/// <summary>
62+
/// The request_uri parameter to use in the subsequent authorize call, if
63+
/// the OnPushAuthorization event chose to handle pushing the authorization
64+
/// request, and null otherwise.
65+
/// </summary>
66+
public string? RequestUri { get; private set; }
67+
68+
/// <summary>
69+
/// Tells the handler to skip pushing authorization entirely. If this is
70+
/// called, the handler will not use pushed authorization on the subsequent
71+
/// authorize call.
72+
/// </summary>
73+
public void SkipPush()
74+
{
75+
if (HandledPush || HandledClientAuthentication)
76+
{
77+
throw new InvalidOperationException("Only one of HandlePush, SkipPush, and HandledClientAuthentication may be called in the OnPushAuthorization event.");
78+
}
79+
SkippedPush = true;
80+
}
81+
82+
/// <summary>
83+
/// Indicates if the OnPushAuthorization event chose to handle client
84+
/// authentication for the pushed authorization request. If true, the
85+
/// handler will not attempt to set authentication parameters for the pushed
86+
/// authorization request.
87+
/// </summary>
88+
public bool HandledClientAuthentication { get; private set; }
89+
90+
/// <summary>
91+
/// Tells the handler to skip setting client authentication properties for
92+
/// pushed authorization. The handler uses the client_secret_basic
93+
/// authentication mode by default, but the OnPushAuthorization event may
94+
/// replace that with an alternative authentication mode, such as
95+
/// private_key_jwt.
96+
/// </summary>
97+
public void HandleClientAuthentication()
98+
{
99+
if (SkippedPush || HandledPush)
100+
{
101+
throw new InvalidOperationException("Only one of HandlePush, SkipPush, and HandledClientAuthentication may be called in the OnPushAuthorization event.");
102+
}
103+
HandledClientAuthentication = true;
104+
}
105+
}
106+

src/Security/Authentication/OpenIdConnect/src/LoggingExtensions.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ internal static partial class LoggingExtensions
7474
[LoggerMessage(37, LogLevel.Debug, "The UserInformationReceived event returned Skipped.", EventName = "UserInformationReceivedSkipped")]
7575
public static partial void UserInformationReceivedSkipped(this ILogger logger);
7676

77+
[LoggerMessage(57, LogLevel.Debug, "The PushAuthorization event handled client authentication", EventName = "PushAuthorizationHandledClientAuthentication")]
78+
public static partial void PushAuthorizationHandledClientAuthentication(this ILogger logger);
79+
80+
[LoggerMessage(58, LogLevel.Debug, "The PushAuthorization event handled pushing authorization", EventName = "PushAuthorizationHandledPush")]
81+
public static partial void PushAuthorizationHandledPush(this ILogger logger);
82+
83+
[LoggerMessage(59, LogLevel.Debug, "The PushAuthorization event skipped pushing authorization", EventName = "PushAuthorizationSkippedPush")]
84+
public static partial void PushAuthorizationSkippedPush(this ILogger logger);
85+
7786
[LoggerMessage(3, LogLevel.Warning, "The query string for Logout is not a well-formed URI. Redirect URI: '{RedirectUrl}'.", EventName = "InvalidLogoutQueryStringRedirectUrl")]
7887
public static partial void InvalidLogoutQueryStringRedirectUrl(this ILogger logger, string redirectUrl);
7988

src/Security/Authentication/OpenIdConnect/src/OpenIdConnectHandler.cs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,40 @@ private async Task HandleChallengeAsyncInternal(AuthenticationProperties propert
485485
"Cannot redirect to the authorization endpoint, the configuration may be missing or invalid.");
486486
}
487487

488+
var parEndpoint = _configuration?.PushedAuthorizationRequestEndpoint;
489+
490+
switch (Options.PushedAuthorizationBehavior)
491+
{
492+
case PushedAuthorizationBehavior.UseIfAvailable:
493+
// Push if endpoint is in disco
494+
if (!string.IsNullOrEmpty(parEndpoint))
495+
{
496+
await PushAuthorizationRequest(message, properties);
497+
}
498+
499+
break;
500+
case PushedAuthorizationBehavior.Disable:
501+
// Fail if disabled in options but required by disco
502+
if (_configuration?.RequirePushedAuthorizationRequests == true)
503+
{
504+
throw new InvalidOperationException("Pushed authorization is required by the OpenId Connect provider, but disabled by the OpenIdConnectOptions.PushedAuthorizationBehavior.");
505+
}
506+
507+
// Otherwise do nothing
508+
break;
509+
case PushedAuthorizationBehavior.Require:
510+
// Fail if required in options but unavailable in disco
511+
var endpointIsConfigured = !string.IsNullOrEmpty(parEndpoint);
512+
if (!endpointIsConfigured)
513+
{
514+
throw new InvalidOperationException("Pushed authorization is required by the OpenIdConnectOptions.PushedAuthorizationBehavior, but no pushed authorization endpoint is available.");
515+
}
516+
517+
// Otherwise push
518+
await PushAuthorizationRequest(message, properties);
519+
break;
520+
}
521+
488522
if (Options.AuthenticationMethod == OpenIdConnectRedirectBehavior.RedirectGet)
489523
{
490524
var redirectUri = message.CreateAuthenticationRequestUrl();
@@ -516,6 +550,82 @@ private async Task HandleChallengeAsyncInternal(AuthenticationProperties propert
516550
throw new NotImplementedException($"An unsupported authentication method has been configured: {Options.AuthenticationMethod}");
517551
}
518552

553+
private async Task PushAuthorizationRequest(OpenIdConnectMessage authorizeRequest, AuthenticationProperties properties)
554+
{
555+
// Build context and run event
556+
var parRequest = authorizeRequest.Clone();
557+
var context = new PushedAuthorizationContext(Context, Scheme, Options, parRequest, properties);
558+
await Events.PushAuthorization(context);
559+
560+
// If the event handled client authentication, skip the default auth behavior
561+
if (context.HandledClientAuthentication)
562+
{
563+
Logger.PushAuthorizationHandledClientAuthentication();
564+
}
565+
// Otherwise, add the client secret to the parameters (if available)
566+
else
567+
{
568+
if (!string.IsNullOrEmpty(Options.ClientSecret))
569+
{
570+
parRequest.Parameters.Add(OpenIdConnectParameterNames.ClientSecret, Options.ClientSecret);
571+
}
572+
}
573+
574+
string requestUri;
575+
576+
// The event can either entirely skip pushing to the par endpoint...
577+
if (context.SkippedPush)
578+
{
579+
Logger.PushAuthorizationSkippedPush();
580+
return;
581+
}
582+
// ... or handle pushing to the par endpoint itself, in which case it will supply the request uri
583+
else if (context.HandledPush)
584+
{
585+
Logger.PushAuthorizationHandledPush();
586+
requestUri = context.RequestUri;
587+
}
588+
else
589+
{
590+
var parEndpoint = _configuration?.PushedAuthorizationRequestEndpoint;
591+
if (string.IsNullOrEmpty(parEndpoint))
592+
{
593+
new InvalidOperationException("Attempt to push authorization with no pushed authorization endpoint configured.");
594+
}
595+
596+
var requestMessage = new HttpRequestMessage(HttpMethod.Post, parEndpoint);
597+
requestMessage.Content = new FormUrlEncodedContent(parRequest.Parameters);
598+
requestMessage.Version = Backchannel.DefaultRequestVersion;
599+
var parResponseMessage = await Backchannel.SendAsync(requestMessage, Context.RequestAborted);
600+
requestUri = await GetPushedAuthorizationRequestUri(parResponseMessage);
601+
}
602+
603+
authorizeRequest.Parameters.Clear();
604+
authorizeRequest.Parameters.Add("client_id", Options.ClientId);
605+
authorizeRequest.Parameters.Add("request_uri", requestUri);
606+
}
607+
608+
private async Task<string> GetPushedAuthorizationRequestUri(HttpResponseMessage parResponseMessage)
609+
{
610+
// Check content type
611+
var contentType = parResponseMessage.Content.Headers.ContentType;
612+
if (!(contentType?.MediaType?.Equals("application/json", StringComparison.OrdinalIgnoreCase) ?? false))
613+
{
614+
throw new InvalidOperationException("Invalid response from pushed authorization: content type is not application/json.");
615+
}
616+
617+
// Parse response
618+
var parResponseString = await parResponseMessage.Content.ReadAsStringAsync(Context.RequestAborted);
619+
var message = new OpenIdConnectMessage(parResponseString);
620+
621+
var requestUri = message.GetParameter("request_uri");
622+
if (requestUri == null)
623+
{
624+
throw CreateOpenIdConnectProtocolException(message, parResponseMessage);
625+
}
626+
return requestUri;
627+
}
628+
519629
/// <summary>
520630
/// Invoked to process incoming OpenIdConnect messages.
521631
/// </summary>

src/Security/Authentication/OpenIdConnect/src/OpenIdConnectOptions.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,4 +408,13 @@ public bool MapInboundClaims
408408
/// When using TokenHandler, <see cref="TokenValidatedContext.SecurityToken"/> will be a <see cref="JsonWebToken"/>.
409409
/// </remarks>
410410
public bool UseSecurityTokenValidator { get; set; }
411+
412+
/// <summary>
413+
/// Controls wether the handler should push authorization parameters on the
414+
/// backchannel before redirecting to the identity provider. See <see
415+
/// href="https://tools.ietf.org/html/9126"/>.
416+
/// </summary>
417+
/// <value>Defaults to <see
418+
/// cref="PushedAuthorizationBehavior.UseIfAvailable" />.</value>
419+
public PushedAuthorizationBehavior PushedAuthorizationBehavior { get; set; } = PushedAuthorizationBehavior.UseIfAvailable;
411420
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,21 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectEvents.OnPushAuthorization.get -> System.Func<Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationContext!, System.Threading.Tasks.Task!>!
3+
Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectEvents.OnPushAuthorization.set -> void
24
Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectOptions.AdditionalAuthorizationParameters.get -> System.Collections.Generic.IDictionary<string!, string!>!
5+
Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectOptions.PushedAuthorizationBehavior.get -> Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationBehavior
6+
Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectOptions.PushedAuthorizationBehavior.set -> void
7+
Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationBehavior
8+
Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationBehavior.Disable = 1 -> Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationBehavior
9+
Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationBehavior.Require = 2 -> Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationBehavior
10+
Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationBehavior.UseIfAvailable = 0 -> Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationBehavior
11+
Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationContext
12+
Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationContext.HandleClientAuthentication() -> void
13+
Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationContext.HandledClientAuthentication.get -> bool
14+
Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationContext.HandledPush.get -> bool
15+
Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationContext.HandlePush(string! requestUri) -> void
16+
Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationContext.ProtocolMessage.get -> Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectMessage!
17+
Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationContext.PushedAuthorizationContext(Microsoft.AspNetCore.Http.HttpContext! context, Microsoft.AspNetCore.Authentication.AuthenticationScheme! scheme, Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectOptions! options, Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectMessage! parRequest, Microsoft.AspNetCore.Authentication.AuthenticationProperties! properties) -> void
18+
Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationContext.RequestUri.get -> string?
19+
Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationContext.SkippedPush.get -> bool
20+
Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationContext.SkipPush() -> void
21+
virtual Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectEvents.PushAuthorization(Microsoft.AspNetCore.Authentication.OpenIdConnect.PushedAuthorizationContext! context) -> System.Threading.Tasks.Task!
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
5+
6+
namespace Microsoft.AspNetCore.Authentication.OpenIdConnect;
7+
8+
/// <summary>
9+
/// Enum containing the options for use of Pushed Authorization (PAR).
10+
/// </summary>
11+
public enum PushedAuthorizationBehavior
12+
{
13+
/// <summary>
14+
/// Use Pushed Authorization (PAR) if the PAR endpoint is available in the identity provider's discovery document or the explicit <see cref="OpenIdConnectConfiguration"/>. This is the default value.
15+
/// </summary>
16+
UseIfAvailable,
17+
/// <summary>
18+
/// Never use Pushed Authorization (PAR), even if the PAR endpoint is available in the identity provider's discovery document or the explicit <see cref="OpenIdConnectConfiguration"/>.
19+
/// If the identity provider's discovery document indicates that it requires Pushed Authorization (PAR), the handler will fail.
20+
/// </summary>
21+
Disable,
22+
/// <summary>
23+
/// Always use Pushed Authorization (PAR), and emit errors if the PAR endpoint is not available in the identity provider's discovery document or the explicit <see cref="OpenIdConnectConfiguration"/>.
24+
/// </summary>
25+
Require
26+
}

0 commit comments

Comments
 (0)