diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 8b6b0add..45b2c5a8 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,11 +1,13 @@ import { defineConfig } from 'vitepress' +import { withMermaid } from "vitepress-plugin-mermaid"; // https://vitepress.dev/reference/site-config -export default defineConfig({ +export default withMermaid({ title: "Keycloak.AuthServices", description: "", base: '/keycloak-authorization-services-dotnet/', themeConfig: { + logo: '/logo.svg', // https://vitepress.dev/reference/default-theme-config nav: [ { text: 'Home', link: '/' }, @@ -13,7 +15,6 @@ export default defineConfig({ { text: 'Migration', link: '/migration' }, { text: 'Examples', link: 'examples/auth-getting-started' } ], - sidebar: { '/': [ { @@ -38,6 +39,7 @@ export default defineConfig({ collapsed: false, items: [ { text: 'Authorization Server', link: '/authorization/authorization-server' }, + { text: 'Protected Resources ✨', link: '/authorization/resources' }, ] }, { @@ -54,6 +56,9 @@ export default defineConfig({ }, socialLinks: [ { icon: 'github', link: 'https://github.com/NikiforovAll/keycloak-authorization-services-dotnet' } - ] + ], + editLink: { + pattern: 'https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/edit/main/docs/:path' + } } -}) +}); diff --git a/docs/authorization/authorization-server.md b/docs/authorization/authorization-server.md index 0f477706..6ab9f497 100644 --- a/docs/authorization/authorization-server.md +++ b/docs/authorization/authorization-server.md @@ -1,18 +1,32 @@ # Authorization Server -Keycloak is an open-source Identity and Access Management solution that provides an Authorization Server. The Authorization Server is responsible for access to clients after successfully authenticating and authorizing the users. +Keycloak is an open-source Identity and Access Management solution that provides an Authorization Server. The Authorization Server is responsible for access to resources. -In addition to the Authorization Server, Keycloak also supports a *Policy Enforcement Point* (PEP). The PEP is responsible for enforcing access control policies and protecting resources. It intercepts requests from clients and verifies the access token before allowing or denying access to the requested resource. +> [!TIP] +> See Keycloak's documentation - [Authorization Services Guide](https://www.keycloak.org/docs/latest/authorization_services) for more details. + +This functionality is based on a *Policy Enforcement Point* (**PEP**). The PEP is responsible for enforcing access control policies and protecting resources. It intercepts requests from clients (users) and verifies the access token before allowing or denying access to the requested resource. By integrating Keycloak's Authorization Server and PEP into your application, you can implement fine-grained access control and secure your resources based on user roles, permissions, and other attributes. +The PEP works together with the Policy Decision Point (**PDP**), which is the component that actually makes the decision whether access should be granted based on the policies defined in Keycloak. + +When a request to access a resource is made, the PEP intercepts the request and sends a request to the PDP to evaluate the policies associated with the requested resource. The PDP evaluates the policies and returns a decision (permit or deny) back to the PEP. The PEP then enforces this decision. + +Remember that to use the PEP endpoint and the Keycloak Authorization Services, you need to enable authorization for your client in the Keycloak admin console. + +![authz-arch-overview](/images/authz-arch-overview.png) + +> [!TIP] +> See Keycloak's documentation - [Authorization Server Architecture](https://www.keycloak.org/docs/latest/authorization_services/index.html#_overview_architecture) for more details. + ## Evaluate Permissions Assume we have a default resource with Name *"urn:test-client:resources:default"*. We want to check if a given user has access to it. It is accomplished based on permissions. In our case default permission is applied to default resource type. *"Default Permission"* is based on policy named - *"Require Admin Role"*. This policy checks if a user has *"Admin"* realm role. -Here is how to do it from code: +Here is how to use `AuthorizationBuilder` to define policy for a protected resource: <<< @/../tests/Keycloak.AuthServices.IntegrationTests/AuthorizationServerPolicyTests.cs#RequireProtectedResource_DefaultResource_Verified diff --git a/docs/authorization/resources.md b/docs/authorization/resources.md new file mode 100644 index 00000000..a6436a7a --- /dev/null +++ b/docs/authorization/resources.md @@ -0,0 +1,88 @@ +# Protected Resources + +*Keycloak Authorization Server* is a powerful tool that provides fine-grained access control to your services and applications. It enables developers to manage **permissions** and **policies** *centrally*, providing a standardized way to secure applications regardless of the platform they are built on. + +*Table of Contents*: +[[toc]] + +## Example + +Let's say we have a workspace management functionality, that allows users to create and manage workspaces. + +In this example, we have: + +1. An entity named workspace. The entity (aka **resource**) *"my-workspace"* represents a specific workspace in an application. +2. The available actions on the workspace are reading and writing, represented by the **scopes** *"workspace:read"* and *"workspace:write"*. +3. The **permissions** associated with these scopes are *"Read Workspace"* and *"Delete Workspace"*. + +The Authorization Server evaluates these permissions to determine whether a user has access to a workspace based on their role and the requested action. + +```mermaid +graph LR + A -- has --> C[Scope: workspace:write] + D[Permission: Read Workspace] -- assigned to --> B + E[Permission: Delete Workspace] -- assigned to --> C + F[Policy: Require Reader Role] -- applies to --> D + G[Policy: Require Admin Role] -- applies to --> E + G[Policy: Require Admin Role] -- applies to --> D + A[Resource: my-workspace] -- has --> B[Scope: workspace:read] +``` + +In Keycloak, you can define **resources**, which are the entities that you want to protect. For instance, in the given example, we have a resource named *"my-workspace"* with a type of *"urn:workspaces"*. This resource could represent a workspace in an application that users can access and manipulate. + +Keycloak allows you to define **scopes**, which are the actions that can be performed on a resource. In our example, we have two scopes defined: *"workspace:read"* and *"workspace:write"*. These scopes represent the ability to read and write to the workspace. + +Keycloak also allows you to define **permissions**, which are the rules that determine who can perform which actions on a resource. In our example, we have two permissions: *"Delete Workspace"* and *"Read Workspace"*. These permissions are linked to specific scopes and are based on policies. + +Policies in Keycloak are the conditions that a user must meet to be granted a permission. They can be based on various attributes, including the role of the user, time, and location. + +In our example, we have two policies: *"Require Admin Role"* and *"Require Reader Role"*. These policies are based on realm roles, which are roles that apply to the entire Keycloak realm. + +To delete a workspace, a user must have the Admin role, as defined by the "Require Admin Role" policy. To read a workspace, a user can have either the Admin role or the Reader role, as defined by the "Require Reader Role" policy. + +The *Keycloak Authorization Server* evaluates these policies whenever a user attempts to access a resource. If the user meets the conditions of the policy, the server grants the permission and the user can perform the action on the resource. This allows for powerful, fine-grained access control that can be easily managed and updated as your application evolves. + +## Configure Keycloak + +> [!NOTE] +> In this section, I'm not going to show you how to setup full example, but rather provide some example, for the full configuration I suggest you looking at the source code. It contains import files that Keycloak allows you to use manually or via CLI. +> +> * [tests/Keycloak.AuthServices.IntegrationTests/KeycloakConfiguration](https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/tree/main/tests/Keycloak.AuthServices.IntegrationTests/KeycloakConfiguration) +> * [tests/Keycloak.AuthServices.IntegrationTests/docker-compose.yml](https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/tree/main/tests/Keycloak.AuthServices.IntegrationTests/docker-compose.yml) +> * + +💡 Here an example of how to create a permission for scopes: + +![permission](/images/read-workspace-permission.png) + +💡 Here is an example of how to create a resource and associate a scopes with it: + +![permission](/images/my-workspace-resource.png) + +💡 Keycloak provides a UI to evaluate permissions for a given resource, user, scopes, etc. This feature enables you to prototype and troubleshoot more easily. Here is an example of how to evaluate permissions for an admin user: + +![permission](/images/evaluate-permissions-for-admin.png) + +## Add to your code + +Here is how to use to use protected resource authorization. + +<<< @/../tests/Keycloak.AuthServices.IntegrationTests/AuthorizationServerPolicyTests.cs#RequireProtectedResource_Scopes_Verified + +Here are the assertions from the integration test for this scenario: + +<<< @/../tests/Keycloak.AuthServices.IntegrationTests/AuthorizationServerPolicyTests.cs#RequireProtectedResource_Scopes_Verified_Assertion + +Source code of integration test: [tests/Keycloak.AuthServices.IntegrationTests/AuthorizationServerPolicyTests.cs](https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/blob/main/tests/Keycloak.AuthServices.IntegrationTests/AuthorizationServerPolicyTests.cs) + +## Validate Multiple Scopes + +You can specify multiple scopes to validate against and control comparison by using `ScopesValidationMode`. + +Here is an example for `ScopesValidationMode.AllOf`: + +<<< @/../tests/Keycloak.AuthServices.IntegrationTests/AuthorizationServerPolicyTests.cs#RequireProtectedResource_MultipleScopesAllOf_Verified + +Here is an example for `ScopesValidationMode.AnyOf`: + +<<< @/../tests/Keycloak.AuthServices.IntegrationTests/AuthorizationServerPolicyTests.cs#RequireProtectedResource_MultipleScopesAnyOf_Verified diff --git a/docs/configuration/configuration-authentication.md b/docs/configuration/configuration-authentication.md index 008f55d0..b10ff363 100644 --- a/docs/configuration/configuration-authentication.md +++ b/docs/configuration/configuration-authentication.md @@ -4,6 +4,7 @@ --- +*Table of Contents*: [[toc]] ## Web API @@ -108,7 +109,7 @@ Inline declaration with `JwtBearerOptions` overrides: <<< @/../tests/Keycloak.AuthServices.IntegrationTests/ConfigurationTests/AddKeycloakWebApiTests.cs#AddKeycloakWebApiAuthentication_FromInline2 -## Web App +## 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. diff --git a/docs/configuration/configuration-authorization.md b/docs/configuration/configuration-authorization.md index b40cdc76..0dc24375 100644 --- a/docs/configuration/configuration-authorization.md +++ b/docs/configuration/configuration-authorization.md @@ -4,6 +4,7 @@ With Keycloak, you can configure roles by defining realm roles and resource roles. Realm roles are global roles that apply to the entire realm, while resource roles are specific to a particular client or resource. +*Table of Contents*: [[toc]] ## Require Realm Roles diff --git a/docs/images/authz-arch-overview.png b/docs/images/authz-arch-overview.png new file mode 100644 index 00000000..e47c0edf --- /dev/null +++ b/docs/images/authz-arch-overview.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d37bb9a1dbc8db9de23ada096c6490e47d54616fa7ede2a6a4d79e07697c6dff +size 24827 diff --git a/docs/images/evaluate-permissions-for-admin.png b/docs/images/evaluate-permissions-for-admin.png new file mode 100644 index 00000000..04ec1c1e --- /dev/null +++ b/docs/images/evaluate-permissions-for-admin.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0b12620c3e455ec7da9e3a8c2ca459118f1fe16b270d2836d9771b1011816e8 +size 98983 diff --git a/docs/images/my-workspace-resource.png b/docs/images/my-workspace-resource.png new file mode 100644 index 00000000..83f7e232 --- /dev/null +++ b/docs/images/my-workspace-resource.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25de9b75cc80bced920b920a3806422bb4def0022fa3562407041505595d1c78 +size 127989 diff --git a/docs/images/read-workspace-permission.png b/docs/images/read-workspace-permission.png new file mode 100644 index 00000000..8c85e9cd --- /dev/null +++ b/docs/images/read-workspace-permission.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5b08062643eec61136f4acf755d3e713a467ce8bf5c7f8b324e7b37251c8cea +size 104000 diff --git a/docs/index.md b/docs/index.md index e5cae272..d2fc1b73 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,7 +14,7 @@ hero: link: /configuration/configuration-authentication - theme: alt text: Authorization - link: /configuration/configuration-authorization + link: /authorization/authorization-server - theme: alt text: HTTP REST Admin API link: /admin-rest-api diff --git a/docs/public/logo.png b/docs/public/logo.png new file mode 100644 index 00000000..bd7381fe --- /dev/null +++ b/docs/public/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71fd6b87a460eabc22759b80991678f86dcf9ed2abef0f198015bd8f7443e70e +size 5796 diff --git a/src/Keycloak.AuthServices.Authorization/AuthorizationServer/IKeycloakProtectionClient.cs b/src/Keycloak.AuthServices.Authorization/AuthorizationServer/IKeycloakProtectionClient.cs index d6b42f8b..1c304d6e 100644 --- a/src/Keycloak.AuthServices.Authorization/AuthorizationServer/IKeycloakProtectionClient.cs +++ b/src/Keycloak.AuthServices.Authorization/AuthorizationServer/IKeycloakProtectionClient.cs @@ -15,6 +15,22 @@ public interface IKeycloakProtectionClient Task VerifyAccessToResource( string resource, string scope, - CancellationToken cancellationToken + CancellationToken cancellationToken = default + ) => + this.VerifyAccessToResource(resource, scope, ScopesValidationMode.AllOf, cancellationToken); + + /// + /// Verifies access to the protected resource. Sends decision request to token endpoint {resource}#{scope} + /// + /// + /// + /// + /// + /// + Task VerifyAccessToResource( + string resource, + string scope, + ScopesValidationMode? scopesValidationMode = default, + CancellationToken cancellationToken = default ); } diff --git a/src/Keycloak.AuthServices.Authorization/AuthorizationServer/KeycloakProtectionClient.cs b/src/Keycloak.AuthServices.Authorization/AuthorizationServer/KeycloakProtectionClient.cs index 44eba427..b4601400 100644 --- a/src/Keycloak.AuthServices.Authorization/AuthorizationServer/KeycloakProtectionClient.cs +++ b/src/Keycloak.AuthServices.Authorization/AuthorizationServer/KeycloakProtectionClient.cs @@ -1,54 +1,174 @@ namespace Keycloak.AuthServices.Authorization.AuthorizationServer; +using System.Net.Http.Json; +using System.Text.Json.Serialization; using Keycloak.AuthServices.Common; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; /// public class KeycloakProtectionClient : IKeycloakProtectionClient { private readonly HttpClient httpClient; - private readonly IOptions clientOptions; + private readonly IOptions options; + private readonly ILogger logger; /// /// Constructs KeycloakProtectionClient /// /// /// + /// /// public KeycloakProtectionClient( HttpClient httpClient, - IOptions clientOptions + IOptions clientOptions, + ILogger logger ) { this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); - this.clientOptions = clientOptions; + this.options = clientOptions; + this.logger = logger; } /// public async Task VerifyAccessToResource( string resource, string scope, - CancellationToken cancellationToken + ScopesValidationMode? scopesValidationMode = default, + CancellationToken cancellationToken = default ) { - var audience = this.clientOptions.Value.Resource; + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(scope); + + var data = this.PrepareData(resource, scope); + + var response = await this.httpClient.PostAsync( + KeycloakConstants.TokenEndpointPath, + new FormUrlEncodedContent(data), + cancellationToken + ); + return await this.HandleResponse( + response, + resource, + scope, + scopesValidationMode, + cancellationToken + ); + } + + private Dictionary PrepareData(string resource, string scope) + { var permission = string.IsNullOrWhiteSpace(scope) ? resource : $"{resource}#{scope}"; + var audience = this.options.Value.Resource; - var data = new Dictionary + return new Dictionary { { "grant_type", "urn:ietf:params:oauth:grant-type:uma-ticket" }, - { "response_mode", "decision" }, + { "response_mode", scope.Contains(',') ? "permissions" : "decision" }, { "audience", audience ?? string.Empty }, { "permission", permission } }; + } - var response = await this.httpClient.PostAsync( - KeycloakConstants.TokenEndpointPath, - new FormUrlEncodedContent(data), - cancellationToken + private async Task HandleResponse( + HttpResponseMessage response, + string resource, + string scope, + ScopesValidationMode? scopesValidationMode, + CancellationToken cancellationToken + ) + { + if (!response.IsSuccessStatusCode) + { + return await this.HandleErrorResponse(response, cancellationToken); + } + + var scopes = scope.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList(); + + if (scopes is { Count: <= 1 }) + { + return true; + } + + var scopeResponse = await response.Content.ReadFromJsonAsync( + cancellationToken: cancellationToken ); - return response.IsSuccessStatusCode; + return this.ValidateScopes(scopeResponse, resource, scopes, scopesValidationMode); + } + + private async Task HandleErrorResponse( + HttpResponseMessage response, + CancellationToken cancellationToken + ) + { + var error = await response.Content.ReadFromJsonAsync( + cancellationToken: cancellationToken + ); + + if ( + !string.IsNullOrWhiteSpace(error?.Error) + && error.Error != ErrorResponse.AccessDeniedError + ) + { +#pragma warning disable CA1848 // Use the LoggerMessage delegates + this.logger.LogWarning( + "Issues invoking {Method} - {Errors}", + nameof(VerifyAccessToResource), + error + ); +#pragma warning restore CA1848 // Use the LoggerMessage delegates + } + + return false; + } + + private bool ValidateScopes( + ScopeResponse[]? scopeResponse, + string resource, + List scopes, + ScopesValidationMode? scopesValidationMode + ) + { + var resourceToValidate = + Array.Find( + scopeResponse ?? Array.Empty(), + r => string.Equals(r.Rsname, resource, StringComparison.Ordinal) + ) ?? throw new KeycloakException($"Unable to find a resource - {resource}"); + + scopesValidationMode ??= this.options.Value.ScopesValidationMode; + + if (scopesValidationMode == ScopesValidationMode.AllOf) + { + var resourceScopes = resourceToValidate.Scopes; + var allScopesPresent = scopes.TrueForAll(s => resourceScopes.Contains(s)); + + return allScopesPresent; + } + else if (scopesValidationMode == ScopesValidationMode.AnyOf) + { + var resourceScopes = resourceToValidate.Scopes; + var anyScopePresent = scopes.Exists(s => resourceScopes.Contains(s)); + + return anyScopePresent; + } + + return true; + } + + private sealed record ScopeResponse(string Rsid, string Rsname, List Scopes); + + private sealed record ErrorResponse + { + public const string AccessDeniedError = "access_denied"; + + [JsonPropertyName("error")] + public string Error { get; init; } = default!; + + [JsonPropertyName("error_description")] + public string ErrorDescription { get; init; } = default!; } } diff --git a/src/Keycloak.AuthServices.Authorization/AuthorizationServer/KeycloakProtectionClientOptions.cs b/src/Keycloak.AuthServices.Authorization/AuthorizationServer/KeycloakProtectionClientOptions.cs index 6c8c9abc..57748f76 100644 --- a/src/Keycloak.AuthServices.Authorization/AuthorizationServer/KeycloakProtectionClientOptions.cs +++ b/src/Keycloak.AuthServices.Authorization/AuthorizationServer/KeycloakProtectionClientOptions.cs @@ -24,4 +24,25 @@ public sealed class KeycloakProtectionClientOptions : KeycloakInstallationOption /// When set to true, the protected resource policy provider will be used to dynamically register policies based on their names. /// public bool UseProtectedResourcePolicyProvider { get; set; } + + /// + /// Represents the mode for validating scopes. + /// + public ScopesValidationMode ScopesValidationMode { get; set; } = ScopesValidationMode.AllOf; +} + +/// +/// Specifies the validation mode for multiple scopes. +/// +public enum ScopesValidationMode +{ + /// + /// Specifies that all of the scopes must be valid. + /// + AllOf, + + /// + /// Specifies that at least one of the scopes must be valid. + /// + AnyOf, } diff --git a/src/Keycloak.AuthServices.Authorization/KeycloakAuthorizationOptions.cs b/src/Keycloak.AuthServices.Authorization/KeycloakAuthorizationOptions.cs index 011fb0f2..2a464f20 100644 --- a/src/Keycloak.AuthServices.Authorization/KeycloakAuthorizationOptions.cs +++ b/src/Keycloak.AuthServices.Authorization/KeycloakAuthorizationOptions.cs @@ -18,7 +18,6 @@ public class KeycloakAuthorizationOptions : KeycloakInstallationOptions public RolesClaimTransformationSource EnableRolesMapping { get; set; } = RolesClaimTransformationSource.None; - /// /// The name of the resource to be used for Roles mapping /// @@ -30,8 +29,6 @@ public class KeycloakAuthorizationOptions : KeycloakInstallationOptions public string RoleClaimType { get; set; } = KeycloakConstants.RoleClaimType; } - - /// /// RolesClaimTransformationSource /// diff --git a/src/Keycloak.AuthServices.Authorization/PoliciesBuilderExtensions.cs b/src/Keycloak.AuthServices.Authorization/PoliciesBuilderExtensions.cs index debcf188..e114a820 100644 --- a/src/Keycloak.AuthServices.Authorization/PoliciesBuilderExtensions.cs +++ b/src/Keycloak.AuthServices.Authorization/PoliciesBuilderExtensions.cs @@ -1,5 +1,6 @@ namespace Keycloak.AuthServices.Authorization; +using Keycloak.AuthServices.Authorization.AuthorizationServer; using Keycloak.AuthServices.Authorization.Requirements; using Keycloak.AuthServices.Common; using Microsoft.AspNetCore.Authorization; @@ -64,4 +65,25 @@ public static AuthorizationPolicyBuilder RequireProtectedResource( string resource, string scope ) => builder.AddRequirements(new DecisionRequirement(resource, scope)); + + /// + /// Adds protected resource requirement to builder. Makes outgoing HTTP requests to Authorization Server. + /// + /// + /// + /// + /// + /// + public static AuthorizationPolicyBuilder RequireProtectedResource( + this AuthorizationPolicyBuilder builder, + string resource, + string[] scopes, + ScopesValidationMode? scopesValidationMode = default + ) => + builder.AddRequirements( + new DecisionRequirement(resource, scopes) + { + ScopesValidationMode = scopesValidationMode + } + ); } diff --git a/src/Keycloak.AuthServices.Authorization/Requirements/DecisionRequirement.cs b/src/Keycloak.AuthServices.Authorization/Requirements/DecisionRequirement.cs index 5d021de3..1a021eff 100644 --- a/src/Keycloak.AuthServices.Authorization/Requirements/DecisionRequirement.cs +++ b/src/Keycloak.AuthServices.Authorization/Requirements/DecisionRequirement.cs @@ -15,9 +15,14 @@ public class DecisionRequirement : IAuthorizationRequirement public string Resource { get; } /// - /// Resource scope + /// Resource scopes /// - public string Scope { get; } + public string[] Scopes { get; } + + /// + /// Validation Mode + /// + public ScopesValidationMode? ScopesValidationMode { get; set; } /// /// Constructs requirement @@ -27,7 +32,18 @@ public class DecisionRequirement : IAuthorizationRequirement public DecisionRequirement(string resource, string scope) { this.Resource = resource; - this.Scope = scope; + this.Scopes = new[] { scope }; + } + + /// + /// Constructs requirement + /// + /// + /// + public DecisionRequirement(string resource, string[] scopes) + { + this.Resource = resource; + this.Scopes = scopes; } /// @@ -41,7 +57,12 @@ public DecisionRequirement(string resource, string id, string scope) /// public override string ToString() => - $"{nameof(DecisionRequirement)}: {this.Resource}#{this.Scope}"; + $"{nameof(DecisionRequirement)}: {this.Resource}#{this.GetScopesExpression()}"; + + /// + /// + /// + public string GetScopesExpression() => string.Join(',', this.Scopes); } /// @@ -82,7 +103,8 @@ DecisionRequirement requirement { var success = await this.client.VerifyAccessToResource( requirement.Resource, - requirement.Scope, + requirement.GetScopesExpression(), + requirement.ScopesValidationMode, CancellationToken.None ); diff --git a/tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiTests.cs b/tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiTests.cs index c42d38e0..4d56ff30 100644 --- a/tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiTests.cs +++ b/tests/Keycloak.AuthServices.IntegrationTests/AddKeycloakWebApiTests.cs @@ -40,10 +40,7 @@ public async Task AddKeycloakWebApi_FromConfiguration_Ok() await host.Scenario(_ => { _.Get.Url(Endpoint1); - _.UserAndPasswordIs( - TestUsersRegistry.Tester.UserName, - TestUsersRegistry.Tester.Password - ); + _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); _.StatusCodeShouldBe(HttpStatusCode.OK); }); } @@ -75,10 +72,7 @@ public async Task AddKeycloakWebApi_FromConfigurationSection_Ok() await host.Scenario(_ => { _.Get.Url(Endpoint1); - _.UserAndPasswordIs( - TestUsersRegistry.Tester.UserName, - TestUsersRegistry.Tester.Password - ); + _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); _.StatusCodeShouldBe(HttpStatusCode.OK); }); } diff --git a/tests/Keycloak.AuthServices.IntegrationTests/AuthorizationServerPolicyTests.cs b/tests/Keycloak.AuthServices.IntegrationTests/AuthorizationServerPolicyTests.cs index e92e3024..776131c1 100644 --- a/tests/Keycloak.AuthServices.IntegrationTests/AuthorizationServerPolicyTests.cs +++ b/tests/Keycloak.AuthServices.IntegrationTests/AuthorizationServerPolicyTests.cs @@ -5,6 +5,7 @@ namespace Keycloak.AuthServices.IntegrationTests; using Alba.Security; using Keycloak.AuthServices.Authentication; using Keycloak.AuthServices.Authorization; +using Keycloak.AuthServices.Authorization.AuthorizationServer; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.DependencyInjection; using Xunit.Abstractions; @@ -51,7 +52,6 @@ public async Task RequireProtectedResource_DefaultResource_Verified() services .AddAuthorizationServer(context.Configuration) .AddStandardResilienceHandler(); // an example of how to extend IKeycloakProtectionClient by adding Polly - #endregion RequireProtectedResource_DefaultResource_Verified services.PostConfigure(options => @@ -66,17 +66,257 @@ public async Task RequireProtectedResource_DefaultResource_Verified() await host.Scenario(_ => { _.Get.Url(RunPolicyBuyName(policyName)); - _.UserAndPasswordIs(TestUsersRegistry.Admin.UserName, TestUsersRegistry.Admin.Password); + _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); + _.StatusCodeShouldBe(HttpStatusCode.OK); + }); + + await host.Scenario(_ => + { + _.Get.Url(RunPolicyBuyName(policyName)); + _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); + _.StatusCodeShouldBe(HttpStatusCode.Forbidden); + }); + } + + [Fact] + public async Task RequireProtectedResource_Scopes_Verified() + { + var policyName = "RequireProtectedResource"; + await using var host = await AlbaHost.For( + x => + { + x.WithLogging(testOutputHelper); + x.UseConfiguration(AppSettings); + + x.ConfigureServices( + (context, services) => + { + #region RequireProtectedResource_Scopes_Verified + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddKeycloakWebApi(context.Configuration); + + services + .AddAuthorization() + .AddKeycloakAuthorization() + .AddAuthorizationBuilder() + .AddPolicy( + policyName, + policy => + policy.RequireProtectedResource( + resource: "my-workspace", + scope: "workspace:delete" + ) + ); + + services.AddAuthorizationServer(context.Configuration); + + #endregion RequireProtectedResource_Scopes_Verified + + services.PostConfigure(options => + options.WithLocalKeycloakInstallation() + ); + } + ); + }, + UserPasswordFlow(ReadKeycloakAuthenticationOptions(AppSettings)) + ); + #region RequireProtectedResource_Scopes_Verified_Assertion + + await host.Scenario(_ => + { + _.Get.Url(RunPolicyBuyName(policyName)); + _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); + _.StatusCodeShouldBe(HttpStatusCode.OK); + }); + + await host.Scenario(_ => + { + _.Get.Url(RunPolicyBuyName(policyName)); + _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); + _.StatusCodeShouldBe(HttpStatusCode.Forbidden); + }); + #endregion RequireProtectedResource_Scopes_Verified_Assertion + } + + [Fact] + public async Task RequireProtectedResource_MultipleScopesAllOf_Verified() + { + var policyName = "RequireProtectedResource"; + await using var host = await AlbaHost.For( + x => + { + x.WithLogging(testOutputHelper); + x.UseConfiguration(AppSettings); + + x.ConfigureServices( + (context, services) => + { + #region RequireProtectedResource_MultipleScopesAllOf_Verified + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddKeycloakWebApi(context.Configuration); + + services + .AddAuthorization() + .AddKeycloakAuthorization() + .AddAuthorizationBuilder() + .AddPolicy( + policyName, + policy => + policy.RequireProtectedResource( + resource: "my-workspace", + scopes: ["workspace:delete", "workspace:read"], + scopesValidationMode: ScopesValidationMode.AllOf + ) + ); + + services.AddAuthorizationServer(context.Configuration); + + #endregion RequireProtectedResource_MultipleScopesAllOf_Verified + + services.PostConfigure(options => + options.WithLocalKeycloakInstallation() + ); + } + ); + }, + UserPasswordFlow(ReadKeycloakAuthenticationOptions(AppSettings)) + ); + await host.Scenario(_ => + { + _.Get.Url(RunPolicyBuyName(policyName)); + _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); + _.StatusCodeShouldBe(HttpStatusCode.OK); + }); + + await host.Scenario(_ => + { + _.Get.Url(RunPolicyBuyName(policyName)); + _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); + _.StatusCodeShouldBe(HttpStatusCode.Forbidden); + }); + } + + [Fact] + public async Task RequireProtectedResource_MultipleScopesAnyOf_Verified() + { + var policyName = "RequireProtectedResource"; + await using var host = await AlbaHost.For( + x => + { + x.WithLogging(testOutputHelper); + x.UseConfiguration(AppSettings); + + x.ConfigureServices( + (context, services) => + { + #region RequireProtectedResource_MultipleScopesAnyOf_Verified + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddKeycloakWebApi(context.Configuration); + + services + .AddAuthorization() + .AddKeycloakAuthorization() + .AddAuthorizationBuilder() + .AddPolicy( + policyName, + policy => + policy.RequireProtectedResource( + resource: "my-workspace", + scopes: ["workspace:delete", "workspace:read"], + scopesValidationMode: ScopesValidationMode.AnyOf + ) + ); + + services.AddAuthorizationServer(context.Configuration); + + #endregion RequireProtectedResource_MultipleScopesAnyOf_Verified + + services.PostConfigure(options => + options.WithLocalKeycloakInstallation() + ); + } + ); + }, + UserPasswordFlow(ReadKeycloakAuthenticationOptions(AppSettings)) + ); + await host.Scenario(_ => + { + _.Get.Url(RunPolicyBuyName(policyName)); + _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); _.StatusCodeShouldBe(HttpStatusCode.OK); }); await host.Scenario(_ => { _.Get.Url(RunPolicyBuyName(policyName)); - _.UserAndPasswordIs( - TestUsersRegistry.Tester.UserName, - TestUsersRegistry.Tester.Password - ); + _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); + _.StatusCodeShouldBe(HttpStatusCode.OK); + }); + } + + [Fact] + public async Task RequireProtectedResource_MultipleScopesMissingScope_Verified() + { + var policyName = "RequireProtectedResource"; + await using var host = await AlbaHost.For( + x => + { + x.WithLogging(testOutputHelper); + x.UseConfiguration(AppSettings); + + x.ConfigureServices( + (context, services) => + { + #region RequireProtectedResource_MultipleScopesMissingScope_Verified + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddKeycloakWebApi(context.Configuration); + + services + .AddAuthorization() + .AddKeycloakAuthorization() + .AddAuthorizationBuilder() + .AddPolicy( + policyName, + policy => + policy.RequireProtectedResource( + resource: "my-workspace", + scopes: + [ + "workspace:read", + "workspace:delete", + "workspace:unknown" + ], + scopesValidationMode: ScopesValidationMode.AllOf + ) + ); + + services.AddAuthorizationServer(context.Configuration); + + #endregion RequireProtectedResource_MultipleScopesMissingScope_Verified + + services.PostConfigure(options => + options.WithLocalKeycloakInstallation() + ); + } + ); + }, + UserPasswordFlow(ReadKeycloakAuthenticationOptions(AppSettings)) + ); + await host.Scenario(_ => + { + _.Get.Url(RunPolicyBuyName(policyName)); + _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); + _.StatusCodeShouldBe(HttpStatusCode.Forbidden); + }); + + await host.Scenario(_ => + { + _.Get.Url(RunPolicyBuyName(policyName)); + _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); _.StatusCodeShouldBe(HttpStatusCode.Forbidden); }); } diff --git a/tests/Keycloak.AuthServices.IntegrationTests/KeycloakConfiguration/Test-realm.json b/tests/Keycloak.AuthServices.IntegrationTests/KeycloakConfiguration/Test-realm.json index 794c292b..d518acc6 100644 --- a/tests/Keycloak.AuthServices.IntegrationTests/KeycloakConfiguration/Test-realm.json +++ b/tests/Keycloak.AuthServices.IntegrationTests/KeycloakConfiguration/Test-realm.json @@ -712,6 +712,20 @@ "_id" : "605c8302-4d45-4d60-aff1-9d1727ddde5f", "uris" : [ "/*" ], "icon_uri" : "" + }, { + "name" : "my-workspace", + "type" : "urn:workspaces", + "ownerManagedAccess" : false, + "displayName" : "my-workspace", + "attributes" : { }, + "_id" : "9e5ec5de-30be-44b6-a55a-d572840d2f91", + "uris" : [ ], + "scopes" : [ { + "name" : "workspace:delete" + }, { + "name" : "workspace:read" + } ], + "icon_uri" : "" } ], "policies" : [ { "id" : "04034aae-7d80-4ad4-902f-c8e57211b4c7", @@ -723,6 +737,16 @@ "config" : { "roles" : "[{\"id\":\"Admin\",\"required\":true}]" } + }, { + "id" : "b7e23d24-a1f0-4139-b866-6ea01d3bf9d4", + "name" : "Require Reader Role", + "description" : "", + "type" : "role", + "logic" : "POSITIVE", + "decisionStrategy" : "UNANIMOUS", + "config" : { + "roles" : "[{\"id\":\"Reader\",\"required\":true}]" + } }, { "id" : "90c2fe20-481c-4d32-b5e7-04cca9f8d9a5", "name" : "Default Permission", @@ -734,8 +758,40 @@ "defaultResourceType" : "urn:test-client:resources:default", "applyPolicies" : "[\"Require Admin Role\"]" } + }, { + "id" : "d009e33d-16f2-41a5-a235-dca56c010226", + "name" : "Read Workspace", + "description" : "", + "type" : "scope", + "logic" : "POSITIVE", + "decisionStrategy" : "AFFIRMATIVE", + "config" : { + "defaultResourceType" : "urn:workspaces", + "applyPolicies" : "[\"Require Reader Role\",\"Require Admin Role\"]", + "scopes" : "[\"workspace:read\"]" + } + }, { + "id" : "740a28fd-117d-4572-8fc2-2e65a61860d3", + "name" : "Delete Workspace", + "description" : "", + "type" : "scope", + "logic" : "POSITIVE", + "decisionStrategy" : "UNANIMOUS", + "config" : { + "defaultResourceType" : "urn:workspaces", + "applyPolicies" : "[\"Require Admin Role\"]", + "scopes" : "[\"workspace:delete\"]" + } + } ], + "scopes" : [ { + "id" : "9bef5afa-f20c-4f24-8df9-f6cc634dece2", + "name" : "workspace:read", + "iconUri" : "" + }, { + "id" : "032c731c-c2db-4de4-89a3-b1a93bda51c7", + "name" : "workspace:delete", + "iconUri" : "" } ], - "scopes" : [ ], "decisionStrategy" : "UNANIMOUS" } } ], @@ -1285,7 +1341,7 @@ "subType" : "anonymous", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "oidc-sha256-pairwise-sub-mapper", "saml-user-attribute-mapper", "oidc-address-mapper", "oidc-usermodel-property-mapper", "oidc-usermodel-attribute-mapper", "saml-user-property-mapper", "saml-role-list-mapper", "oidc-full-name-mapper" ] + "allowed-protocol-mapper-types" : [ "oidc-sha256-pairwise-sub-mapper", "saml-role-list-mapper", "oidc-full-name-mapper", "saml-user-attribute-mapper", "oidc-usermodel-property-mapper", "oidc-usermodel-attribute-mapper", "saml-user-property-mapper", "oidc-address-mapper" ] } }, { "id" : "df4acae2-494f-4c6b-8c75-1a64296e47db", @@ -1304,7 +1360,7 @@ "subType" : "authenticated", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "oidc-usermodel-property-mapper", "saml-user-property-mapper", "oidc-address-mapper", "oidc-usermodel-attribute-mapper", "oidc-full-name-mapper", "saml-role-list-mapper", "saml-user-attribute-mapper", "oidc-sha256-pairwise-sub-mapper" ] + "allowed-protocol-mapper-types" : [ "oidc-full-name-mapper", "saml-user-attribute-mapper", "oidc-address-mapper", "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper", "saml-role-list-mapper", "saml-user-property-mapper", "oidc-sha256-pairwise-sub-mapper" ] } }, { "id" : "79c2f0c9-65fd-4c62-83a3-0ede96795205", diff --git a/tests/Keycloak.AuthServices.IntegrationTests/Playground.cs b/tests/Keycloak.AuthServices.IntegrationTests/Playground.cs new file mode 100644 index 00000000..50e3b16d --- /dev/null +++ b/tests/Keycloak.AuthServices.IntegrationTests/Playground.cs @@ -0,0 +1,70 @@ +namespace Keycloak.AuthServices.IntegrationTests; + +using System.Net; +using Alba; +using Alba.Security; +using Keycloak.AuthServices.Authentication; +using Keycloak.AuthServices.Authorization; +using Keycloak.AuthServices.Authorization.AuthorizationServer; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; +using static Keycloak.AuthServices.IntegrationTests.Utils; + +public class Playground(ITestOutputHelper testOutputHelper) +{ + private static readonly string AppSettings = "appsettings.json"; + + [Fact(Skip = "Playground Test")] + public async Task PlaygroundRequireProtectedResource_Scopes_Verified() + { + var policyName = "RequireProtectedResource"; + await using var host = await AlbaHost.For( + x => + { + x.WithLogging(testOutputHelper); + x.UseConfiguration(AppSettings); + + x.ConfigureServices( + (context, services) => + { + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddKeycloakWebApi(context.Configuration); + + services + .AddAuthorization() + .AddKeycloakAuthorization() + .AddAuthorizationBuilder() + .AddPolicy( + policyName, + policy => + policy.RequireProtectedResource( + resource: "my-workspace", + scopes: ["workspace:read", "workspace:delete"], + scopesValidationMode: ScopesValidationMode.AllOf + ) + ); + + services.AddAuthorizationServer(context.Configuration); + + services.PostConfigure(options => + options.WithLocalKeycloakInstallation() + ); + } + ); + }, + UserPasswordFlow(ReadKeycloakAuthenticationOptions(AppSettings)) + ); + + await host.Scenario(_ => + { + _.Get.Url(RunPolicyBuyName(policyName)); + _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); + _.StatusCodeShouldBe(HttpStatusCode.OK); + }); + } + + private static string RunPolicyBuyName(string policyName) => + $"/endpoints/RunPolicyBuyName?policy={policyName}"; +} diff --git a/tests/Keycloak.AuthServices.IntegrationTests/PolicyTests.cs b/tests/Keycloak.AuthServices.IntegrationTests/PolicyTests.cs index c10affc0..9c19c7d9 100644 --- a/tests/Keycloak.AuthServices.IntegrationTests/PolicyTests.cs +++ b/tests/Keycloak.AuthServices.IntegrationTests/PolicyTests.cs @@ -57,7 +57,7 @@ public async Task RequireRealmRoles_AdminRole_Verified() await host.Scenario(_ => { _.Get.Url(RunPolicyBuyName(policyName)); - _.UserAndPasswordIs(TestUsersRegistry.Admin.UserName, TestUsersRegistry.Admin.Password); + _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); _.StatusCodeShouldBe(HttpStatusCode.OK); }); @@ -65,8 +65,8 @@ await host.Scenario(_ => { _.Get.Url(RunPolicyBuyName(policyName)); _.UserAndPasswordIs( - TestUsersRegistry.Tester.UserName, - TestUsersRegistry.Tester.Password + TestUsers.Tester.UserName, + TestUsers.Tester.Password ); _.StatusCodeShouldBe(HttpStatusCode.Forbidden); }); @@ -117,7 +117,7 @@ public async Task RequireClientRoles_TestClientRole_Verified() await host.Scenario(_ => { _.Get.Url(RunPolicyBuyName(policyName)); - _.UserAndPasswordIs(TestUsersRegistry.Admin.UserName, TestUsersRegistry.Admin.Password); + _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); _.StatusCodeShouldBe(HttpStatusCode.Forbidden); }); @@ -125,8 +125,8 @@ await host.Scenario(_ => { _.Get.Url(RunPolicyBuyName(policyName)); _.UserAndPasswordIs( - TestUsersRegistry.Tester.UserName, - TestUsersRegistry.Tester.Password + TestUsers.Tester.UserName, + TestUsers.Tester.Password ); _.StatusCodeShouldBe(HttpStatusCode.OK); }); @@ -175,7 +175,7 @@ public async Task RequireClientRoles_TestClientRoleWithConfiguration_Verified() await host.Scenario(_ => { _.Get.Url(RunPolicyBuyName(policyName)); - _.UserAndPasswordIs(TestUsersRegistry.Admin.UserName, TestUsersRegistry.Admin.Password); + _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); _.StatusCodeShouldBe(HttpStatusCode.Forbidden); }); @@ -183,8 +183,8 @@ await host.Scenario(_ => { _.Get.Url(RunPolicyBuyName(policyName)); _.UserAndPasswordIs( - TestUsersRegistry.Tester.UserName, - TestUsersRegistry.Tester.Password + TestUsers.Tester.UserName, + TestUsers.Tester.Password ); _.StatusCodeShouldBe(HttpStatusCode.OK); }); @@ -236,7 +236,7 @@ public async Task RequireRealmRoles_AdminRoleWithMapping_Verified() await host.Scenario(_ => { _.Get.Url(RunPolicyBuyName(policyName)); - _.UserAndPasswordIs(TestUsersRegistry.Admin.UserName, TestUsersRegistry.Admin.Password); + _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); _.StatusCodeShouldBe(HttpStatusCode.OK); }); @@ -244,8 +244,8 @@ await host.Scenario(_ => { _.Get.Url(RunPolicyBuyName(policyName)); _.UserAndPasswordIs( - TestUsersRegistry.Tester.UserName, - TestUsersRegistry.Tester.Password + TestUsers.Tester.UserName, + TestUsers.Tester.Password ); _.StatusCodeShouldBe(HttpStatusCode.Forbidden); }); @@ -297,7 +297,7 @@ public async Task RequireClientRoles_TestClientRoleWithMapping_Verified() await host.Scenario(_ => { _.Get.Url(RunPolicyBuyName(policyName)); - _.UserAndPasswordIs(TestUsersRegistry.Admin.UserName, TestUsersRegistry.Admin.Password); + _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); _.StatusCodeShouldBe(HttpStatusCode.Forbidden); }); @@ -305,8 +305,8 @@ await host.Scenario(_ => { _.Get.Url(RunPolicyBuyName(policyName)); _.UserAndPasswordIs( - TestUsersRegistry.Tester.UserName, - TestUsersRegistry.Tester.Password + TestUsers.Tester.UserName, + TestUsers.Tester.Password ); _.StatusCodeShouldBe(HttpStatusCode.OK); }); diff --git a/tests/Keycloak.AuthServices.IntegrationTests/TestUsersRegistry.cs b/tests/Keycloak.AuthServices.IntegrationTests/TestUsers.cs similarity index 78% rename from tests/Keycloak.AuthServices.IntegrationTests/TestUsersRegistry.cs rename to tests/Keycloak.AuthServices.IntegrationTests/TestUsers.cs index c163f5e1..23990bdb 100644 --- a/tests/Keycloak.AuthServices.IntegrationTests/TestUsersRegistry.cs +++ b/tests/Keycloak.AuthServices.IntegrationTests/TestUsers.cs @@ -1,7 +1,9 @@ namespace Keycloak.AuthServices.IntegrationTests; -public static class TestUsersRegistry +public static class TestUsers { +#pragma warning disable CA2211 // Non-constant fields should not be visible +#pragma warning disable IDE1006 // Naming Styles public static TestUser Admin = new( UserName: "testadminuser", @@ -22,6 +24,8 @@ public static class TestUsersRegistry ["test-client"] = [KeycloakRoles.TestClientRole] } ); +#pragma warning restore IDE1006 // Naming Styles +#pragma warning restore CA2211 // Non-constant fields should not be visible } public static class KeycloakRoles diff --git a/tests/Keycloak.AuthServices.IntegrationTests/Utils.cs b/tests/Keycloak.AuthServices.IntegrationTests/Utils.cs index b0b35aea..b9c67544 100644 --- a/tests/Keycloak.AuthServices.IntegrationTests/Utils.cs +++ b/tests/Keycloak.AuthServices.IntegrationTests/Utils.cs @@ -52,6 +52,12 @@ KeycloakContainer keycloakContainer options.RequireHttpsMetadata = false; } + public static void WithLocalKeycloakInstallation(this JwtBearerOptions options) + { + options.Authority = $"localhost:8080/realms/Test"; + options.RequireHttpsMetadata = false; + } + public static KeycloakAuthenticationOptions ReadKeycloakAuthenticationOptions(string fileName) { var configuration = new ConfigurationBuilder() @@ -74,7 +80,7 @@ KeycloakAuthenticationOptions keycloakAuthenticationOptions { ClientId = keycloakAuthenticationOptions.Resource, ClientSecret = keycloakAuthenticationOptions.Credentials.Secret, - UserName = TestUsersRegistry.Tester.UserName, - Password = TestUsersRegistry.Tester.Password, + UserName = TestUsers.Tester.UserName, + Password = TestUsers.Tester.Password, }; }