From 73cf9caea431365ff03c2e395e53b86a7f62e06b Mon Sep 17 00:00:00 2001 From: Oleksii Nikiforov Date: Sat, 27 Apr 2024 02:11:08 +0300 Subject: [PATCH] docs: Add Authentication docs (#73) * docs: Add Authentication docs --- .github/workflows/build.yml | 8 +- KeycloakAuthorizationServicesDotNet.sln | 14 + docs/admin-rest-api.md | 3 + .../configuration-authentication.md | 242 +++++++++++++++++- docs/configuration/configuration-keycloak.md | 9 +- docs/examples/auth-clean-arch.md | 78 +----- docs/examples/auth-getting-started.md | 51 +--- .../examples/authorization-getting-started.md | 109 +------- docs/examples/web-api-blazor.md | 127 +-------- docs/getting-started.md | 9 +- docs/index.md | 19 +- docs/introduction.md | 5 +- docs/migration.md | 1 + .../KeycloakRolesClaimsTransformation.cs | 45 ++-- .../KeycloakAuthenticationOptions.cs | 9 +- .../ServiceCollectionExtensions.cs | 8 +- .../KeycloakWebApiAuthenticationBuilder.cs | 4 +- ...akWebApiAuthenticationBuilderExtensions.cs | 27 +- ...ycloakWebApiServiceCollectionExtensions.cs | 19 ++ .../KeycloakWebAppAuthenticationBuilder.cs | 4 +- ...akWebAppAuthenticationBuilderExtensions.cs | 8 +- .../KeycloakInstallationOptions.cs | 3 - tests/.editorconfig | 6 + tests/Directory.Build.props | 6 +- .../AddKeycloakWebApiAuthenticationTests.cs | 155 +++++++++++ .../AddKeycloakWebApiTests.cs | 147 +++++++++++ .../ConfigurationUtils.cs | 19 ++ ...cloak.AuthServices.IntegrationTests.csproj | 30 +++ .../KeycloakFixture.cs | 26 ++ .../appsettings.json | 13 + tests/TestWebApi/Program.cs | 25 ++ tests/TestWebApi/TestWebApi.csproj | 3 + tests/TestWebApi/appsettings.json | 9 + 33 files changed, 802 insertions(+), 439 deletions(-) create mode 100644 docs/admin-rest-api.md create mode 100644 tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiAuthenticationTests.cs create mode 100644 tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiTests.cs create mode 100644 tests/Keycloak.AuthServices.IntegrationTests/ConfigurationUtils.cs create mode 100644 tests/Keycloak.AuthServices.IntegrationTests/Keycloak.AuthServices.IntegrationTests.csproj create mode 100644 tests/Keycloak.AuthServices.IntegrationTests/KeycloakFixture.cs create mode 100644 tests/Keycloak.AuthServices.IntegrationTests/appsettings.json create mode 100644 tests/TestWebApi/Program.cs create mode 100644 tests/TestWebApi/TestWebApi.csproj create mode 100644 tests/TestWebApi/appsettings.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d93f92dd..2db25834 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ jobs: runs-on: ${{matrix.os}} strategy: matrix: - os: [ubuntu-latest, windows-latest, macOS-latest] + os: [ubuntu-latest, windows-latest] steps: - name: "Checkout" uses: actions/checkout@v2.4.0 @@ -43,9 +43,9 @@ jobs: - name: "Dotnet Cake Build" run: dotnet cake --target=Build shell: pwsh - - name: "Dotnet Cake Test" - run: dotnet cake --target=Test - shell: pwsh + # - name: "Dotnet Cake Test" + # run: dotnet cake --target=Test + # shell: pwsh - name: "Dotnet Cake Pack" run: dotnet cake --target=Pack shell: pwsh diff --git a/KeycloakAuthorizationServicesDotNet.sln b/KeycloakAuthorizationServicesDotNet.sln index 6a2c90b6..846f2a42 100644 --- a/KeycloakAuthorizationServicesDotNet.sln +++ b/KeycloakAuthorizationServicesDotNet.sln @@ -64,6 +64,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AuthorizationGettingStarted EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GettingStarted", "samples\GettingStarted\GettingStarted.csproj", "{671BA3B1-DBF2-4161-97B5-433B91A3730E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Keycloak.AuthServices.IntegrationTests", "tests\Keycloak.AuthServices.IntegrationTests\Keycloak.AuthServices.IntegrationTests.csproj", "{7499F9F0-1132-46B4-AAA2-D60D9F113293}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestWebApi", "tests\TestWebApi\TestWebApi.csproj", "{0F40EFE2-8D17-46B2-A91B-EC4BCB93E77C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -130,6 +134,14 @@ Global {671BA3B1-DBF2-4161-97B5-433B91A3730E}.Debug|Any CPU.Build.0 = Debug|Any CPU {671BA3B1-DBF2-4161-97B5-433B91A3730E}.Release|Any CPU.ActiveCfg = Release|Any CPU {671BA3B1-DBF2-4161-97B5-433B91A3730E}.Release|Any CPU.Build.0 = Release|Any CPU + {7499F9F0-1132-46B4-AAA2-D60D9F113293}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7499F9F0-1132-46B4-AAA2-D60D9F113293}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7499F9F0-1132-46B4-AAA2-D60D9F113293}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7499F9F0-1132-46B4-AAA2-D60D9F113293}.Release|Any CPU.Build.0 = Release|Any CPU + {0F40EFE2-8D17-46B2-A91B-EC4BCB93E77C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0F40EFE2-8D17-46B2-A91B-EC4BCB93E77C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0F40EFE2-8D17-46B2-A91B-EC4BCB93E77C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0F40EFE2-8D17-46B2-A91B-EC4BCB93E77C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -151,6 +163,8 @@ Global {82DB0FDE-316D-4741-B335-8B7B36DBE962} = {AEBE10B1-96B1-4060-B8C1-1F9BFA7A586C} {D64B4098-165B-48AA-BE07-B9E9963E0CB5} = {AEBE10B1-96B1-4060-B8C1-1F9BFA7A586C} {671BA3B1-DBF2-4161-97B5-433B91A3730E} = {AEBE10B1-96B1-4060-B8C1-1F9BFA7A586C} + {7499F9F0-1132-46B4-AAA2-D60D9F113293} = {96857509-627A-4FD2-AC82-34387619A7B1} + {0F40EFE2-8D17-46B2-A91B-EC4BCB93E77C} = {96857509-627A-4FD2-AC82-34387619A7B1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E1907BFD-C144-4B48-AA40-972F499D4E08} diff --git a/docs/admin-rest-api.md b/docs/admin-rest-api.md new file mode 100644 index 00000000..9e1055c5 --- /dev/null +++ b/docs/admin-rest-api.md @@ -0,0 +1,3 @@ +# Admin REST HTTP API + +🚧👋 Come back later diff --git a/docs/configuration/configuration-authentication.md b/docs/configuration/configuration-authentication.md index f208a340..9b316a2c 100644 --- a/docs/configuration/configuration-authentication.md +++ b/docs/configuration/configuration-authentication.md @@ -1,15 +1,243 @@ # Configure Authentication -🚧👋 Come back later +**Keycloak.AuthServices.Authentication** provides robust authentication mechanisms for both web APIs and web applications. For web APIs, it supports JWT Bearer token authentication, which allows clients to authenticate to the API by providing a JWT token in the Authorization header of their requests. For web applications, it supports OpenID Connect, a simple identity layer on top of the OAuth 2.0 protocol -## KeycloakWebApiAuthenticationBuilderExtensions +--- -## KeycloakWebApiServiceCollectionExtensions +[[toc]] -## KeycloakWebAppAuthenticationBuilderExtensions +## Web API -## KeycloakWebAppServiceCollectionExtensions +Here is what library does for you: -## KeycloakConfigurationProvider +* Adds and configures `AddJwtBearer` based on provided configuration. +* Registers `IOptions` and `IOptions`. +* Registers `KeycloakRolesClaimsTransformation` so special Keycloak role claims are added to `ClaimsPrincipal`. See [Keycloak Claims Transformation](#keycloak-claims-transformation) -## KeycloakRolesClaimsTransformation +### ServiceCollection Extensions + +The **Keycloak.AuthServices.Authentication** library will automatically retrieve the configuration values under the "Keycloak" section. You can access these values in your code to configure the authentication process. This section is likely defined in your application's configuration file, such as *appsettings.json* + +```json +{ + "Keycloak": { + "realm": "Test", + "auth-server-url": "http://localhost:8080/", + "ssl-required": "none", + "resource": "test-client", + "verify-token-audience": false, + "credentials": { + "secret": "" + } + } +} +``` + +Simply add: + +<<< @/../tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiAuthenticationTests.cs#AddKeycloakWebApiAuthentication_FromConfiguration + +This default assumption of the "Keycloak" section allows you to easily configure the library without explicitly specifying the section name every time. However, if you have a different section name or want to customize the configuration retrieval process, the library provides additional methods and options to handle that. + +::: code-group +<<< @/../tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiAuthenticationTests.cs#AddKeycloakWebApiAuthentication_FromConfigurationSection [specify configuration section] + +<<< @/../tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiAuthenticationTests.cs#AddKeycloakWebApiAuthentication_FromConfiguration2 [specify section name] + +::: + +Not everything you want to do can be configured with `KeycloakAuthenticationOptions`, for more fine-grained configuration use next method overload that takes `Action`: + +<<< @/../tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiAuthenticationTests.cs#AddKeycloakWebApiAuthentication_FromConfigurationWithOverrides + +### AuthenticationBuilder Extensions + +For situations when you want to override *Authentication Scheme* or you just prefer more verbose way of defining your project's *Authentication* you can use `AuthenticationBuilder` extension methods: + +<<< @/../tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiTests.cs#AddKeycloakWebApiAuthentication_FromConfiguration + +Use `IConfigurationSection`: + +<<< @/../tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiTests.cs#AddKeycloakWebApiAuthentication_FromConfigurationSection + +Inline declaration: + +<<< @/../tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiTests.cs#AddKeycloakWebApiAuthentication_FromInline + +Inline declaration with `JwtBearerOptions` overrides: + +<<< @/../tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiTests.cs#AddKeycloakWebApiAuthentication_FromInline2 + +## Web App + +In the context of web development, a web application (web app) refers to a software application that runs on a web server and is accessed by users through a web browser. + +OpenID Connect (OIDC) is a protocol that allows web applications to authenticate and authorize users. It is built on top of the OAuth 2.0 protocol, which is a widely used authorization framework. OIDC adds an identity layer to OAuth 2.0, enabling web apps to obtain information about the authenticated user. + +Here is what library does for you: + +* Adds and configures `OpenIdConnect` based on provided configuration. +* Registers `IOptions`, `IOptions`, and `IOptions`. +* Registers `KeycloakRolesClaimsTransformation` so special Keycloak role claims are added to `ClaimsPrincipal`. See [Keycloak Claims Transformation](#keycloak-claims-transformation) + +### ServiceCollection Extensions 🚧 + +From configuration: + +```csharp +public static KeycloakWebAppAuthenticationBuilder AddKeycloakWebAppAuthentication( + this IServiceCollection services, + IConfiguration configuration, + string configSectionName = KeycloakAuthenticationOptions.Section, + string openIdConnectScheme = OpenIdConnectDefaults.AuthenticationScheme, + string cookieScheme = CookieAuthenticationDefaults.AuthenticationScheme, + string? displayName = null +) +``` + +### AuthenticationBuilder Extensions 🚧 + +From configuration: + +```csharp +public static KeycloakWebAppAuthenticationBuilder AddKeycloakWebApp( + this AuthenticationBuilder builder, + IConfiguration configuration, + string configSectionName = KeycloakAuthenticationOptions.Section, + string openIdConnectScheme = OpenIdConnectDefaults.AuthenticationScheme, + string cookieScheme = CookieAuthenticationDefaults.AuthenticationScheme, + string? displayName = null +) +``` + +Inline: + +```csharp +public static KeycloakWebAppAuthenticationBuilder AddKeycloakWebApp( + this AuthenticationBuilder builder, + Action configureKeycloakOptions, + Action? configureCookieAuthenticationOptions = null, + Action? configureOpenIdConnectOptions = null, + string openIdConnectScheme = OpenIdConnectDefaults.AuthenticationScheme, + string? cookieScheme = CookieAuthenticationDefaults.AuthenticationScheme, + string? displayName = null +) +``` + +See [source code](https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/blob/main/src/Keycloak.AuthServices.Authentication/WebAppExtensions/KeycloakWebAppAuthenticationBuilderExtensions.cs) for more details. + +## Adapter File Configuration Provider + +Using *appsettings.json* is a recommended and it is an idiomatic approach for .NET, but if you want a standalone "adapter" (installation) file - *keycloak.json*. You can use `ConfigureKeycloakConfigurationSource`. It adds dedicated configuration source. + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.Host.ConfigureKeycloakConfigurationSource("keycloak.json"); // [!code focus] + +builder.Services.AddKeycloakWebApiAuthentication(builder.Configuration); + +var app = builder.Build(); +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapGet("/", () => "Hello World!").RequireAuthorization(); + +app.Run(); +``` + +Here is an example of **keycloak.json** adapter file: + +```json +{ + "realm": "Test", + "auth-server-url": "http://localhost:8088/", + "ssl-required": "external", + "resource": "test-client", + "verify-token-audience": true +} + +``` + +## Keycloak Claims Transformation + +Keycloak roles can be automatically transformed to [AspNetCore Roles](https://learn.microsoft.com/en-us/aspnet/core/security/authorization/roles). This feature is disabled by default. + +There are three options to determine a source for the roles: + +```csharp +public enum RolesClaimTransformationSource +{ + /// + /// No Transformation. Default + /// + None, + + /// + /// Use realm roles as source + /// + Realm, + + /// + /// Use client roles as source + /// + ResourceAccess +} +``` + +Here is an example of decoded JWT token: + +```json +{ + "exp": 1714057504, + "iat": 1714057204, + "jti": "7250d2a9-e5a1-442f-9e76-5e6b78bb2760", + "iss": "http://localhost:8080/realms/Test", + "aud": [ + "test-client", + "account" + ], + "sub": "bf0b3371-ccdc-44f6-8861-ce25cbfcac39", + "typ": "Bearer", + "azp": "test-client", + "session_state": "563332d2-111a-4ef2-b6a0-ebc1d3ae9a1e", + "acr": "1", + "allowed-origins": [ + "/*" + ], + "realm_access": { + "roles": [ + "default-roles-test", + "offline_access", + "uma_authorization" + ] + }, + "resource_access": { + "account": { + "roles": [ + "manage-account", + "manage-account-links", + "view-profile" + ] + } + }, + "scope": "profile email", + "sid": "563332d2-111a-4ef2-b6a0-ebc1d3ae9a1e", + "email_verified": false, + "name": "Test Test", + "preferred_username": "test", + "given_name": "Test", + "family_name": "Test", + "email": "test@test.com" +} +``` + +If we specify `KeycloakAuthenticationOptions.RolesSource = RolesClaimTransformationSource.Realm` the roles are taken from $token.realm_access.roles. + +Result = ["default-roles-test","offline_access","uma_authorization"] + +If we specify `KeycloakAuthenticationOptions.RolesSource = RolesClaimTransformationSource.ResourceAccess` and `KeycloakAuthenticationOptions.RolesResource="account"` the roles are taken from $token.realm_access.account.roles. + +Result = ["manage-account","manage-account-links","view-profile"] + +The target claim can be configured `KeycloakAuthenticationOptions.RoleClaimType`, the default value is "role". diff --git a/docs/configuration/configuration-keycloak.md b/docs/configuration/configuration-keycloak.md index fafe73ce..70f4ff47 100644 --- a/docs/configuration/configuration-keycloak.md +++ b/docs/configuration/configuration-keycloak.md @@ -2,13 +2,8 @@ This section contains a general instruction of how to configure Keyclaok to be used for .NET applications. -- [Configure Keycloak](#configure-keycloak) - - [Create Realm](#create-realm) - - [Create User](#create-user) - - [Set Password](#set-password) - - [Create Client](#create-client) - - [Add Audience Mapper](#add-audience-mapper) - - [Download Adapter Config](#download-adapter-config) +*Table of Contents*: +[[toc]] ## Create Realm diff --git a/docs/examples/auth-clean-arch.md b/docs/examples/auth-clean-arch.md index d5996e77..df2123ff 100644 --- a/docs/examples/auth-clean-arch.md +++ b/docs/examples/auth-clean-arch.md @@ -1,81 +1,5 @@ # AuthorizationAndCleanArchitecture -```csharp -using Api; -using Api.Filters; -using Keycloak.AuthServices.Authentication; -using Keycloak.AuthServices.Authentication.Configuration; -using Keycloak.AuthServices.Authorization; -using Keycloak.AuthServices.Sdk.Admin; -using Microsoft.AspNetCore.Authorization; - - -var builder = WebApplication.CreateBuilder(args); - -var services = builder.Services; -var configuration = builder.Configuration; -var host = builder.Host; - -host.ConfigureLogger(); -host.ConfigureKeycloakConfigurationSource("keycloak.json"); - -services.AddInfrastructure(configuration); - -#pragma warning disable ASP0000 -DatabaseUtils.MigrateDatabase(services.BuildServiceProvider()); -#pragma warning restore ASP0000 - -services - .AddApplication() - .AddSwagger(); - -// adds client resource claims transformation -services.AddKeycloakWebApiAuthentication(configuration, o => -{ - o.RequireHttpsMetadata = false; -}); - -services.AddAuthorization(o => -{ - o.FallbackPolicy = new AuthorizationPolicyBuilder() - .RequireAuthenticatedUser() - .Build(); - - o.AddPolicy(PolicyConstants.MyCustomPolicy, b => - { - // b.AddRequirements(new DecisionRequirement("workspaces", "workspaces:read")); - b.RequireProtectedResource("workspaces", "workspaces:read"); - }); - - o.AddPolicy(PolicyConstants.CanDeleteAllWorkspaces, b => - { - b.RequireRealmRoles("SuperManager"); - }); - - o.AddPolicy(PolicyConstants.AccessManagement, b => - { - b.RequireResourceRoles("Manager"); - }); -}).AddKeycloakAuthorization(configuration); - -services.AddSingleton(); - -services.AddControllers(options => - options.Filters.Add()); - -services.AddKeycloakAdminHttpClient(configuration); - -var app = builder.Build(); - -app - .UseSwagger() - .UseSwaggerUI() - .UseAuthentication() - .UseAuthorization(); - -app.MapControllers(); - -app.Run(); -``` +<<< @/../samples/AuthorizationAndCleanArchitecture/Program.cs See sample source code: [keycloak-authorization-services-dotnet/tree/main/samples/AuthorizationAndCleanArchitecture](https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/tree/main/samples/AuthorizationAndCleanArchitecture) diff --git a/docs/examples/auth-getting-started.md b/docs/examples/auth-getting-started.md index a7ca1b1a..cdca6019 100644 --- a/docs/examples/auth-getting-started.md +++ b/docs/examples/auth-getting-started.md @@ -1,54 +1,5 @@ # AuthGettingStarted -```csharp -using System.Security.Claims; -using Api; -using Keycloak.AuthServices.Authentication; -using Keycloak.AuthServices.Authorization; -using Keycloak.AuthServices.Sdk.Admin; - -var builder = WebApplication.CreateBuilder(args); - -var services = builder.Services; -var configuration = builder.Configuration; -var host = builder.Host; - -host.ConfigureLogger(); - -services.AddEndpointsApiExplorer().AddSwagger(); - -services.AddKeycloakWebApiAuthentication(configuration); - -services - .AddAuthorization(o => - o.AddPolicy( - "IsAdmin", - b => - { - b.RequireRealmRoles("admin"); - b.RequireResourceRoles("r-admin"); - // TokenValidationParameters.RoleClaimType is overridden - // by KeycloakRolesClaimsTransformation - b.RequireRole("r-admin"); - } - ) - ) - .AddKeycloakAuthorization(configuration); - -services.AddKeycloakAdminHttpClient(configuration); - -var app = builder.Build(); - -app.UseSwagger().UseSwaggerUI(); - -app.UseAuthentication(); -app.UseAuthorization(); - -app.MapGet("/", (ClaimsPrincipal user) => app.Logger.LogInformation("{@User}", user.Identity.Name)) - .RequireAuthorization("IsAdmin"); - -app.Run(); - -``` +<<< @/../samples/AuthGettingStarted/Program.cs See sample source code: [keycloak-authorization-services-dotnet/tree/main/samples/AuthGettingStarted](https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/tree/main/samples/AuthGettingStarted) diff --git a/docs/examples/authorization-getting-started.md b/docs/examples/authorization-getting-started.md index 2cc729ac..9a971230 100644 --- a/docs/examples/authorization-getting-started.md +++ b/docs/examples/authorization-getting-started.md @@ -1,111 +1,6 @@ # AuthorizationGettingStarted -```csharp -public static partial class ServiceCollectionExtensions -{ - public static IServiceCollection AddAuth(this IServiceCollection services, IConfiguration configuration) - { - services.AddKeycloakWebApiAuthentication(configuration); - - services.AddAuthorization(options => - { - options.AddPolicy( - Policies.RequireAspNetCoreRole, - builder => builder.RequireRole(Roles.AspNetCoreRole)); - - options.AddPolicy( - Policies.RequireRealmRole, - builder => builder.RequireRealmRoles(Roles.RealmRole)); - - options.AddPolicy( - Policies.RequireClientRole, - builder => builder.RequireResourceRoles(Roles.ClientRole)); - - options.AddPolicy( - Policies.RequireToBeInKeycloakGroupAsReader, - builder => builder - .RequireAuthenticatedUser() - .RequireProtectedResource("workspace", "workspaces:read")); - - }).AddKeycloakAuthorization(configuration); - - return services; - } -} - -public static class AuthorizationConstants -{ - public static class Roles - { - public const string AspNetCoreRole = "realm-role"; - - public const string RealmRole = "realm-role"; - - public const string ClientRole = "client-role"; - } - - public static class Policies - { - public const string RequireAspNetCoreRole = nameof(RequireAspNetCoreRole); - - public const string RequireRealmRole = nameof(RequireRealmRole); - - public const string RequireClientRole = nameof(RequireClientRole); - - public const string RequireToBeInKeycloakGroupAsReader = nameof(RequireToBeInKeycloakGroupAsReader); - } -} -``` - -```csharp -using System.Security.Claims; -using System.Text.Json.Serialization; -using Microsoft.AspNetCore.Http.Json; -using static Microsoft.Extensions.DependencyInjection.AuthorizationConstants.Policies; - -var builder = WebApplication.CreateBuilder(args); - -var configuration = builder.Configuration; -var services = builder.Services; - -builder.AddSerilog(); - -services - .AddApplicationSwagger(configuration) - .AddAuth(configuration); - -services.Configure(opts => -{ - opts.SerializerOptions.ReferenceHandler = ReferenceHandler.Preserve; - opts.SerializerOptions.WriteIndented = true; -}); - -var app = builder.Build(); - -app - .UseHttpsRedirection() - .UseApplicationSwagger(configuration) - .UseAuthentication() - .UseAuthorization(); - -// login with required aspnet core identity role -app.MapGet("/endpoint1", (ClaimsPrincipal user) => user) - .RequireAuthorization(RequireAspNetCoreRole); - -// login with requireed realm role evaluated from corresponding claim -app.MapGet("/endpoint2", (ClaimsPrincipal user) => user) - .RequireAuthorization(RequireRealmRole); - -// login with requireed client role evaluated from corresponding claim -app.MapGet("/endpoint3", (ClaimsPrincipal user) => user) - .RequireAuthorization(RequireClientRole); - -// login based on remotely executed policy -// authorization is performed by Keycloak (Authorization Server) -app.MapGet("/endpoint4", (ClaimsPrincipal user) => user) - .RequireAuthorization(RequireToBeInKeycloakGroupAsReader); - -await app.RunAsync(); -``` +<<< @/../samples/AuthorizationGettingStarted/Program.cs +<<< @/../samples/AuthorizationGettingStarted/ServiceCollectionExtensions.Auth.cs See sample source code: [keycloak-authorization-services-dotnet/tree/main/samples/AuthGettingStarted](https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/tree/main/samples/AuthGettingStarted) diff --git a/docs/examples/web-api-blazor.md b/docs/examples/web-api-blazor.md index 25ab05c9..bc75e7a6 100644 --- a/docs/examples/web-api-blazor.md +++ b/docs/examples/web-api-blazor.md @@ -2,133 +2,10 @@ Client: -```csharp -using Blazor.Client; -using Microsoft.AspNetCore.Components.Web; -using Microsoft.AspNetCore.Components.WebAssembly.Authentication; -using Microsoft.AspNetCore.Components.WebAssembly.Hosting; - -var builder = WebAssemblyHostBuilder.CreateDefault(args); -builder.RootComponents.Add("#app"); -builder.RootComponents.Add("head::after"); - -var services = builder.Services; - -RegisterHttpClient(builder, services); - -builder.Services.AddOidcAuthentication(options => -{ - options.ProviderOptions.MetadataUrl = "http://localhost:8080/realms/Test/.well-known/openid-configuration"; - options.ProviderOptions.Authority = "http://localhost:8080/realms/Test"; - options.ProviderOptions.ClientId = "test-client"; - options.ProviderOptions.ResponseType = "id_token token"; - //options.ProviderOptions.DefaultScopes.Add("Audience"); - - options.UserOptions.NameClaim = "preferred_username"; - options.UserOptions.RoleClaim = "roles"; - options.UserOptions.ScopeClaim = "scope"; -}); - -var app = builder.Build(); - -await app.RunAsync(); - -static void RegisterHttpClient( - WebAssemblyHostBuilder builder, - IServiceCollection services) -{ - var httpClientName = "Default"; - - services.AddHttpClient(httpClientName, - client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)) - .AddHttpMessageHandler(); - - services.AddScoped( - sp => sp.GetRequiredService() - .CreateClient(httpClientName)); -} -``` +<<< @/../samples/Blazor/Client/Program.cs Server: -```csharp -using System.Globalization; -using Keycloak.AuthServices.Authentication; -using Microsoft.OpenApi.Models; -using Serilog; -using Serilog.Events; - -var builder = WebApplication.CreateBuilder(args); -var services = builder.Services; -var configuration = builder.Configuration; - -services.AddControllersWithViews(); -services.AddRazorPages(); - -services.AddEndpointsApiExplorer(); -var openIdConnectUrl = $"{configuration["Keycloak:auth-server-url"]}realms/{configuration["Keycloak:realm"]}/.well-known/openid-configuration"; - -services.AddSwaggerGen(c => -{ - var securityScheme = new OpenApiSecurityScheme - { - Name = "Auth", - In = ParameterLocation.Header, - Type = SecuritySchemeType.OpenIdConnect, - OpenIdConnectUrl = new Uri(openIdConnectUrl), - Scheme = "bearer", - BearerFormat = "JWT", - Reference = new OpenApiReference - { - Id = "Bearer", - Type = ReferenceType.SecurityScheme - } - }; - c.AddSecurityDefinition(securityScheme.Reference.Id, securityScheme); - c.AddSecurityRequirement(new OpenApiSecurityRequirement - { - {securityScheme, Array.Empty()} - }); -}); - -Log.Logger = new LoggerConfiguration() - .MinimumLevel.Verbose() - .WriteTo.Console( - outputTemplate: "[{Level:u4}] | {Message:lj}{NewLine}{Exception}", - restrictedToMinimumLevel: LogEventLevel.Information, - formatProvider: CultureInfo.InvariantCulture) - .CreateBootstrapLogger(); - -builder.Host.UseSerilog(); - -services.AddKeycloakWebApiAuthentication(configuration); - -var app = builder.Build(); - -if (app.Environment.IsDevelopment()) -{ - app.UseWebAssemblyDebugging(); - app.UseSwagger().UseSwaggerUI(); -} -else -{ - app.UseExceptionHandler("/Error"); -} - -app.UseBlazorFrameworkFiles(); -app.UseStaticFiles(); - -app.UseRouting(); - -app.UseAuthentication(); -app.UseAuthorization(); - -app.MapRazorPages(); -app.MapControllers(); -app.MapFallbackToFile("index.html"); - -app.Run(); -``` - +<<< @/../samples/Blazor/Server/Program.cs See sample source code: [keycloak-authorization-services-dotnet/tree/main/samples/Blazor](https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/tree/main/samples/Blazor) diff --git a/docs/getting-started.md b/docs/getting-started.md index f984c483..6cb71b1b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -16,6 +16,7 @@ Install **Keycloak.AuthServices.Authentication** by running: ```bash dotnet add package Keycloak.AuthServices.Authentication +dotnet add package Keycloak.AuthServices.Common ``` Replace the content of **Program.cs** with: @@ -38,6 +39,9 @@ app.MapGet("/", () => "Hello World!").RequireAuthorization(); // [!code focus] app.Run(); ``` +> [!TIP] +> 💡 For more detailed explanation of how to configure *Authentication* visit [Configuration/Authentication](/configuration/configuration-authentication) + ## Configure Keycloak Run docker image locally, as described in the [documentation](https://www.keycloak.org/getting-started/getting-started-docker). @@ -64,11 +68,12 @@ Here is high level overview of what we need to do to configure Keycloak for our 1. Name: **test-client** 2. Configure Client authentication - **On** (We will need it in the future to get access token via username:password) 6. [Configure the Audience Mapper](/configuration/configuration-keycloak#add-audience-mapper) - 1. ❗This is important, by default **Keycloak.AuthServices.Authentication** assume that the name of resource is the intended Audience / client. Otherwise, you will get **401** status code. + 1. ❗This is important, by default **Keycloak.AuthServices.Authentication** assume that the name of resource is the intended Audience / client. Otherwise, you will get **401** status code. + 2. Alternatively, you can specify `KeycloakAuthenticationOptions.VerifyTokenAudience=false`. ## Configure API -[Download the adapter](/configuration/configuration-keycloak#download-adapter-config) config so we can use it for seamless integration with Keycloak. All you need to do is to add the content of Adapter Config to the **appsettings.Development.json** "Keycloak" section like this: +[Download adapter config](/configuration/configuration-keycloak#download-adapter-config) so we can use it for seamless integration with Keycloak. All you need to do is to add the content of Adapter Config to the **appsettings.Development.json** "Keycloak" section like this: ```jsonc { diff --git a/docs/index.md b/docs/index.md index 0e47fceb..e5cae272 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,17 +10,14 @@ hero: text: Getting Started link: /introduction - theme: alt - text: Configuration - link: /configuration/configuration-keycloak - # - theme: alt - # text: Authentication - # link: /authentication - # - theme: alt - # text: Authorization - # link: /authorization - # - theme: alt - # text: Admin REST HTTP API - # link: /admin-rest-api + text: Authentication + link: /configuration/configuration-authentication + - theme: alt + text: Authorization + link: /configuration/configuration-authorization + - theme: alt + text: HTTP REST Admin API + link: /admin-rest-api features: - title: 🔒Authentication diff --git a/docs/introduction.md b/docs/introduction.md index cd634764..cce2f528 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -6,13 +6,14 @@ Welcome to the **Keycloak.AuthServices** documentation! [Keycloak](https://www.keycloak.org/) is an open-source identity and access management solution. It provides features like single sign-on, user authentication, and authorization for web applications and services. Keycloak allows you to secure your applications by managing user identities, roles, and permissions. It supports various authentication mechanisms, including username/password, social logins, and multi-factor authentication. Additionally, Keycloak provides integration with popular identity providers like Google, Facebook, and LDAP directories. It is widely used in enterprise applications to ensure secure and seamless user authentication and authorization. +> [!NOTE] > Keycloak is a Cloud Native Computing Foundation incubation project Here is good [getting-started](https://www.keycloak.org/getting-started/getting-started-docker) from the Keycloak's official [documentation](https://www.keycloak.org/documentation) website. -## What is Keycloak.AuthService? +## What is Keycloak.AuthServices? -Keycloak.AuthService is a [family of packages](https://www.nuget.org/packages?q=Keycloak.AuthServices) that provides you everything you need to integrate and use Keycloak. From Single-Sign On and OpenId Connect (OIDC) to Keycloak Admin REST API integration. +Keycloak.AuthServices is a [family of packages](https://www.nuget.org/packages?q=Keycloak.AuthServices) that provides you everything you need to integrate and use Keycloak. From Single-Sign On and OpenId Connect (OIDC) to Keycloak Admin REST API integration. ## Packages diff --git a/docs/migration.md b/docs/migration.md index a3d40f2e..9d1f4faa 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -2,6 +2,7 @@ ## Key Changes in 2.0.0 +* `RolesClaimTransformationSource` change to `None` from `ResourceAccess` meaning we no longer map to `AspNetCore` roles by default. * Moved `IKeycloakProtectionClient` to `Keycloak.AuthServices.Authorization`. Removed `AddKeycloakProtectionHttpClient`, added `AddAuthorizationServer` instead. ```csharp diff --git a/src/Keycloak.AuthServices.Authentication/Claims/KeycloakRolesClaimsTransformation.cs b/src/Keycloak.AuthServices.Authentication/Claims/KeycloakRolesClaimsTransformation.cs index 98ff6591..f7b4e86f 100644 --- a/src/Keycloak.AuthServices.Authentication/Claims/KeycloakRolesClaimsTransformation.cs +++ b/src/Keycloak.AuthServices.Authentication/Claims/KeycloakRolesClaimsTransformation.cs @@ -34,15 +34,16 @@ public class KeycloakRolesClaimsTransformation : IClaimsTransformation /// /// Type of the role claim. /// - /// The audience. + /// The audience. public KeycloakRolesClaimsTransformation( string roleClaimType, RolesClaimTransformationSource roleSource, - string audience) + string resource + ) { this.roleClaimType = roleClaimType; this.roleSource = roleSource; - this.audience = audience; + this.audience = resource; } /// @@ -57,6 +58,12 @@ public KeycloakRolesClaimsTransformation( public Task TransformAsync(ClaimsPrincipal principal) { var result = principal.Clone(); + + if (this.roleSource == RolesClaimTransformationSource.None) + { + return Task.FromResult(result); + } + if (result.Identity is not ClaimsIdentity identity) { return Task.FromResult(result); @@ -71,9 +78,10 @@ public Task TransformAsync(ClaimsPrincipal principal) } using var resourceAccess = JsonDocument.Parse(resourceAccessValue); - var containsAudienceRoles = resourceAccess - .RootElement - .TryGetProperty(this.audience, out var rolesElement); + var containsAudienceRoles = resourceAccess.RootElement.TryGetProperty( + this.audience, + out var rolesElement + ); if (!containsAudienceRoles) { @@ -86,9 +94,12 @@ public Task TransformAsync(ClaimsPrincipal principal) { var value = role.GetString(); - var matchingClaim = identity.Claims.FirstOrDefault(claim => - claim.Type.Equals(this.roleClaimType, StringComparison.InvariantCultureIgnoreCase) && - claim.Value.Equals(value, StringComparison.InvariantCultureIgnoreCase)); + var matchingClaim = identity.Claims.FirstOrDefault(claim => + claim.Type.Equals( + this.roleClaimType, + StringComparison.InvariantCultureIgnoreCase + ) && claim.Value.Equals(value, StringComparison.InvariantCultureIgnoreCase) + ); if (matchingClaim is null && !string.IsNullOrWhiteSpace(value)) { @@ -109,9 +120,10 @@ public Task TransformAsync(ClaimsPrincipal principal) using var realmAccess = JsonDocument.Parse(realmAccessValue); - var containsRoles = realmAccess - .RootElement - .TryGetProperty("roles", out var rolesElement); + var containsRoles = realmAccess.RootElement.TryGetProperty( + "roles", + out var rolesElement + ); if (containsRoles) { @@ -119,9 +131,12 @@ public Task TransformAsync(ClaimsPrincipal principal) { var value = role.GetString(); - var matchingClaim = identity.Claims.FirstOrDefault(claim => - claim.Type.Equals(this.roleClaimType, StringComparison.InvariantCultureIgnoreCase) && - claim.Value.Equals(value, StringComparison.InvariantCultureIgnoreCase)); + var matchingClaim = identity.Claims.FirstOrDefault(claim => + claim.Type.Equals( + this.roleClaimType, + StringComparison.InvariantCultureIgnoreCase + ) && claim.Value.Equals(value, StringComparison.InvariantCultureIgnoreCase) + ); if (matchingClaim is null && !string.IsNullOrWhiteSpace(value)) { diff --git a/src/Keycloak.AuthServices.Authentication/KeycloakAuthenticationOptions.cs b/src/Keycloak.AuthServices.Authentication/KeycloakAuthenticationOptions.cs index 3bb5c3e9..8431588c 100644 --- a/src/Keycloak.AuthServices.Authentication/KeycloakAuthenticationOptions.cs +++ b/src/Keycloak.AuthServices.Authentication/KeycloakAuthenticationOptions.cs @@ -28,8 +28,13 @@ public class KeycloakAuthenticationOptions : KeycloakInstallationOptions public string NameClaimType { get; set; } = "preferred_username"; /// - /// RolesClaimTransformationSource + /// Determines the source for roles /// public RolesClaimTransformationSource RolesSource { get; set; } = - RolesClaimTransformationSource.ResourceAccess; + RolesClaimTransformationSource.None; + + /// + /// The name of the resource to be used. Only relevant for RolesSource = RolesClaimTransformationSource.ResourceAccess + /// + public string? RolesResource { get; set; } } diff --git a/src/Keycloak.AuthServices.Authentication/ServiceCollectionExtensions.cs b/src/Keycloak.AuthServices.Authentication/ServiceCollectionExtensions.cs index 7506926d..04ce095e 100644 --- a/src/Keycloak.AuthServices.Authentication/ServiceCollectionExtensions.cs +++ b/src/Keycloak.AuthServices.Authentication/ServiceCollectionExtensions.cs @@ -17,7 +17,7 @@ public static class ServiceCollectionExtensions /// /// Adds keycloak authentication services. /// - [Obsolete("This method will be removed. Use AddKeycloakWebApiAuthentication")] + [Obsolete("This method will be removed. Use AddKeycloakWebApiAuthentication. Furthermore, the way KeycloakAuthenticationOptions is changed and you need to specify KeycloakFormatBinder.Instance to correctly bind the instance. See for more details https://nikiforovall.github.io/keycloak-authorization-services-dotnet/migration.html#key-changes-in-2-0-0")] public static AuthenticationBuilder AddKeycloakAuthentication( this IServiceCollection services, KeycloakAuthenticationOptions keycloakOptions, @@ -38,7 +38,7 @@ public static AuthenticationBuilder AddKeycloakAuthentication( services.AddTransient(_ => new KeycloakRolesClaimsTransformation( roleClaimType, keycloakOptions.RolesSource, - keycloakOptions.Resource + keycloakOptions.RolesResource ?? keycloakOptions.Resource )); return services @@ -68,7 +68,7 @@ public static AuthenticationBuilder AddKeycloakAuthentication( /// Configuration source /// Configure overrides /// - [Obsolete("This method is obsolete and will be removed. Use AddKeycloakWebApiAuthentication")] + [Obsolete("This method will be removed. Use AddKeycloakWebApiAuthentication")] public static AuthenticationBuilder AddKeycloakAuthentication( this IServiceCollection services, IConfiguration configuration, @@ -90,7 +90,7 @@ public static AuthenticationBuilder AddKeycloakAuthentication( /// /// /// - [Obsolete("This method is obsolete and will be removed. Use AddKeycloakWebApiAuthentication")] + [Obsolete("This method will be removed. Use AddKeycloakWebApiAuthentication")] public static AuthenticationBuilder AddKeycloakAuthentication( this IServiceCollection services, IConfiguration configuration, diff --git a/src/Keycloak.AuthServices.Authentication/WebApiExtensions/KeycloakWebApiAuthenticationBuilder.cs b/src/Keycloak.AuthServices.Authentication/WebApiExtensions/KeycloakWebApiAuthenticationBuilder.cs index 15d6c1c7..708e66dc 100644 --- a/src/Keycloak.AuthServices.Authentication/WebApiExtensions/KeycloakWebApiAuthenticationBuilder.cs +++ b/src/Keycloak.AuthServices.Authentication/WebApiExtensions/KeycloakWebApiAuthenticationBuilder.cs @@ -35,9 +35,7 @@ internal KeycloakWebApiAuthenticationBuilder( ArgumentNullException.ThrowIfNull(configureKeycloakOptions); this.Services.AddOptions(jwtBearerAuthenticationScheme) - .Configure(configureKeycloakOptions) - .ValidateDataAnnotations() - .ValidateOnStart(); + .Configure(configureKeycloakOptions); } /// diff --git a/src/Keycloak.AuthServices.Authentication/WebApiExtensions/KeycloakWebApiAuthenticationBuilderExtensions.cs b/src/Keycloak.AuthServices.Authentication/WebApiExtensions/KeycloakWebApiAuthenticationBuilderExtensions.cs index 14f4ab8b..5d56632e 100644 --- a/src/Keycloak.AuthServices.Authentication/WebApiExtensions/KeycloakWebApiAuthenticationBuilderExtensions.cs +++ b/src/Keycloak.AuthServices.Authentication/WebApiExtensions/KeycloakWebApiAuthenticationBuilderExtensions.cs @@ -55,18 +55,23 @@ public static KeycloakWebApiAuthenticationBuilder AddKeycloakWebApi( ArgumentNullException.ThrowIfNull(configurationSection); ArgumentNullException.ThrowIfNull(builder); +#pragma warning disable IDE0039 // Use local function + Action configureJwtBearerOptions = _ => { }; +#pragma warning restore IDE0039 // Use local function + AddKeycloakWebApiImplementation( - builder, - options => configurationSection.Bind(options, KeycloakFormatBinder.Instance), + builder: builder, + configureJwtBearerOptions: configureJwtBearerOptions, jwtBearerScheme ); return new KeycloakWebApiAuthenticationBuilder( - builder.Services, - jwtBearerScheme, - options => configurationSection.Bind(options, KeycloakFormatBinder.Instance), - options => configurationSection.Bind(options, KeycloakFormatBinder.Instance), - configurationSection + services: builder.Services, + jwtBearerAuthenticationScheme: jwtBearerScheme, + configureJwtBearerOptions: configureJwtBearerOptions, + configureKeycloakOptions: options => + configurationSection.Bind(options, KeycloakFormatBinder.Instance), + configurationSection: configurationSection ); } @@ -81,12 +86,12 @@ public static KeycloakWebApiAuthenticationBuilder AddKeycloakWebApi( public static KeycloakWebApiAuthenticationBuilder AddKeycloakWebApi( this AuthenticationBuilder builder, Action configureKeycloakOptions, - Action configureJwtBearerOptions, + Action? configureJwtBearerOptions = default, string jwtBearerScheme = JwtBearerDefaults.AuthenticationScheme ) { + configureJwtBearerOptions ??= _ => { }; ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(configureJwtBearerOptions); ArgumentNullException.ThrowIfNull(configureKeycloakOptions); AddKeycloakWebApiImplementation(builder, configureJwtBearerOptions, jwtBearerScheme); @@ -103,7 +108,7 @@ public static KeycloakWebApiAuthenticationBuilder AddKeycloakWebApi( private static void AddKeycloakWebApiImplementation( AuthenticationBuilder builder, Action configureJwtBearerOptions, - string jwtBearerScheme + string jwtBearerScheme = JwtBearerDefaults.AuthenticationScheme ) { builder.Services.AddTransient(sp => @@ -115,7 +120,7 @@ string jwtBearerScheme return new KeycloakRolesClaimsTransformation( keycloakOptions.RoleClaimType, keycloakOptions.RolesSource, - keycloakOptions.Resource + keycloakOptions.RolesResource ?? keycloakOptions.Resource ); }); diff --git a/src/Keycloak.AuthServices.Authentication/WebApiExtensions/KeycloakWebApiServiceCollectionExtensions.cs b/src/Keycloak.AuthServices.Authentication/WebApiExtensions/KeycloakWebApiServiceCollectionExtensions.cs index 28d1ef06..32c89dd6 100644 --- a/src/Keycloak.AuthServices.Authentication/WebApiExtensions/KeycloakWebApiServiceCollectionExtensions.cs +++ b/src/Keycloak.AuthServices.Authentication/WebApiExtensions/KeycloakWebApiServiceCollectionExtensions.cs @@ -31,6 +31,25 @@ public static KeycloakWebApiAuthenticationBuilder AddKeycloakWebApiAuthenticatio return builder.AddKeycloakWebApi(configuration, configSectionName, jwtBearerScheme); } + /// + /// Protects the web API with Keycloak. + /// This method expects the configuration file will have a section, named "Keycloak" as default, with the necessary settings to initialize authentication options. + /// + /// The service collection to which to add authentication. + /// The Configuration section object. + /// The JwtBearer scheme name to be used. Default value is "JwtBearerDefaults.AuthenticationScheme". + /// The authentication builder to chain extension methods. + public static KeycloakWebApiAuthenticationBuilder AddKeycloakWebApiAuthentication( + this IServiceCollection services, + IConfigurationSection configurationSection, + string jwtBearerScheme = JwtBearerDefaults.AuthenticationScheme + ) + { + var builder = services.AddAuthentication(jwtBearerScheme); + + return builder.AddKeycloakWebApi(configurationSection, jwtBearerScheme); + } + /// /// Protects the web API with Keycloak. /// This method expects the configuration file will have a section, named "Keycloak" as default, with the necessary settings to initialize authentication options. diff --git a/src/Keycloak.AuthServices.Authentication/WebAppExtensions/KeycloakWebAppAuthenticationBuilder.cs b/src/Keycloak.AuthServices.Authentication/WebAppExtensions/KeycloakWebAppAuthenticationBuilder.cs index 22692322..339d75e4 100644 --- a/src/Keycloak.AuthServices.Authentication/WebAppExtensions/KeycloakWebAppAuthenticationBuilder.cs +++ b/src/Keycloak.AuthServices.Authentication/WebAppExtensions/KeycloakWebAppAuthenticationBuilder.cs @@ -34,9 +34,7 @@ internal KeycloakWebAppAuthenticationBuilder( ArgumentNullException.ThrowIfNull(configureKeycloakOptions); this.Services.AddOptions(openIdConnectScheme) - .Configure(configureKeycloakOptions) - .ValidateDataAnnotations() - .ValidateOnStart(); + .Configure(configureKeycloakOptions); } /// diff --git a/src/Keycloak.AuthServices.Authentication/WebAppExtensions/KeycloakWebAppAuthenticationBuilderExtensions.cs b/src/Keycloak.AuthServices.Authentication/WebAppExtensions/KeycloakWebAppAuthenticationBuilderExtensions.cs index 74bcc3ef..9aef8d2c 100644 --- a/src/Keycloak.AuthServices.Authentication/WebAppExtensions/KeycloakWebAppAuthenticationBuilderExtensions.cs +++ b/src/Keycloak.AuthServices.Authentication/WebAppExtensions/KeycloakWebAppAuthenticationBuilderExtensions.cs @@ -229,15 +229,17 @@ private static void AddKeycloakWebAppInternal( return new KeycloakRolesClaimsTransformation( keycloakOptions.RoleClaimType, keycloakOptions.RolesSource, - keycloakOptions.Resource + keycloakOptions.RolesResource ?? keycloakOptions.Resource ); }); - builder.Services.Configure(openIdConnectScheme, configureKeycloakOptions); + configureCookieAuthenticationOptions ??= _ => { }; + + builder.Services.Configure(openIdConnectScheme, configureCookieAuthenticationOptions); if (!string.IsNullOrEmpty(cookieScheme)) { - builder.AddCookie(cookieScheme, configureCookieAuthenticationOptions ?? (_ => { })); + builder.AddCookie(cookieScheme, configureCookieAuthenticationOptions); } if (!string.IsNullOrEmpty(displayName)) diff --git a/src/Keycloak.AuthServices.Common/KeycloakInstallationOptions.cs b/src/Keycloak.AuthServices.Common/KeycloakInstallationOptions.cs index b90428c6..79ad7f4b 100644 --- a/src/Keycloak.AuthServices.Common/KeycloakInstallationOptions.cs +++ b/src/Keycloak.AuthServices.Common/KeycloakInstallationOptions.cs @@ -1,6 +1,5 @@ namespace Keycloak.AuthServices.Common; -using System.ComponentModel.DataAnnotations; using Microsoft.Extensions.Configuration; /// @@ -23,7 +22,6 @@ public class KeycloakInstallationOptions /// "auth-server-url": "http://localhost:8088/auth/" /// [ConfigurationKeyName("AuthServerUrl")] - [Required] public string AuthServerUrl { get => this.authServerUrl ?? this.AuthServerUrl2; @@ -36,7 +34,6 @@ public string AuthServerUrl /// /// Keycloak Realm /// - [Required] public string Realm { get; set; } = string.Empty; /// diff --git a/tests/.editorconfig b/tests/.editorconfig index 79bfd7f0..75c9d089 100644 --- a/tests/.editorconfig +++ b/tests/.editorconfig @@ -1,2 +1,8 @@ [*.cs] dotnet_diagnostic.CA1707.severity = none + +# CA1711: Identifiers should not have incorrect suffix +dotnet_diagnostic.CA1711.severity = suggestion + +# IDE0022: Use expression body for method +dotnet_diagnostic.IDE0022.severity = suggestion diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index aa54dc7e..55fb64ad 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -10,12 +10,12 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiAuthenticationTests.cs b/tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiAuthenticationTests.cs new file mode 100644 index 00000000..6d9e1d67 --- /dev/null +++ b/tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiAuthenticationTests.cs @@ -0,0 +1,155 @@ +namespace Keycloak.AuthServices.IntegrationTests; + +using System.Net; +using Alba; +using Keycloak.AuthServices.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +public class AddKeycloakWebApiAuthenticationTests(KeycloakFixture fixture) + : AuthenticationScenario(fixture) +{ + private const string Endpoint1 = "/endpoints/1"; + private static readonly string AppSettings = "appsettings.json"; + + [Fact] + public async Task AddKeycloakWebApiAuthentication_FromConfiguration_Unauthorized() + { + await using var host = await AlbaHost.For(x => + { + x.UseConfiguration(AppSettings); + x.ConfigureServices( + (context, services) => + AddKeycloakWebApiAuthentication_FromConfiguration_Setup( + services, + context.Configuration + ) + ); + }); + + await host.Scenario(_ => + { + _.Get.Url(Endpoint1); + _.StatusCodeShouldBe(HttpStatusCode.Unauthorized); + }); + } + + private static void AddKeycloakWebApiAuthentication_FromConfiguration_Setup( + IServiceCollection services, + IConfiguration configuration + ) + { + // #region AddKeycloakWebApiAuthentication_FromConfiguration + services.AddKeycloakWebApiAuthentication(configuration); + // #endregion AddKeycloakWebApiAuthentication_FromConfiguration + } + + [Fact] + public async Task AddKeycloakWebApiAuthentication_FromConfiguration2_Unauthorized() + { + await using var host = await AlbaHost.For(x => + { + x.UseConfiguration(AppSettings); + x.ConfigureServices( + (context, services) => + AddKeycloakWebApiAuthentication_FromConfiguration2_Setup( + services, + context.Configuration + ) + ); + }); + + await host.Scenario(_ => + { + _.Get.Url(Endpoint1); + _.StatusCodeShouldBe(HttpStatusCode.Unauthorized); + }); + } + + private static void AddKeycloakWebApiAuthentication_FromConfiguration2_Setup( + IServiceCollection services, + IConfiguration configuration + ) + { + // #region AddKeycloakWebApiAuthentication_FromConfiguration2 + services.AddKeycloakWebApiAuthentication( + configuration, + KeycloakAuthenticationOptions.Section + ); + // #endregion AddKeycloakWebApiAuthentication_FromConfiguration2 + } + + [Fact] + public async Task AddKeycloakWebApiAuthentication_FromConfigurationWithOverrides_Unauthorized() + { + await using var host = await AlbaHost.For(x => + { + x.UseConfiguration(AppSettings); + x.ConfigureServices( + (context, services) => + AddKeycloakWebApiAuthentication_FromConfigurationWithOverrides_Setup( + services, + context.Configuration + ) + ); + }); + + await host.Scenario(_ => + { + _.Get.Url(Endpoint1); + _.StatusCodeShouldBe(HttpStatusCode.Unauthorized); + }); + } + + private static void AddKeycloakWebApiAuthentication_FromConfigurationWithOverrides_Setup( + IServiceCollection services, + IConfiguration configuration + ) + { + // #region AddKeycloakWebApiAuthentication_FromConfigurationWithOverrides + services.AddKeycloakWebApiAuthentication( + configuration, + (JwtBearerOptions options) => + { + options.RequireHttpsMetadata = false; + options.Audience = "test-client"; + } + ); + // #endregion AddKeycloakWebApiAuthentication_FromConfigurationWithOverrides + } + + [Fact] + public async Task AddKeycloakWebApiAuthentication_FromConfigurationSection_Unauthorized() + { + await using var host = await AlbaHost.For(x => + { + x.UseConfiguration(AppSettings); + x.ConfigureServices( + (context, services) => + AddKeycloakWebApiAuthentication_FromConfigurationSection_Setup( + services, + context.Configuration + ) + ); + }); + + await host.Scenario(_ => + { + _.Get.Url(Endpoint1); + _.StatusCodeShouldBe(HttpStatusCode.Unauthorized); + }); + } + + private static void AddKeycloakWebApiAuthentication_FromConfigurationSection_Setup( + IServiceCollection services, + IConfiguration configuration + ) + { + // #region AddKeycloakWebApiAuthentication_FromConfigurationSection + services.AddKeycloakWebApiAuthentication( + configuration.GetSection(KeycloakAuthenticationOptions.Section) + ); + // #endregion AddKeycloakWebApiAuthentication_FromConfigurationSection + } +} diff --git a/tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiTests.cs b/tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiTests.cs new file mode 100644 index 00000000..0e367084 --- /dev/null +++ b/tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiTests.cs @@ -0,0 +1,147 @@ +namespace Keycloak.AuthServices.IntegrationTests; + +using System.Net; +using Alba; +using Keycloak.AuthServices.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +public class AddKeycloakWebApiTests(KeycloakFixture fixture) : AuthenticationScenario(fixture) +{ + private const string Endpoint1 = "/endpoints/1"; + private static readonly string AppSettings = "appsettings.json"; + + [Fact] + public async Task AddKeycloakWebApi_FromConfiguration_Unauthorized() + { + await using var host = await AlbaHost.For(x => + { + x.UseConfiguration(AppSettings); + x.ConfigureServices( + (context, services) => + AddKeycloakWebApi_FromConfiguration_Setup(services, context.Configuration) + ); + }); + + await host.Scenario(_ => + { + _.Get.Url(Endpoint1); + _.StatusCodeShouldBe(HttpStatusCode.Unauthorized); + }); + } + + private static void AddKeycloakWebApi_FromConfiguration_Setup( + IServiceCollection services, + IConfiguration configuration + ) + { + // #region AddKeycloakWebApiAuthentication_FromConfiguration + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddKeycloakWebApi(configuration); + // #endregion AddKeycloakWebApiAuthentication_FromConfiguration + } + + [Fact] + public async Task AddKeycloakWebApi_FromConfigurationSection_Unauthorized() + { + await using var host = await AlbaHost.For(x => + { + x.UseConfiguration(AppSettings); + x.ConfigureServices( + (context, services) => + AddKeycloakWebApi_FromConfigurationSection_Setup( + services, + context.Configuration + ) + ); + }); + + await host.Scenario(_ => + { + _.Get.Url(Endpoint1); + _.StatusCodeShouldBe(HttpStatusCode.Unauthorized); + }); + } + + private static void AddKeycloakWebApi_FromConfigurationSection_Setup( + IServiceCollection services, + IConfiguration configuration + ) + { + // #region AddKeycloakWebApiAuthentication_FromConfigurationSection + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddKeycloakWebApi(configuration.GetSection(KeycloakAuthenticationOptions.Section)); + // #endregion AddKeycloakWebApiAuthentication_FromConfigurationSection + } + + [Fact] + public async Task AddKeycloakWebApi_FromInline_Unauthorized() + { + await using var host = await AlbaHost.For(x => + { + x.UseConfiguration(AppSettings); + x.ConfigureServices(AddKeycloakWebApi_FromInline_Setup); + }); + + await host.Scenario(_ => + { + _.Get.Url(Endpoint1); + _.StatusCodeShouldBe(HttpStatusCode.Unauthorized); + }); + } + + private static void AddKeycloakWebApi_FromInline_Setup(IServiceCollection services) + { + // #region AddKeycloakWebApiAuthentication_FromInline + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddKeycloakWebApi(options => + { + options.Resource = "test-client"; + options.Realm = "Test"; + options.SslRequired = "none"; + options.AuthServerUrl = "http://localhost:8080/"; + }); + // #endregion AddKeycloakWebApiAuthentication_FromInline + } + + [Fact] + public async Task AddKeycloakWebApi_FromInline2_Unauthorized() + { + await using var host = await AlbaHost.For(x => + { + x.UseConfiguration(AppSettings); + x.ConfigureServices(AddKeycloakWebApi_FromInline2_Setup); + }); + + await host.Scenario(_ => + { + _.Get.Url(Endpoint1); + _.StatusCodeShouldBe(HttpStatusCode.Unauthorized); + }); + } + + private static void AddKeycloakWebApi_FromInline2_Setup(IServiceCollection services) + { + // #region AddKeycloakWebApiAuthentication_FromInline2 + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddKeycloakWebApi( + options => + { + options.Resource = "test-client"; + options.Realm = "Test"; + options.AuthServerUrl = "http://localhost:8080/"; + }, + options => + { + options.RequireHttpsMetadata = false; + options.Audience = "test-client"; + } + ); + // #endregion AddKeycloakWebApiAuthentication_FromInline2 + } +} diff --git a/tests/Keycloak.AuthServices.IntegrationTests/ConfigurationUtils.cs b/tests/Keycloak.AuthServices.IntegrationTests/ConfigurationUtils.cs new file mode 100644 index 00000000..31647ef1 --- /dev/null +++ b/tests/Keycloak.AuthServices.IntegrationTests/ConfigurationUtils.cs @@ -0,0 +1,19 @@ +namespace Keycloak.AuthServices.IntegrationTests; + +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; + +public static class ConfigurationUtils +{ + public static IWebHostBuilder UseConfiguration( + this IWebHostBuilder hostBuilder, + string fileName + ) => + hostBuilder.UseConfiguration( + new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile(fileName) + .AddEnvironmentVariables() + .Build() + ); +} diff --git a/tests/Keycloak.AuthServices.IntegrationTests/Keycloak.AuthServices.IntegrationTests.csproj b/tests/Keycloak.AuthServices.IntegrationTests/Keycloak.AuthServices.IntegrationTests.csproj new file mode 100644 index 00000000..0ba69e50 --- /dev/null +++ b/tests/Keycloak.AuthServices.IntegrationTests/Keycloak.AuthServices.IntegrationTests.csproj @@ -0,0 +1,30 @@ + + + + false + true + + + + + + + + + + + + + + + PreserveNewest + + + + + + + + + + diff --git a/tests/Keycloak.AuthServices.IntegrationTests/KeycloakFixture.cs b/tests/Keycloak.AuthServices.IntegrationTests/KeycloakFixture.cs new file mode 100644 index 00000000..634ff98c --- /dev/null +++ b/tests/Keycloak.AuthServices.IntegrationTests/KeycloakFixture.cs @@ -0,0 +1,26 @@ +namespace Keycloak.AuthServices.IntegrationTests; + +using Testcontainers.Keycloak; + +public class KeycloakFixture : IAsyncLifetime +{ + public KeycloakContainer Keycloak { get; } = new KeycloakBuilder() + .WithImage("quay.io/keycloak/keycloak:24.0.3") + .Build(); + + public string BaseAddress => this.Keycloak.GetBaseAddress(); + public string ContainerId => $"{this.Keycloak.Id}"; + + public Task InitializeAsync() => this.Keycloak.StartAsync(); + + public Task DisposeAsync() => this.Keycloak.DisposeAsync().AsTask(); +} + +[CollectionDefinition(nameof(AuthenticationCollection))] +public class AuthenticationCollection : ICollectionFixture; + +[Collection(nameof(AuthenticationCollection))] +public abstract class AuthenticationScenario(KeycloakFixture fixture) +{ + public KeycloakContainer Keycloak { get; } = fixture.Keycloak; +} diff --git a/tests/Keycloak.AuthServices.IntegrationTests/appsettings.json b/tests/Keycloak.AuthServices.IntegrationTests/appsettings.json new file mode 100644 index 00000000..bd66d67a --- /dev/null +++ b/tests/Keycloak.AuthServices.IntegrationTests/appsettings.json @@ -0,0 +1,13 @@ +{ + "Keycloak": { + "realm": "Test", + "auth-server-url": "http://localhost:8080/", + "ssl-required": "none", + "resource": "test-client", + "verify-token-audience": false, + "credentials": { + "secret": "Tgx4lvbyhho7oNFmiIupDRVA8ioQY7PW" + }, + "confidential-port": 0 + } +} diff --git a/tests/TestWebApi/Program.cs b/tests/TestWebApi/Program.cs new file mode 100644 index 00000000..9fddd4f1 --- /dev/null +++ b/tests/TestWebApi/Program.cs @@ -0,0 +1,25 @@ +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddAuthorization(); + +var app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); + +var endpoints = app + .MapGroup("/endpoints") + .RequireAuthorization(); + +endpoints.MapGet("1", Run); +endpoints.MapGet("2", Run).RequireAuthorization("Policy1"); +endpoints.MapGet("3", Run).RequireAuthorization("Policy2"); +endpoints.MapGet("4", Run).RequireAuthorization("Policy2"); + +app.Run(); + +static Response Run() => new(true); + +public partial class Program { } + +public record Response(bool Success); diff --git a/tests/TestWebApi/TestWebApi.csproj b/tests/TestWebApi/TestWebApi.csproj new file mode 100644 index 00000000..375cccf7 --- /dev/null +++ b/tests/TestWebApi/TestWebApi.csproj @@ -0,0 +1,3 @@ + + + diff --git a/tests/TestWebApi/appsettings.json b/tests/TestWebApi/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/tests/TestWebApi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}