diff --git a/src/OpenIddict.Client.Owin/OpenIddictClientOwinConstants.cs b/src/OpenIddict.Client.Owin/OpenIddictClientOwinConstants.cs index e0f374e8..7986c47c 100644 --- a/src/OpenIddict.Client.Owin/OpenIddictClientOwinConstants.cs +++ b/src/OpenIddict.Client.Owin/OpenIddictClientOwinConstants.cs @@ -35,6 +35,14 @@ public static class OpenIddictClientOwinConstants public const string UserinfoTokenPrincipal = ".userinfo_token_principal"; } + public static class PropertyTypes + { + public const string Boolean = "#boolean"; + public const string Integer = "#integer"; + public const string Json = "#json"; + public const string String = "#string"; + } + public static class Tokens { public const string AuthorizationCode = "authorization_code"; diff --git a/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlers.cs b/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlers.cs index d19b8f6a..a6eb1e3b 100644 --- a/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlers.cs +++ b/src/OpenIddict.Client.Owin/OpenIddictClientOwinHandlers.cs @@ -7,8 +7,10 @@ using System.Collections.Immutable; using System.ComponentModel; using System.Diagnostics; +using System.Globalization; using System.Security.Claims; using System.Text; +using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; @@ -454,6 +456,44 @@ public static partial class OpenIddictClientOwinHandlers context.TargetLinkUri = properties.RedirectUri; } + // Note: unlike ASP.NET Core, Owin's AuthenticationProperties doesn't offer a strongly-typed + // dictionary that allows flowing parameters while preserving their original types. To allow + // returning custom parameters, the OWIN host allows using AuthenticationProperties.Dictionary + // but requires suffixing the properties that are meant to be used as parameters using a special + // suffix that indicates that the property is public and determines its actual representation. + foreach (var property in properties.Dictionary) + { + var (name, value) = property.Key switch + { + // If the property ends with #string, represent it as a string parameter. + string key when key.EndsWith(PropertyTypes.String, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.String.Length), + Value: new OpenIddictParameter(property.Value)), + + // If the property ends with #boolean, return it as a boolean parameter. + string key when key.EndsWith(PropertyTypes.Boolean, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.Boolean.Length), + Value: new OpenIddictParameter(bool.Parse(property.Value))), + + // If the property ends with #integer, return it as an integer parameter. + string key when key.EndsWith(PropertyTypes.Integer, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.Integer.Length), + Value: new OpenIddictParameter(long.Parse(property.Value, CultureInfo.InvariantCulture))), + + // If the property ends with #json, return it as a JSON parameter. + string key when key.EndsWith(PropertyTypes.Json, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.Json.Length), + Value: new OpenIddictParameter(JsonSerializer.Deserialize(property.Value))), + + _ => default + }; + + if (!string.IsNullOrEmpty(name)) + { + context.Parameters[name] = value; + } + } + return default; } } diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinConstants.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinConstants.cs index a30c0452..2888bed1 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinConstants.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinConstants.cs @@ -51,6 +51,14 @@ public static class OpenIddictServerOwinConstants public const string UserCodePrincipal = ".user_code_principal"; } + public static class PropertyTypes + { + public const string Boolean = "#boolean"; + public const string Integer = "#integer"; + public const string Json = "#json"; + public const string String = "#string"; + } + public static class Tokens { public const string AccessToken = "access_token"; diff --git a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs index 90b3a968..cfb5b46e 100644 --- a/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs +++ b/src/OpenIddict.Server.Owin/OpenIddictServerOwinHandlers.cs @@ -7,6 +7,7 @@ using System.Collections.Immutable; using System.ComponentModel; using System.Diagnostics; +using System.Globalization; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; @@ -32,7 +33,18 @@ public static partial class OpenIddictServerOwinHandlers /* * Challenge processing: */ - AttachHostChallengeError.Descriptor) + AttachHostChallengeError.Descriptor, + ResolveHostChallengeParameters.Descriptor, + + /* + * Sign-in processing: + */ + ResolveHostSignInParameters.Descriptor, + + /* + * Sign-out processing: + */ + ResolveHostSignOutParameters.Descriptor) .AddRange(Authentication.DefaultHandlers) .AddRange(Device.DefaultHandlers) .AddRange(Discovery.DefaultHandlers) @@ -304,6 +316,228 @@ public static partial class OpenIddictServerOwinHandlers } } + /// + /// Contains the logic responsible for resolving the additional sign-in parameters stored in the + /// OWIN authentication properties specified by the application that triggered the sign-in operation. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public class ResolveHostChallengeParameters : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachChallengeParameters.Descriptor.Order - 500) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessChallengeContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var properties = context.Transaction.GetProperty(typeof(AuthenticationProperties).FullName!); + if (properties is null) + { + return default; + } + + // Note: unlike ASP.NET Core, Owin's AuthenticationProperties doesn't offer a strongly-typed + // dictionary that allows flowing parameters while preserving their original types. To allow + // returning custom parameters, the OWIN host allows using AuthenticationProperties.Dictionary + // but requires suffixing the properties that are meant to be used as parameters using a special + // suffix that indicates that the property is public and determines its actual representation. + foreach (var property in properties.Dictionary) + { + var (name, value) = property.Key switch + { + // If the property ends with #string, represent it as a string parameter. + string key when key.EndsWith(PropertyTypes.String, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.String.Length), + Value: new OpenIddictParameter(property.Value)), + + // If the property ends with #boolean, return it as a boolean parameter. + string key when key.EndsWith(PropertyTypes.Boolean, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.Boolean.Length), + Value: new OpenIddictParameter(bool.Parse(property.Value))), + + // If the property ends with #integer, return it as an integer parameter. + string key when key.EndsWith(PropertyTypes.Integer, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.Integer.Length), + Value: new OpenIddictParameter(long.Parse(property.Value, CultureInfo.InvariantCulture))), + + // If the property ends with #json, return it as a JSON parameter. + string key when key.EndsWith(PropertyTypes.Json, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.Json.Length), + Value: new OpenIddictParameter(JsonSerializer.Deserialize(property.Value))), + + _ => default + }; + + if (!string.IsNullOrEmpty(name)) + { + context.Parameters[name] = value; + } + } + + return default; + } + } + + /// + /// Contains the logic responsible for resolving the additional sign-in parameters stored in the + /// OWIN authentication properties specified by the application that triggered the sign-in operation. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public class ResolveHostSignInParameters : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachSignInParameters.Descriptor.Order - 500) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessSignInContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var properties = context.Transaction.GetProperty(typeof(AuthenticationProperties).FullName!); + if (properties is null) + { + return default; + } + + // Note: unlike ASP.NET Core, Owin's AuthenticationProperties doesn't offer a strongly-typed + // dictionary that allows flowing parameters while preserving their original types. To allow + // returning custom parameters, the OWIN host allows using AuthenticationProperties.Dictionary + // but requires suffixing the properties that are meant to be used as parameters using a special + // suffix that indicates that the property is public and determines its actual representation. + foreach (var property in properties.Dictionary) + { + var (name, value) = property.Key switch + { + // If the property ends with #string, represent it as a string parameter. + string key when key.EndsWith(PropertyTypes.String, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.String.Length), + Value: new OpenIddictParameter(property.Value)), + + // If the property ends with #boolean, return it as a boolean parameter. + string key when key.EndsWith(PropertyTypes.Boolean, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.Boolean.Length), + Value: new OpenIddictParameter(bool.Parse(property.Value))), + + // If the property ends with #integer, return it as an integer parameter. + string key when key.EndsWith(PropertyTypes.Integer, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.Integer.Length), + Value: new OpenIddictParameter(long.Parse(property.Value, CultureInfo.InvariantCulture))), + + // If the property ends with #json, return it as a JSON parameter. + string key when key.EndsWith(PropertyTypes.Json, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.Json.Length), + Value: new OpenIddictParameter(JsonSerializer.Deserialize(property.Value))), + + _ => default + }; + + if (!string.IsNullOrEmpty(name)) + { + context.Parameters[name] = value; + } + } + + return default; + } + } + + /// + /// Contains the logic responsible for resolving the additional sign-out parameters stored in the + /// OWIN authentication properties specified by the application that triggered the sign-out operation. + /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. + /// + public class ResolveHostSignOutParameters : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .UseSingletonHandler() + .SetOrder(AttachSignOutParameters.Descriptor.Order - 500) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ProcessSignOutContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var properties = context.Transaction.GetProperty(typeof(AuthenticationProperties).FullName!); + if (properties is null) + { + return default; + } + + // Note: unlike ASP.NET Core, Owin's AuthenticationProperties doesn't offer a strongly-typed + // dictionary that allows flowing parameters while preserving their original types. To allow + // returning custom parameters, the OWIN host allows using AuthenticationProperties.Dictionary + // but requires suffixing the properties that are meant to be used as parameters using a special + // suffix that indicates that the property is public and determines its actual representation. + foreach (var property in properties.Dictionary) + { + var (name, value) = property.Key switch + { + // If the property ends with #string, represent it as a string parameter. + string key when key.EndsWith(PropertyTypes.String, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.String.Length), + Value: new OpenIddictParameter(property.Value)), + + // If the property ends with #boolean, return it as a boolean parameter. + string key when key.EndsWith(PropertyTypes.Boolean, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.Boolean.Length), + Value: new OpenIddictParameter(bool.Parse(property.Value))), + + // If the property ends with #integer, return it as an integer parameter. + string key when key.EndsWith(PropertyTypes.Integer, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.Integer.Length), + Value: new OpenIddictParameter(long.Parse(property.Value, CultureInfo.InvariantCulture))), + + // If the property ends with #json, return it as a JSON parameter. + string key when key.EndsWith(PropertyTypes.Json, StringComparison.OrdinalIgnoreCase) => ( + Name: key.Substring(0, key.Length - PropertyTypes.Json.Length), + Value: new OpenIddictParameter(JsonSerializer.Deserialize(property.Value))), + + _ => default + }; + + if (!string.IsNullOrEmpty(name)) + { + context.Parameters[name] = value; + } + } + + return default; + } + } + /// /// Contains the logic responsible for extracting OpenID Connect requests from GET HTTP requests. /// Note: this handler is not used when the OpenID Connect request is not initially handled by OWIN. diff --git a/test/OpenIddict.Server.AspNetCore.IntegrationTests/OpenIddictServerAspNetCoreIntegrationTests.cs b/test/OpenIddict.Server.AspNetCore.IntegrationTests/OpenIddictServerAspNetCoreIntegrationTests.cs index 2e2fde9e..cbbf0910 100644 --- a/test/OpenIddict.Server.AspNetCore.IntegrationTests/OpenIddictServerAspNetCoreIntegrationTests.cs +++ b/test/OpenIddict.Server.AspNetCore.IntegrationTests/OpenIddictServerAspNetCoreIntegrationTests.cs @@ -171,6 +171,10 @@ public partial class OpenIddictServerAspNetCoreIntegrationTests : OpenIddictServ Assert.Equal(JsonValueKind.Number, ((JsonElement) response["integer_parameter"]).ValueKind); Assert.Equal("Bob l'Eponge", (string?) response["string_parameter"]); Assert.Equal(JsonValueKind.String, ((JsonElement) response["string_parameter"]).ValueKind); + Assert.Equal(new[] { "Contoso", "Fabrikam" }, (string[]?) response["array_parameter"]); + Assert.Equal(JsonValueKind.Array, ((JsonElement) response["array_parameter"]).ValueKind); + Assert.Equal("value", (string?) response["object_parameter"]?["parameter"]); + Assert.Equal(JsonValueKind.Object, ((JsonElement) response["object_parameter"]).ValueKind); #if SUPPORTS_JSON_NODES Assert.Equal(new[] { "Contoso", "Fabrikam" }, (string[]?) response["node_array_parameter"]); diff --git a/test/OpenIddict.Server.Owin.IntegrationTests/OpenIddictServerOwinIntegrationTests.cs b/test/OpenIddict.Server.Owin.IntegrationTests/OpenIddictServerOwinIntegrationTests.cs index d3a646ec..d6bc21fc 100644 --- a/test/OpenIddict.Server.Owin.IntegrationTests/OpenIddictServerOwinIntegrationTests.cs +++ b/test/OpenIddict.Server.Owin.IntegrationTests/OpenIddictServerOwinIntegrationTests.cs @@ -130,6 +130,45 @@ public partial class OpenIddictServerOwinIntegrationTests : OpenIddictServerInte Assert.Equal(new DateTimeOffset(2120, 01, 01, 00, 00, 00, TimeSpan.Zero), properties.ExpiresUtc); } + [Fact] + public async Task ProcessChallenge_ReturnsParametersFromAuthenticationProperties() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.SetTokenEndpointUris("/challenge/custom"); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/challenge/custom", new OpenIddictRequest + { + GrantType = GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w" + }); + + // Assert + Assert.True((bool) response["boolean_parameter"]); + Assert.Equal(JsonValueKind.True, ((JsonElement) response["boolean_parameter"]).ValueKind); + Assert.Equal(42, (long) response["integer_parameter"]); + Assert.Equal(JsonValueKind.Number, ((JsonElement) response["integer_parameter"]).ValueKind); + Assert.Equal("Bob l'Eponge", (string?) response["string_parameter"]); + Assert.Equal(JsonValueKind.String, ((JsonElement) response["string_parameter"]).ValueKind); + Assert.Equal(new[] { "Contoso", "Fabrikam" }, (string[]?) response["json_parameter"]); + Assert.Equal(JsonValueKind.Array, ((JsonElement) response["json_parameter"]).ValueKind); + } + [Fact] public async Task ProcessChallenge_ReturnsErrorFromAuthenticationProperties() { @@ -680,6 +719,78 @@ public partial class OpenIddictServerOwinIntegrationTests : OpenIddictServerInte Assert.Equal("Bob le Magnifique", (string?) response["name"]); } + [Fact] + public async Task ProcessSignIn_ReturnsParametersFromAuthenticationProperties() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.SetTokenEndpointUris("/signin/custom"); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/signin/custom", new OpenIddictRequest + { + GrantType = GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w" + }); + + // Assert + Assert.True((bool) response["boolean_parameter"]); + Assert.Equal(JsonValueKind.True, ((JsonElement) response["boolean_parameter"]).ValueKind); + Assert.Equal(42, (long) response["integer_parameter"]); + Assert.Equal(JsonValueKind.Number, ((JsonElement) response["integer_parameter"]).ValueKind); + Assert.Equal("Bob l'Eponge", (string?) response["string_parameter"]); + Assert.Equal(JsonValueKind.String, ((JsonElement) response["string_parameter"]).ValueKind); + Assert.Equal(new[] { "Contoso", "Fabrikam" }, (string[]?) response["json_parameter"]); + Assert.Equal(JsonValueKind.Array, ((JsonElement) response["json_parameter"]).ValueKind); + } + + [Fact] + public async Task ProcessSignOut_ReturnsParametersFromAuthenticationProperties() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + options.SetLogoutEndpointUris("/signout/custom"); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/signout/custom", new OpenIddictRequest + { + PostLogoutRedirectUri = "http://www.fabrikam.com/path", + State = "af0ifjsldkj" + }); + + // Assert + Assert.True((bool) response["boolean_parameter"]); + Assert.Equal(42, (long) response["integer_parameter"]); + Assert.Equal("Bob l'Eponge", (string?) response["string_parameter"]); + } + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "The caller is responsible for disposing the test server.")] protected override ValueTask CreateServerAsync(Action? configuration = null) @@ -746,12 +857,45 @@ public partial class OpenIddictServerOwinIntegrationTests : OpenIddictServerInte return; } + else if (context.Request.Path == new PathString("/signin/custom")) + { + var identity = new ClaimsIdentity(OpenIddictServerOwinDefaults.AuthenticationType); + identity.AddClaim(Claims.Subject, "Bob le Bricoleur"); + + var principal = new ClaimsPrincipal(identity); + + var properties = new AuthenticationProperties(new Dictionary + { + ["boolean_parameter#boolean"] = "true", + ["integer_parameter#integer"] = "42", + ["string_parameter#string"] = "Bob l'Eponge", + ["json_parameter#json"] = @"[""Contoso"",""Fabrikam""]" + }); + + context.Authentication.SignIn(properties, identity); + return; + } + else if (context.Request.Path == new PathString("/signout")) { context.Authentication.SignOut(OpenIddictServerOwinDefaults.AuthenticationType); return; } + else if (context.Request.Path == new PathString("/signout/custom")) + { + + var properties = new AuthenticationProperties(new Dictionary + { + ["boolean_parameter#boolean"] = "true", + ["integer_parameter#integer"] = "42", + ["string_parameter#string"] = "Bob l'Eponge" + }); + + context.Authentication.SignOut(properties, OpenIddictServerOwinDefaults.AuthenticationType); + return; + } + else if (context.Request.Path == new PathString("/challenge")) { context.Authentication.Challenge(OpenIddictServerOwinDefaults.AuthenticationType); @@ -764,7 +908,12 @@ public partial class OpenIddictServerOwinIntegrationTests : OpenIddictServerInte { [OpenIddictServerOwinConstants.Properties.Error] = "custom_error", [OpenIddictServerOwinConstants.Properties.ErrorDescription] = "custom_error_description", - [OpenIddictServerOwinConstants.Properties.ErrorUri] = "custom_error_uri" + [OpenIddictServerOwinConstants.Properties.ErrorUri] = "custom_error_uri", + + ["boolean_parameter#boolean"] = "true", + ["integer_parameter#integer"] = "42", + ["string_parameter#string"] = "Bob l'Eponge", + ["json_parameter#json"] = @"[""Contoso"",""Fabrikam""]" }); context.Authentication.Challenge(properties, OpenIddictServerOwinDefaults.AuthenticationType);