From a1215728db33dcc70d5c1e50d258fc2aba37867f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Tue, 15 Mar 2022 20:48:43 +0100 Subject: [PATCH] Implement "iss" support in the server stack and update the token validation logic to support issuers that don't end with a trailing slash --- .../OpenIddictConstants.cs | 4 + .../OpenIddictResources.resx | 9 ++ .../Primitives/OpenIddictResponse.cs | 9 ++ .../OpenIddictClientConfiguration.cs | 2 +- .../OpenIddictClientHandlers.Protection.cs | 19 ++- .../OpenIddictClientHandlers.Userinfo.cs | 2 +- .../OpenIddictClientHandlers.cs | 3 +- .../Managers/OpenIddictApplicationManager.cs | 13 ++ .../OpenIddictServerAspNetCoreHandlers.cs | 2 +- .../OpenIddictServerOwinHandlers.cs | 2 +- ...OpenIddictServerHandlers.Authentication.cs | 80 +++++++++++- .../OpenIddictServerHandlers.Discovery.cs | 7 +- .../OpenIddictServerHandlers.cs | 2 +- .../OpenIddictValidationConfiguration.cs | 2 +- ...nIddictValidationHandlers.Introspection.cs | 4 +- ...OpenIddictValidationHandlers.Protection.cs | 20 ++- .../Primitives/OpenIddictResponseTests.cs | 7 ++ ...ctServerIntegrationTests.Authentication.cs | 117 ++++++++++++++++++ .../OpenIddictServerIntegrationTests.cs | 7 +- 19 files changed, 293 insertions(+), 18 deletions(-) diff --git a/src/OpenIddict.Abstractions/OpenIddictConstants.cs b/src/OpenIddict.Abstractions/OpenIddictConstants.cs index b078ab77..9d4c9d57 100644 --- a/src/OpenIddict.Abstractions/OpenIddictConstants.cs +++ b/src/OpenIddict.Abstractions/OpenIddictConstants.cs @@ -439,6 +439,10 @@ public static class OpenIddictConstants public static readonly char[] Ampersand = { '&' }; public static readonly char[] Dash = { '-' }; public static readonly char[] Dot = { '.' }; + public static readonly char[] EqualsSign = { '=' }; + public static readonly char[] Hash = { '#' }; + public static readonly char[] QuestionMark = { '?' }; + public static readonly char[] Semicolon = { ';' }; public static readonly char[] Space = { ' ' }; } diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index a38aba6c..7fa4af06 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -1587,6 +1587,12 @@ To apply redirection responses, create a class implementing 'IOpenIddictClientHa The '{0}' claim returned in the specified userinfo response/token doesn't match the expected value. + + Callback URLs cannot contain an "{0}" parameter. + + + The '{0}' parameter must not include a '{1}' component. + The '{0}' parameter shouldn't be null or empty at this point. @@ -2132,6 +2138,9 @@ This may indicate that the hashed entry is corrupted or malformed. The redirection request was successfully validated. + + The authorization request was rejected because the '{Parameter}' contained a forbidden parameter: {Name}. + https://documentation.openiddict.com/errors/{0} diff --git a/src/OpenIddict.Abstractions/Primitives/OpenIddictResponse.cs b/src/OpenIddict.Abstractions/Primitives/OpenIddictResponse.cs index 12671a6e..e2cc9836 100644 --- a/src/OpenIddict.Abstractions/Primitives/OpenIddictResponse.cs +++ b/src/OpenIddict.Abstractions/Primitives/OpenIddictResponse.cs @@ -153,6 +153,15 @@ public class OpenIddictResponse : OpenIddictMessage set => SetParameter(OpenIddictConstants.Parameters.IdToken, value); } + /// + /// Gets or sets the "iss" parameter. + /// + public string? Iss + { + get => (string?) GetParameter(OpenIddictConstants.Parameters.Iss); + set => SetParameter(OpenIddictConstants.Parameters.Iss, value); + } + /// /// Gets or sets the "refresh_token" parameter. /// diff --git a/src/OpenIddict.Client/OpenIddictClientConfiguration.cs b/src/OpenIddict.Client/OpenIddictClientConfiguration.cs index bfc0c45b..04de69ef 100644 --- a/src/OpenIddict.Client/OpenIddictClientConfiguration.cs +++ b/src/OpenIddict.Client/OpenIddictClientConfiguration.cs @@ -65,7 +65,7 @@ public class OpenIddictClientConfiguration : IPostConfigureOptions null, + + // If the issuer URI doesn't contain any path/query/fragment, allow both http://www.fabrikam.com + // and http://www.fabrikam.com/ (the recommended URI representation) to be considered valid. + // See https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.3 for more information. + { AbsolutePath: "/", Query.Length: 0, Fragment.Length: 0 } issuer => new[] + { + issuer.AbsoluteUri, // Uri.AbsoluteUri is normalized and always contains a trailing slash. + issuer.AbsoluteUri.Substring(0, issuer.AbsoluteUri.Length - 1) + }, + + Uri issuer => new[] { issuer.AbsoluteUri } + }; + + parameters.ValidateIssuer = parameters.ValidIssuers is not null; // Combine the signing keys registered statically in the token validation parameters // with the signing keys resolved from the OpenID Connect server configuration. diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.Userinfo.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.Userinfo.cs index 0ee86817..a14aeeec 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.Userinfo.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.Userinfo.cs @@ -129,7 +129,7 @@ public static partial class OpenIddictClientHandlers // Resolve the issuer that will be attached to the claims created by this handler. // Note: at this stage, the optional issuer extracted from the response is assumed // to be valid, as it is guarded against unknown values by the ValidateIssuer handler. - var issuer = (string?) context.Response[Claims.Issuer] ?? configuration.Issuer!.OriginalString; + var issuer = (string?) context.Response[Claims.Issuer] ?? configuration.Issuer!.AbsoluteUri; foreach (var parameter in context.Response.GetParameters()) { diff --git a/src/OpenIddict.Client/OpenIddictClientHandlers.cs b/src/OpenIddict.Client/OpenIddictClientHandlers.cs index 0a50834f..8f25bc61 100644 --- a/src/OpenIddict.Client/OpenIddictClientHandlers.cs +++ b/src/OpenIddict.Client/OpenIddictClientHandlers.cs @@ -445,7 +445,8 @@ public static partial class OpenIddictClientHandlers } // If the two values don't match, this may indicate a mix-up attack attempt. - if (!string.Equals(issuer, context.Issuer.AbsoluteUri, StringComparison.Ordinal)) + if (!Uri.TryCreate(issuer, UriKind.Absolute, out Uri? uri) || + !uri.IsWellFormedOriginalString() || uri != configuration.Issuer) { context.Reject( error: Errors.InvalidRequest, diff --git a/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs b/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs index 4b8c67bd..9625b8af 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs @@ -1226,6 +1226,19 @@ public class OpenIddictApplicationManager : IOpenIddictApplication break; } + + // To prevent issuer fixation attacks where a malicious client would specify an "iss" parameter + // in the callback URL, ensure the query - if present - doesn't include an "iss" parameter. + if (!string.IsNullOrEmpty(uri.Query) && uri.Query.TrimStart(Separators.QuestionMark[0]) + .Split(new[] { Separators.Ampersand[0], Separators.Semicolon[0] }, StringSplitOptions.RemoveEmptyEntries) + .Select(parameter => parameter.Split(Separators.EqualsSign, StringSplitOptions.RemoveEmptyEntries)) + .Select(parts => parts[0] is string value ? Uri.UnescapeDataString(value) : null) + .Contains(Parameters.Iss, StringComparer.OrdinalIgnoreCase)) + { + yield return new ValidationResult(SR.FormatID2134(Parameters.Iss)); + + break; + } } } diff --git a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.cs b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.cs index 9d8b3789..b054fd3e 100644 --- a/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.cs +++ b/src/OpenIddict.Server.AspNetCore/OpenIddictServerAspNetCoreHandlers.cs @@ -240,7 +240,7 @@ public static partial class OpenIddictServerAspNetCoreHandlers .AddFilter() .AddFilter() .UseSingletonHandler() - .SetOrder(InferEndpointType.Descriptor.Order + 1_000) + .SetOrder(InferIssuerFromHost.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs index b9f2cb0a..5ab20b15 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs @@ -231,7 +231,7 @@ public static partial class OpenIddictServerOwinHandlers .AddFilter() .AddFilter() .UseSingletonHandler() - .SetOrder(InferEndpointType.Descriptor.Order + 1_000) + .SetOrder(InferIssuerFromHost.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs index b2914674..ad2cad99 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Authentication.cs @@ -56,7 +56,8 @@ public static partial class OpenIddictServerHandlers */ AttachRedirectUri.Descriptor, InferResponseMode.Descriptor, - AttachResponseState.Descriptor); + AttachResponseState.Descriptor, + AttachIssuer.Descriptor); /// /// Contains the logic responsible of extracting authorization requests and invoking the corresponding event handlers. @@ -532,6 +533,27 @@ public static partial class OpenIddictServerHandlers return default; } + // To prevent issuer fixation attacks where a malicious client would specify an "iss" parameter + // in the redirect_uri, ensure the query - if present - doesn't include an "iss" parameter. + // + // Note: while OAuth 2.0 parameters are case-sentitive, the following check deliberately + // uses a case-insensitive comparison to ensure that all variations of "iss" are rejected. + if (!string.IsNullOrEmpty(uri.Query) && uri.Query.TrimStart(Separators.QuestionMark[0]) + .Split(new[] { Separators.Ampersand[0], Separators.Semicolon[0] }, StringSplitOptions.RemoveEmptyEntries) + .Select(parameter => parameter.Split(Separators.EqualsSign, StringSplitOptions.RemoveEmptyEntries)) + .Select(parts => parts[0] is string value ? Uri.UnescapeDataString(value) : null) + .Contains(Parameters.Iss, StringComparer.OrdinalIgnoreCase)) + { + context.Logger.LogInformation(SR.GetResourceString(SR.ID6181), Parameters.RedirectUri, Parameters.Iss); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2135(Parameters.RedirectUri, Parameters.Iss), + uri: SR.FormatID8000(SR.ID2135)); + + return default; + } + return default; } } @@ -1723,8 +1745,11 @@ public static partial class OpenIddictServerHandlers throw new ArgumentNullException(nameof(context)); } - // Attach the request state to the authorization response. - if (string.IsNullOrEmpty(context.Response.State)) + // If the user agent is expected to be redirected to the client application, attach the request + // state to the authorization response to help the client mitigate CSRF/session fixation attacks. + // + // Note: don't override the state if one was already attached to the response instance. + if (!string.IsNullOrEmpty(context.RedirectUri) && string.IsNullOrEmpty(context.Response.State)) { context.Response.State = context.Request?.State; } @@ -1732,5 +1757,54 @@ public static partial class OpenIddictServerHandlers return default; } } + + /// + /// Contains the logic responsible of attaching an "iss" parameter + /// containing the address of the authorization server to the response. + /// + public class AttachIssuer : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(AttachResponseState.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ApplyAuthorizationResponseContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // If the user agent is expected to be redirected to the client application, attach the + // issuer address to the authorization response to help the client detect mix-up attacks. + // + // Note: this applies to all authorization responses, whether they represent valid or errored responses. + // For more information, see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-iss-auth-resp-05. + + if (!string.IsNullOrEmpty(context.RedirectUri)) + { + // At this stage, throw an exception if the issuer cannot be retrieved. + if (context.Issuer is not { IsAbsoluteUri: true }) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0023)); + } + + // Note: don't override the issuer if one was already attached to the response instance. + if (string.IsNullOrEmpty(context.Response.Iss)) + { + context.Response.Iss = context.Issuer.AbsoluteUri; + } + } + + return default; + } + } } } diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs index d76e9d7d..7f2eb59c 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Discovery.cs @@ -377,7 +377,7 @@ public static partial class OpenIddictServerHandlers } // At this stage, throw an exception if the issuer cannot be retrieved. - if (issuer is null || !issuer.IsAbsoluteUri) + if (issuer is not { IsAbsoluteUri: true }) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0023)); } @@ -746,6 +746,11 @@ public static partial class OpenIddictServerHandlers context.Metadata[Metadata.RequestParameterSupported] = false; context.Metadata[Metadata.RequestUriParameterSupported] = false; + // As of 3.2.0, OpenIddict automatically returns an "iss" parameter containing its own address as + // part of authorization responses to help clients mitigate mix-up attacks. For more information, + // see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-iss-auth-resp-05. + context.Metadata[Metadata.AuthorizationResponseIssParameterSupported] = true; + return default; } } diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.cs index 463d6250..bcdf9045 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.cs @@ -2953,7 +2953,7 @@ public static partial class OpenIddictServerHandlers } // At this stage, throw an exception if the issuer cannot be retrieved. - if (issuer is null || !issuer.IsAbsoluteUri) + if (issuer is not { IsAbsoluteUri: true }) { throw new InvalidOperationException(SR.GetResourceString(SR.ID0023)); } diff --git a/src/OpenIddict.Validation/OpenIddictValidationConfiguration.cs b/src/OpenIddict.Validation/OpenIddictValidationConfiguration.cs index 741cbd21..8e8cb15d 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationConfiguration.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationConfiguration.cs @@ -109,7 +109,7 @@ public class OpenIddictValidationConfiguration : IPostConfigureOptions null, + + // If the issuer URI doesn't contain any path/query/fragment, allow both http://www.fabrikam.com + // and http://www.fabrikam.com/ (the recommended URI representation) to be considered valid. + // See https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.3 for more information. + { AbsolutePath: "/", Query.Length: 0, Fragment.Length: 0 } issuer => new[] + { + issuer.AbsoluteUri, // Uri.AbsoluteUri is normalized and always contains a trailing slash. + issuer.AbsoluteUri.Substring(0, issuer.AbsoluteUri.Length - 1) + }, + + Uri issuer => new[] { issuer.AbsoluteUri } + }; + + parameters.ValidateIssuer = parameters.ValidIssuers is not null; // Combine the signing keys registered statically in the token validation parameters // with the signing keys resolved from the OpenID Connect server configuration. diff --git a/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictResponseTests.cs b/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictResponseTests.cs index 62fb8ba1..91210bbd 100644 --- a/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictResponseTests.cs +++ b/test/OpenIddict.Abstractions.Tests/Primitives/OpenIddictResponseTests.cs @@ -71,6 +71,13 @@ public class OpenIddictResponseTests /* value: */ new OpenIddictParameter("802A3E3E-DCCA-4EFC-89FA-7D82FE8C27E4") }; + yield return new object[] + { + /* property: */ nameof(OpenIddictResponse.Iss), + /* name: */ Parameters.Iss, + /* value: */ new OpenIddictParameter("802A3E3E-DCCA-4EFC-89FA-7D82FE8C27E4") + }; + yield return new object[] { /* property: */ nameof(OpenIddictResponse.RefreshToken), diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs index 51eb59d7..7eaf4cfa 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Authentication.cs @@ -241,6 +241,41 @@ public abstract partial class OpenIddictServerIntegrationTests Assert.Equal(SR.FormatID8000(message), response.ErrorUri); } + [Theory] + [InlineData("http://www.fabrikam.com/path?iss")] + [InlineData("http://www.fabrikam.com/path?iss=value")] + [InlineData("http://www.fabrikam.com/path?;iss")] + [InlineData("http://www.fabrikam.com/path?;iss=value")] + [InlineData("http://www.fabrikam.com/path?&iss")] + [InlineData("http://www.fabrikam.com/path?&iss=value")] + [InlineData("http://www.fabrikam.com/path?state;iss")] + [InlineData("http://www.fabrikam.com/path?state;iss=value")] + [InlineData("http://www.fabrikam.com/path?state&iss")] + [InlineData("http://www.fabrikam.com/path?state&iss=value")] + [InlineData("http://www.fabrikam.com/path?state=abc;iss")] + [InlineData("http://www.fabrikam.com/path?state=abc;iss=value")] + [InlineData("http://www.fabrikam.com/path?state=abc&iss")] + [InlineData("http://www.fabrikam.com/path?state=abc&iss=value")] + public async Task ValidateAuthorizationRequest_RedirectUriWithIssuerParameterCausesAnError(string address) + { + // 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", + RedirectUri = address, + Scope = Scopes.OpenId + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2135(Parameters.RedirectUri, Parameters.Iss), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2135), response.ErrorUri); + } + [Fact] public async Task ValidateAuthorizationRequest_MissingResponseTypeCausesAnError() { @@ -2335,4 +2370,86 @@ public abstract partial class OpenIddictServerIntegrationTests // Assert Assert.Equal("custom_state", response.State); } + + [Theory] + [InlineData(null)] + [InlineData("http://www.fabrikam.com/")] + public async Task ApplyAuthorizationResponse_SetsIssuerParameter(string issuer) + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + if (!string.IsNullOrEmpty(issuer)) + { + options.SetIssuer(new Uri(issuer, UriKind.Absolute)); + } + + options.AddEventHandler(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", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + State = "af0ifjsldkj" + }); + + // Assert + Assert.Equal(issuer is not null ? issuer : "http://localhost/", response.Iss); + } + + [Fact] + public async Task ApplyAuthorizationResponse_DoesNotOverrideIssuerSetByApplicationCode() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.SetIssuer(new Uri("http://www.fabrikam.com/", UriKind.Absolute)); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetClaim(Claims.Subject, "Bob le Magnifique"); + + return default; + })); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Response.Iss = "http://www.contoso.com/"; + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/authorize", new OpenIddictRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = ResponseTypes.Code, + State = "af0ifjsldkj" + }); + + // Assert + Assert.Equal("http://www.contoso.com/", response.Iss); + } } diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs index d6bd375e..46d5a1d4 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs @@ -1201,7 +1201,12 @@ public abstract partial class OpenIddictServerIntegrationTests }); // Assert - Assert.Equal(0, response.Count); + Assert.Null(response.AccessToken); + Assert.Null(response.Code); + Assert.Null(response.DeviceCode); + Assert.Null(response.IdToken); + Assert.Null(response.RefreshToken); + Assert.Null(response.UserCode); } [Theory]