Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow using response_type=token with PKCE when response type permissions are enforced #2088

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/OpenIddict.Client/OpenIddictClientHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4581,8 +4581,9 @@ public ValueTask HandleAsync(ProcessChallengeContext context)
=> (GrantTypes.Implicit, ResponseTypes.IdToken),

// Note: response types combinations containing "token" are always tested last as some
// authorization servers - like OpenIddict - are known to block authorization requests
// asking for an access token if Proof Key for Code Exchange is used in the same request.
// authorization servers (e.g OpenIddict when response type permissions are disabled)
// are known to mitigate downgrade attacks by blocking authorization requests asking
// for an access token if Proof Key for Code Exchange is used in the same request.
//
// Returning an identity token directly from the authorization endpoint also has privacy
// concerns that code-based flows - that require a backchannel request - typically don't
Expand Down
98 changes: 60 additions & 38 deletions src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -996,19 +996,6 @@ public ValueTask HandleAsync(ValidateAuthorizationRequestContext context)
return default;
}

// Reject authorization requests that contain response_type=token when a code_challenge is specified.
if (context.Request.HasResponseType(ResponseTypes.Token))
{
context.Logger.LogInformation(SR.GetResourceString(SR.ID6043));

context.Reject(
error: Errors.InvalidRequest,
description: SR.FormatID2041(Parameters.ResponseType),
uri: SR.FormatID8000(SR.ID2041));

return default;
}

return default;
}
}
Expand Down Expand Up @@ -1075,27 +1062,34 @@ public async ValueTask HandleAsync(ValidateAuthorizationRequestContext context)
}

/// <summary>
/// Contains the logic responsible for rejecting authorization requests that use a
/// response_type containing token if the application is a confidential client.
/// Note: this handler is not used when the degraded mode is enabled
/// or when response type permissions enforcement is not disabled.
/// Contains the logic responsible for rejecting authorization requests that use an unsafe response type.
/// </summary>
public sealed class ValidateResponseType : IOpenIddictServerHandler<ValidateAuthorizationRequestContext>
{
private readonly IOpenIddictApplicationManager _applicationManager;
private readonly IOpenIddictApplicationManager? _applicationManager;

public ValidateResponseType() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016));
[Obsolete("This constructor is no longer supported and will be removed in a future version.", error: true)]
public ValidateResponseType() => throw new NotSupportedException(SR.GetResourceString(SR.ID0403));

public ValidateResponseType(IOpenIddictApplicationManager applicationManager)
=> _applicationManager = applicationManager ?? throw new ArgumentNullException(nameof(applicationManager));
public ValidateResponseType(IOpenIddictApplicationManager? applicationManager = null)
=> _applicationManager = applicationManager;

/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
= OpenIddictServerHandlerDescriptor.CreateBuilder<ValidateAuthorizationRequestContext>()
.AddFilter<RequireDegradedModeDisabled>()
.UseScopedHandler<ValidateResponseType>()
.UseScopedHandler<ValidateResponseType>(static provider =>
{
// Note: the application manager is only resolved if the degraded mode was not enabled to ensure
// invalid core configuration exceptions are not thrown even if the managers were registered.
var options = provider.GetRequiredService<IOptionsMonitor<OpenIddictServerOptions>>().CurrentValue;

return options.EnableDegradedMode ?
new ValidateResponseType(applicationManager: null) :
new ValidateResponseType(provider.GetService<IOpenIddictApplicationManager>() ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)));
})
.SetOrder(ValidateAuthentication.Descriptor.Order + 1_000)
.SetType(OpenIddictServerHandlerType.BuiltIn)
.Build();
Expand All @@ -1108,33 +1102,61 @@ public async ValueTask HandleAsync(ValidateAuthorizationRequestContext context)
throw new ArgumentNullException(nameof(context));
}

Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId));

var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0032));

// To prevent downgrade attacks, ensure that authorization requests returning an access token directly
// from the authorization endpoint are rejected if the client_id corresponds to a confidential application
// and if response type permissions enforcement was explicitly disabled in the server options.
// Users who want to enable this advanced scenario are encouraged to re-enable permissions validation.
// Note: this handler is responsible for enforcing additional response_type requirements when
// response type permissions are not used (and thus cannot be finely controlled per client).
//
// Alternatively, this handler can be removed from the handlers list using the events model APIs.
if (!context.Options.IgnoreResponseTypePermissions || !context.Request.HasResponseType(ResponseTypes.Token))
// Users who want to support the scenarios disallowed by this event handler are encouraged
// to re-enable permissions validation. Alternatively, this handler can be removed from
// the handlers list and replaced by a custom version using the events model APIs.
if (!context.Options.IgnoreResponseTypePermissions)
{
return;
}

if (await _applicationManager.HasClientTypeAsync(application, ClientTypes.Confidential))
Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId));

// When PKCE is used, reject authorization requests returning an access token directly
// from the authorization endpoint to prevent a malicious client from retrieving a valid
// access token - even with a limited scope - without sending the correct code_verifier.
if (!string.IsNullOrEmpty(context.Request.CodeChallenge) &&
context.Request.HasResponseType(ResponseTypes.Token))
{
context.Logger.LogInformation(SR.GetResourceString(SR.ID6045), context.ClientId);
context.Logger.LogInformation(SR.GetResourceString(SR.ID6043));

context.Reject(
error: Errors.UnauthorizedClient,
description: SR.FormatID2043(Parameters.ResponseType),
uri: SR.FormatID8000(SR.ID2043));
description: SR.FormatID2041(Parameters.ResponseType),
uri: SR.FormatID8000(SR.ID2041));

return;
}

if (!context.Options.EnableDegradedMode)
{
if (_applicationManager is null)
{
throw new InvalidOperationException(SR.GetResourceString(SR.ID0016));
}

var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0032));

// To prevent downgrade attacks, ensure that authorization requests returning
// an access token directly from the authorization endpoint are rejected if
// the client_id corresponds to a confidential application.
if (context.Request.HasResponseType(ResponseTypes.Token) &&
await _applicationManager.HasClientTypeAsync(application, ClientTypes.Confidential))
{
context.Logger.LogInformation(SR.GetResourceString(SR.ID6045), context.ClientId);

context.Reject(
error: Errors.UnauthorizedClient,
description: SR.FormatID2043(Parameters.ResponseType),
uri: SR.FormatID8000(SR.ID2043));

return;
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1045,33 +1045,6 @@ public async Task ValidateAuthorizationRequest_RequestIsValidatedWhenCodeChallen
Assert.NotNull(response.Code);
}

[Theory]
[InlineData("code id_token token")]
[InlineData("code token")]
public async Task ValidateAuthorizationRequest_PkceRequestWithForbiddenResponseTypeIsRejected(string type)
{
// Arrange
await using var server = await CreateServerAsync(options => options.EnableDegradedMode());
await using var client = await server.CreateClientAsync();

// Act
var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest
{
ClientId = "Fabrikam",
CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
CodeChallengeMethod = CodeChallengeMethods.Sha256,
Nonce = "n-0S6_WzA2Mj",
RedirectUri = "http://www.fabrikam.com/path",
ResponseType = type,
Scope = Scopes.OpenId
});

// Assert
Assert.Equal(Errors.InvalidRequest, response.Error);
Assert.Equal(SR.FormatID2041(Parameters.ResponseType), response.ErrorDescription);
Assert.Equal(SR.FormatID8000(SR.ID2041), response.ErrorUri);
}

[Fact]
public async Task ValidateAuthorizationRequest_RequestIsRejectedWhenRedirectUriIsMissing()
{
Expand Down Expand Up @@ -1183,6 +1156,154 @@ public async Task ValidateAuthorizationRequest_RequestIsRejectedWhenClientCannot
Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny<CancellationToken>()), Times.AtLeastOnce());
}

[Theory]
[InlineData("code id_token token")]
[InlineData("code token")]
public async Task ValidateAuthorizationRequest_PkceRequestWithSensitiveResponseTypeIsRejectedWhenDegradedModeIsEnabled(string type)
{
// Arrange
await using var server = await CreateServerAsync(options => options.EnableDegradedMode());
await using var client = await server.CreateClientAsync();

// Act
var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest
{
ClientId = "Fabrikam",
CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
CodeChallengeMethod = CodeChallengeMethods.Sha256,
Nonce = "n-0S6_WzA2Mj",
RedirectUri = "http://www.fabrikam.com/path",
ResponseType = type,
Scope = Scopes.OpenId
});

// Assert
Assert.Equal(Errors.UnauthorizedClient, response.Error);
Assert.Equal(SR.FormatID2041(Parameters.ResponseType), response.ErrorDescription);
Assert.Equal(SR.FormatID8000(SR.ID2041), response.ErrorUri);
}

[Theory]
[InlineData("code id_token token")]
[InlineData("code token")]
public async Task ValidateAuthorizationRequest_PkceRequestWithSensitiveResponseTypeIsRejectedWhenPermissionsAreIgnored(string type)
{
// Arrange
var application = new OpenIddictApplication();

var manager = CreateApplicationManager(mock =>
{
mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny<CancellationToken>()))
.ReturnsAsync(application);

mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);

mock.Setup(manager => manager.GetSettingsAsync(application, It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableDictionary.Create<string, string>());
});

await using var server = await CreateServerAsync(options =>
{
options.Services.AddSingleton(manager);

options.AddEventHandler<HandleAuthorizationRequestContext>(builder =>
builder.UseInlineHandler(context =>
{
context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer"))
.SetClaim(Claims.Subject, "Bob le Magnifique");

return default;
}));
});

await using var client = await server.CreateClientAsync();

// Act
var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest
{
ClientId = "Fabrikam",
CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
CodeChallengeMethod = CodeChallengeMethods.Sha256,
Nonce = "n-0S6_WzA2Mj",
RedirectUri = "http://www.fabrikam.com/path",
ResponseType = type,
Scope = Scopes.OpenId
});

// Assert
Assert.Equal(Errors.UnauthorizedClient, response.Error);
Assert.Equal(SR.FormatID2041(Parameters.ResponseType), response.ErrorDescription);
Assert.Equal(SR.FormatID8000(SR.ID2041), response.ErrorUri);
}

[Theory]
[InlineData("code id_token token")]
[InlineData("code token")]
public async Task ValidateAuthorizationRequest_PkceRequestWithSensitiveResponseTypeIsValidatedWhenPermissionsAreEnforced(string type)
{
// Arrange
var application = new OpenIddictApplication();

var manager = CreateApplicationManager(mock =>
{
mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny<CancellationToken>()))
.ReturnsAsync(application);

mock.Setup(manager => manager.GetPermissionsAsync(application, It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableArray.Create("rst:" + type));

mock.Setup(manager => manager.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);

mock.Setup(manager => manager.GetSettingsAsync(application, It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableDictionary.Create<string, string>());
});

await using var server = await CreateServerAsync(options =>
{
options.SetDeviceEndpointUris(Array.Empty<Uri>());
options.SetRevocationEndpointUris(Array.Empty<Uri>());
options.Configure(options => options.GrantTypes.Remove(GrantTypes.DeviceCode));
options.DisableAuthorizationStorage();
options.DisableTokenStorage();
options.DisableSlidingRefreshTokenExpiration();

options.Configure(options => options.IgnoreResponseTypePermissions = false);

options.Services.AddSingleton(manager);

options.AddEventHandler<HandleAuthorizationRequestContext>(builder =>
builder.UseInlineHandler(context =>
{
context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer"))
.SetClaim(Claims.Subject, "Bob le Magnifique");

return default;
}));
});

await using var client = await server.CreateClientAsync();

// Act
var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest
{
ClientId = "Fabrikam",
CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
CodeChallengeMethod = CodeChallengeMethods.Sha256,
Nonce = "n-0S6_WzA2Mj",
RedirectUri = "http://www.fabrikam.com/path",
ResponseType = type,
Scope = Scopes.OpenId
});

// Assert
Assert.Null(response.Error);
Assert.Null(response.ErrorDescription);
Assert.Null(response.ErrorUri);
Assert.NotNull(response.Code);
}

[Theory]
[InlineData("code id_token token")]
[InlineData("code token")]
Expand Down