diff --git a/docs/keycloak.md b/docs/keycloak.md index 29fcf71f3..1b691782e 100644 --- a/docs/keycloak.md +++ b/docs/keycloak.md @@ -15,6 +15,19 @@ services.AddAuthentication(options => /* Auth configuration */) }); ``` +### Production with Public Access Type + +```csharp +services.AddAuthentication(options => /* Auth configuration */) + .AddKeycloak(options => + { + options.AccessType = KeycloakAuthenticationAccessType.Public; + options.ClientId = "my-client-id"; + options.Domain = "mydomain.local"; + options.Realm = "myrealm"; + }); +``` + ### Local Development with Docker ```csharp @@ -40,4 +53,6 @@ Only one of either `BaseAddress` or `Domain` is required to be set. If both are ## Optional Settings -_None._ +| Property Name | Property Type | Description | Default Value | +| :------------ | :--------------------------------- | :--------------------------------------- | :---------------------------------------------- | +| `AccessType` | `KeycloakAuthenticationAccessType` | The Keycloak client's access token type. | `KeycloakAuthenticationAccessType.Confidential` | diff --git a/src/AspNet.Security.OAuth.Keycloak/KeycloakAuthenticationAccessType.cs b/src/AspNet.Security.OAuth.Keycloak/KeycloakAuthenticationAccessType.cs new file mode 100644 index 000000000..4b0873ae9 --- /dev/null +++ b/src/AspNet.Security.OAuth.Keycloak/KeycloakAuthenticationAccessType.cs @@ -0,0 +1,13 @@ +// Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +// See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers +// for more information concerning the license and the contributors participating to this project. + +namespace AspNet.Security.OAuth.Keycloak +{ + public enum KeycloakAuthenticationAccessType + { + Confidential, + Public, + BearerOnly, + } +} diff --git a/src/AspNet.Security.OAuth.Keycloak/KeycloakAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Keycloak/KeycloakAuthenticationOptions.cs index 1b39e646b..89880b1b8 100644 --- a/src/AspNet.Security.OAuth.Keycloak/KeycloakAuthenticationOptions.cs +++ b/src/AspNet.Security.OAuth.Keycloak/KeycloakAuthenticationOptions.cs @@ -37,6 +37,11 @@ public KeycloakAuthenticationOptions() ClaimActions.MapJsonKey(ClaimTypes.Role, "roles"); } + /// + /// Gets or sets the value for the Keycloak client's access type. + /// + public KeycloakAuthenticationAccessType AccessType { get; set; } + /// /// Gets or sets the base address of the Keycloak server. /// @@ -51,5 +56,39 @@ public KeycloakAuthenticationOptions() /// Gets or sets the Keycloak realm to use for authentication. /// public string? Realm { get; set; } + + /// + public override void Validate() + { + try + { + // HACK We want all of the base validation except for ClientSecret, + // so rather than re-implement it all, catch the exception thrown + // for that being null and only throw if we aren't using public access type. + // This does mean that three checks have to be re-implemented + // because the won't be validated if the ClientSecret validation fails. + base.Validate(); + } + catch (ArgumentException ex) when (ex.ParamName == nameof(ClientSecret) && AccessType == KeycloakAuthenticationAccessType.Public) + { + // No client secret is required for a public key. + // See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers/issues/610. + } + + if (string.IsNullOrEmpty(AuthorizationEndpoint)) + { + throw new ArgumentException($"The '{nameof(AuthorizationEndpoint)}' option must be provided.", nameof(AuthorizationEndpoint)); + } + + if (string.IsNullOrEmpty(TokenEndpoint)) + { + throw new ArgumentException($"The '{nameof(TokenEndpoint)}' option must be provided.", nameof(TokenEndpoint)); + } + + if (!CallbackPath.HasValue) + { + throw new ArgumentException($"The '{nameof(CallbackPath)}' option must be provided.", nameof(CallbackPath)); + } + } } } diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Keycloak/KeycloakAuthenticationOptionsTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Keycloak/KeycloakAuthenticationOptionsTests.cs new file mode 100644 index 000000000..507316924 --- /dev/null +++ b/test/AspNet.Security.OAuth.Providers.Tests/Keycloak/KeycloakAuthenticationOptionsTests.cs @@ -0,0 +1,107 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers + * for more information concerning the license and the contributors participating to this project. + */ + +using System; +using System.Collections.Generic; +using Xunit; + +namespace AspNet.Security.OAuth.Keycloak +{ + public static class KeycloakAuthenticationOptionsTests + { + public static IEnumerable AccessTypes => new object[][] + { + new object[] { KeycloakAuthenticationAccessType.BearerOnly }, + new object[] { KeycloakAuthenticationAccessType.Confidential }, + new object[] { KeycloakAuthenticationAccessType.Public }, + }; + + [Theory] + [InlineData(null)] + [InlineData("")] + public static void Validate_Does_Not_Throw_If_ClientSecret_Is_Not_Provided_For_Public_Access_Type(string clientSecret) + { + // Arrange + var options = new KeycloakAuthenticationOptions() + { + AccessType = KeycloakAuthenticationAccessType.Public, + ClientId = "my-client-id", + ClientSecret = clientSecret, + }; + + // Act (no Assert) + options.Validate(); + } + + [Theory] + [InlineData(KeycloakAuthenticationAccessType.BearerOnly)] + [InlineData(KeycloakAuthenticationAccessType.Confidential)] + public static void Validate_Throws_If_ClientSecret_Is_Null(KeycloakAuthenticationAccessType accessType) + { + // Arrange + var options = new KeycloakAuthenticationOptions() + { + AccessType = accessType, + ClientId = "my-client-id", + ClientSecret = null, + }; + + // Act and Assert + Assert.Throws("ClientSecret", () => options.Validate()); + } + + [Theory] + [MemberData(nameof(AccessTypes))] + public static void Validate_Throws_If_AuthorizationEndpoint_Is_Null(KeycloakAuthenticationAccessType accessType) + { + // Arrange + var options = new KeycloakAuthenticationOptions() + { + AccessType = accessType, + AuthorizationEndpoint = null, + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + }; + + // Act and Assert + Assert.Throws("AuthorizationEndpoint", () => options.Validate()); + } + + [Theory] + [MemberData(nameof(AccessTypes))] + public static void Validate_Throws_If_TokenEndpoint_Is_Null(KeycloakAuthenticationAccessType accessType) + { + // Arrange + var options = new KeycloakAuthenticationOptions() + { + AccessType = accessType, + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + TokenEndpoint = null, + }; + + // Act and Assert + Assert.Throws("TokenEndpoint", () => options.Validate()); + } + + [Theory] + [MemberData(nameof(AccessTypes))] + public static void Validate_Throws_If_CallbackPath_Is_Null(KeycloakAuthenticationAccessType accessType) + { + // Arrange + var options = new KeycloakAuthenticationOptions() + { + AccessType = accessType, + CallbackPath = null, + ClientId = "my-client-id", + ClientSecret = "my-client-secret", + }; + + // Act and Assert + Assert.Throws("CallbackPath", () => options.Validate()); + } + } +} diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Keycloak/KeycloakTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Keycloak/KeycloakTests.cs index 37133dd92..a3b333580 100644 --- a/test/AspNet.Security.OAuth.Providers.Tests/Keycloak/KeycloakTests.cs +++ b/test/AspNet.Security.OAuth.Providers.Tests/Keycloak/KeycloakTests.cs @@ -84,5 +84,33 @@ static void ConfigureServices(IServiceCollection services) // Assert AssertClaim(claims, claimType, claimValue); } + + [Theory] + [InlineData(ClaimTypes.NameIdentifier, "995c1500-0dca-495e-ba72-2499d370d181")] + [InlineData(ClaimTypes.Email, "john@smith.com")] + [InlineData(ClaimTypes.GivenName, "John")] + [InlineData(ClaimTypes.Role, "admin")] + [InlineData(ClaimTypes.Name, "John Smith")] + public async Task Can_Sign_In_Using_Keycloak_Public_AccessType(string claimType, string claimValue) + { + // Arrange + static void ConfigureServices(IServiceCollection services) + { + services.PostConfigureAll((options) => + { + options.AccessType = KeycloakAuthenticationAccessType.Public; + options.ClientSecret = string.Empty; + options.Domain = "keycloak.local"; + }); + } + + using var server = CreateTestServer(ConfigureServices); + + // Act + var claims = await AuthenticateUserAsync(server); + + // Assert + AssertClaim(claims, claimType, claimValue); + } } }