diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2db25834..65879446 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 735b41b6..3eca5c3e 100644 --- a/KeycloakAuthorizationServicesDotNet.sln +++ b/KeycloakAuthorizationServicesDotNet.sln @@ -74,6 +74,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestWebApiWithControllers", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ResourceAuthorization", "samples\ResourceAuthorization\ResourceAuthorization.csproj", "{B060EE8C-C76D-48A4-B209-4646070A7E0D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Keycloak.AuthServices.OpenTelemetry", "src\Keycloak.AuthServices.OpenTelemetry\Keycloak.AuthServices.OpenTelemetry.csproj", "{3FE98A91-BA4E-4D4F-A6A5-A43123644ACD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -160,6 +162,10 @@ Global {B060EE8C-C76D-48A4-B209-4646070A7E0D}.Debug|Any CPU.Build.0 = Debug|Any CPU {B060EE8C-C76D-48A4-B209-4646070A7E0D}.Release|Any CPU.ActiveCfg = Release|Any CPU {B060EE8C-C76D-48A4-B209-4646070A7E0D}.Release|Any CPU.Build.0 = Release|Any CPU + {3FE98A91-BA4E-4D4F-A6A5-A43123644ACD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3FE98A91-BA4E-4D4F-A6A5-A43123644ACD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3FE98A91-BA4E-4D4F-A6A5-A43123644ACD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3FE98A91-BA4E-4D4F-A6A5-A43123644ACD}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -186,6 +192,7 @@ Global {8C43A1C1-0069-4B21-ADDE-5268EB214820} = {F9D5C5B8-9933-4AE0-ADAC-6B8C15F7552A} {BF2DCACD-E7C4-4B92-909F-CC535B70F94D} = {96857509-627A-4FD2-AC82-34387619A7B1} {B060EE8C-C76D-48A4-B209-4646070A7E0D} = {AEBE10B1-96B1-4060-B8C1-1F9BFA7A586C} + {3FE98A91-BA4E-4D4F-A6A5-A43123644ACD} = {F9D5C5B8-9933-4AE0-ADAC-6B8C15F7552A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E1907BFD-C144-4B48-AA40-972F499D4E08} diff --git a/build.cake b/build.cake index 771a4d7d..6d4c8cc6 100644 --- a/build.cake +++ b/build.cake @@ -37,7 +37,7 @@ Task("Build") Task("Test") .Description("Runs unit tests and outputs test results to the artefacts directory.") - .DoesForEach(GetFiles("./tests/**/*.csproj"), project => + .DoesForEach(GetFiles("./tests/**/*.csproj").Where(file => !file.ToString().Contains("Integration")), project => { DotNetTest( project.ToString(), diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index cc86bbdf..e6ddf6dd 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -43,7 +43,7 @@ export default withMermaid({ }, { text: 'Authorization', - collapsed: false, + collapsed: true, items: [ { text: 'Authorization Server', link: '/authorization/authorization-server' @@ -104,9 +104,11 @@ export default withMermaid({ ] }, { - text: 'Q&A', + text: 'Maintenance👨‍🔬', items: [ - { text: 'Recipes', link: '/qa/recipes' }, + { text: 'Q&A', link: '/qa/recipes' }, + { text: 'Troubleshooting', link: '/qa/troubleshooting' }, + { text: 'OpenTelemetry🔭', link: '/opentelemetry' } ] }, { diff --git a/docs/authorization/resources-api.md b/docs/authorization/resources-api.md index 8535cc5b..4f9d7471 100644 --- a/docs/authorization/resources-api.md +++ b/docs/authorization/resources-api.md @@ -2,9 +2,11 @@ ASP.NET Core allows you to built policies based on [Microsoft.AspNetCore.Authorization.AuthorizationBuilder](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authorization.authorizationbuilder). `Keycloak.AuthServices.Authorization` adds [Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authorization.authorizationpolicybuilder) extension methods to work with protected resources and configure your polices. +::: details AuthorizationPolicyBuilderExtensions + <<< @/../src/Keycloak.AuthServices.Authorization/PoliciesBuilderExtensions.cs#RequireProtectedResource - +::: ## Add to your code diff --git a/docs/configuration/configuration-authentication.md b/docs/configuration/configuration-authentication.md index 3eeba008..27eb4b2f 100644 --- a/docs/configuration/configuration-authentication.md +++ b/docs/configuration/configuration-authentication.md @@ -50,6 +50,9 @@ Not everything you want to do can be configured with `KeycloakAuthenticationOpti <<< @/../tests/Keycloak.AuthServices.IntegrationTests/ConfigurationTests/AddKeycloakWebApiAuthenticationTests.cs#AddKeycloakWebApiAuthentication_FromConfigurationWithInlineOverrides +> [!NOTE] +> `KeycloakAuthenticationOptions` ("Keycloak") takes precedence over `Authentication:Schemes:{SchemeName}` ("Bearer" - `JwtBearerOptions`) in the case of default configuration + Here is a trick to bind options from configuration an override directly in the same code: <<< @/../tests/Keycloak.AuthServices.IntegrationTests/ConfigurationTests/AddKeycloakWebApiAuthenticationTests.cs#AddKeycloakWebApiAuthentication_FromConfigurationWithInlineOverrides2{3} @@ -87,9 +90,6 @@ Typically, ASP.NET Core expects to find these (default) options under the `Authe } ``` -> [!NOTE] -> `KeycloakAuthenticationOptions` ("Keycloak") takes precedence over `Authentication:Schemes:{SchemeName}` ("Bearer") in the case of default configuration - ### 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: diff --git a/docs/configuration/configuration-keycloak.md b/docs/configuration/configuration-keycloak.md index 70f4ff47..1c08598e 100644 --- a/docs/configuration/configuration-keycloak.md +++ b/docs/configuration/configuration-keycloak.md @@ -1,6 +1,6 @@ # Configure Keycloak -This section contains a general instruction of how to configure Keyclaok to be used for .NET applications. +This section contains a general instruction of how to configure Keycloak to be used for .NET applications. *Table of Contents*: [[toc]] diff --git a/docs/opentelemetry.md b/docs/opentelemetry.md new file mode 100644 index 00000000..80cd1373 --- /dev/null +++ b/docs/opentelemetry.md @@ -0,0 +1,64 @@ +# Keycloak.AuthServices.OpenTelemetry + +`Keycloak.AuthServices` can be instrumented via [OpenTelemetry](https://opentelemetry.io/docs/languages/net/getting-started/). + +You may ask, **"Why do I even need it?"** and you would be right. In most cases, logging is enough. However, since `Keycloak.AuthServices.Authorization` makes multiple outgoing requests to the Authorization Server, it was decided to add OpenTelemetry support to gain better insights into how the authorization process works. + +## Add to your code + +```bash +dotnet add package Keycloak.AuthServices.OpenTelemetry +``` + +Here is how to use it: + +```csharp +var builder = WebApplication.CreateBuilder(args); +var services = builder.Services; + +builder.Logging.AddOpenTelemetry(logging => +{ + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; +}); + +services + .AddOpenTelemetry() + .WithMetrics(metrics => + metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddKeycloakAuthServicesInstrumentation() // [!code highlight] + ) + .WithTracing(tracing => + tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddKeycloakAuthServicesInstrumentation() // [!code highlight] + ) + .UseOtlpExporter(); +``` + +## Metrics example + +```bash +dotnet counters monitor \ + --name ResourceAuthorization \ + --counters Keycloak.AuthServices.Authorization +``` + +Play around with an application and see the results: + +```text +Press p to pause, r to resume, q to quit. + Status: Running + +Name Current Value +[Keycloak.AuthServices.Authorization] + keycloak.authservices.requirements.fail (Count) + requirement=ParameterizedProtectedResourceRequirement 3 + keycloak.authservices.requirements.succeed (Count) + requirement=ParameterizedProtectedResourceRequirement 5 + requirement=RealmAccessRequirement 16 + +``` diff --git a/docs/qa/recipes.md b/docs/qa/recipes.md index 0c7d0eb0..024715b2 100644 --- a/docs/qa/recipes.md +++ b/docs/qa/recipes.md @@ -4,10 +4,54 @@ Welcome to the Recipes section! Here you will find a collection of instructions [[toc]] -## How to get an access token from Swagger UI? +## How to debug an application? + +Adjust logging level: + +```json +{ + "Logging": { + "Keycloak.AuthServices": "Debug", + "Keycloak.AuthServices.Authorization": "Trace" + } +} +``` + +> [!NOTE] +> ☝️`Keycloak.AuthServices` supports OpenTelemetry. See [Keycloak.AuthServices.OpenTelemetry](/opentelemetry). + +## How to get Options from DI? + +```csharp +var keycloakAuthenticationOptions = serviceProvider + .GetRequiredService>() + .Get(JwtBearerDefaults.AuthenticationScheme); + +var keycloakAuthenticationOptions = serviceProvider + .GetRequiredService>() + .CurrentValue; +``` + +> [!NOTE] +> To retrieve `KeycloakAuthenticationOptions` you need to use `IOptionsMonitor.Get(string name)` because this options are registered per Scheme. + +## How to get Options outside of `IServiceProvider`? + +Sometimes you need to resolve options before the DI container is built. E.g: application startup. + +```csharp +var keycloakOptions = configuration.GetKeycloakOptions()!; +// OR +KeycloakAuthorizationOptions options = new(); +configuration.BindKeycloakOptions(options); +``` + +## How to get an access token via Swagger UI? Here is an example of how to use [NSwag](https://github.com/RicoSuter/NSwag/wiki/AspNetCore-Middleware#add-oauth2-authentication-openapi-3): +::: details Code + ```csharp // Program.cs var builder = WebApplication.CreateBuilder(args); @@ -55,42 +99,39 @@ app.UseSwaggerUi(ui => app.Run(); ``` -## How to get Options from DI? +::: -```csharp -var keycloakAuthenticationOptions = serviceProvider - .GetRequiredService>() - .Get(JwtBearerDefaults.AuthenticationScheme); +## How to setup resiliency to HTTP Clients? -var keycloakAuthenticationOptions = serviceProvider - .GetRequiredService>() - .CurrentValue; -``` +Every HTTP Client provided by `Keycloak.AuthServices` expose `IHttpClientBuilder`. It a standard way to extend behavior of `HttpClient`. We can use it to our advantage! -> [!NOTE] -> To retrieve `KeycloakAuthenticationOptions` you need to use `IOptionsMonitor.Get(string name)` because this options are registered per Scheme. +Install [Microsoft.Extensions.Http.Resilience](https://www.nuget.org/packages/Microsoft.Extensions.Http.Resilience) -## How to get Options outside of `IServiceProvider`? +```bash +dotnet add package Microsoft.Extensions.Http.Resilience +``` -Sometimes you need to resolve options before the DI container is built. E.g: application startup. +Add resilience handler globally (for all `HttpClient`s including ones provided by `Keycloak.AuthServices`) + +Add globally: ```csharp -var keycloakOptions = configuration.GetKeycloakOptions()!; -// OR -KeycloakAuthorizationOptions options = new(); -configuration.BindKeycloakOptions(options); +// Program.cs +var builder = WebApplication.CreateBuilder(args); +var services = builder.Services + +builder.Services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler()); ``` -## How to debug an application? +Add per-client: -Adjust logging level: +```csharp +// Program.cs +var builder = WebApplication.CreateBuilder(args); +var services = builder.Services -```json -{ - "Logging": { - "Keycloak.AuthServices": "Debug", - "Keycloak.AuthServices.Authorization": "Trace" - } - } -} +services + .AddKeycloakAuthorization() + .AddAuthorizationServer(builder.Configuration) + .AddStandardResilienceHandler(); ``` diff --git a/docs/qa/troubleshooting.md b/docs/qa/troubleshooting.md new file mode 100644 index 00000000..38c4b163 --- /dev/null +++ b/docs/qa/troubleshooting.md @@ -0,0 +1,27 @@ +# Troubleshooting + +*Common issues:* + +[[toc]] + +## I receive 401 Unauthorized status code + +* [Turn on](/qa/recipes.html#how-to-debug-an-application) Debug or Trace logging level and see the logs output +* Make sure access token is provided in Authorization Header. +* Make sure the audience is mapped to a token via [audience mapper](/configuration/configuration-keycloak#add-audience-mapper). You can try to disable audience validation temporarily. +* Make sure the HTTPs requirement is turned off in Development Mode. `KeycloakAuthenticationOptions.SslRequired="none"` + +## I receive 403 Forbidden + +* [Turn on](/qa/recipes.html#how-to-debug-an-application) Debug or Trace logging level and see the logs output +* In case of RBAC Authorization make sure the `ClaimsPrincipal` has "realm_access" and "resource_access" claims mapped from token issued by Keycloak. +* If you use Keycloak as Authorization Server, make sure it is properly configured and that the Keycloak installation is accessible. + +## Keycloak is slow to respond + +Keycloak is a central part of the system used by many components. Especially, in Authorization Server scenario where authorization requests are sent to centralized place. Essentially, Keycloak becomes a bottleneck of the system. Consider [Cluster Setup](https://www.keycloak.org/2019/05/keycloak-cluster-setup) to tackle this problem. + +Also, you can handle transient HTTP errors by adding resiliency, see [How to setup resiliency to HTTP Clients](/qa/recipes.html#how-to-setup-resiliency-to-http-clients) + +> [!NOTE] +> ☝️`Keycloak.AuthServices` supports OpenTelemetry. See [Keycloak.AuthServices.OpenTelemetry](/opentelemetry). diff --git a/samples/Directory.Packages.props b/samples/Directory.Packages.props index 5c837a77..915a48aa 100644 --- a/samples/Directory.Packages.props +++ b/samples/Directory.Packages.props @@ -9,8 +9,13 @@ + + + + + diff --git a/samples/ResourceAuthorization/KeycloakConfiguration/Test-realm.json b/samples/ResourceAuthorization/KeycloakConfiguration/Test-realm.json index 004952ec..5e3fa22d 100644 --- a/samples/ResourceAuthorization/KeycloakConfiguration/Test-realm.json +++ b/samples/ResourceAuthorization/KeycloakConfiguration/Test-realm.json @@ -822,7 +822,7 @@ } }, { "id" : "64782bb2-79bd-4904-8299-5012f66c2c85", - "name" : "Admin Can Manage Workspaces", + "name" : "Can Manage Workspaces", "description" : "", "type" : "scope", "logic" : "POSITIVE", @@ -832,17 +832,6 @@ "applyPolicies" : "[\"Is Admin\"]", "scopes" : "[\"workspace:delete\",\"workspace:read\",\"workspace:add-user\",\"workspace:remove-user\",\"workspace:list-users\"]" } - }, { - "id" : "a97f05a6-2164-4310-924e-5bdde44692a3", - "name" : "Workspaces Global Access", - "description" : "", - "type" : "resource", - "logic" : "POSITIVE", - "decisionStrategy" : "AFFIRMATIVE", - "config" : { - "resources" : "[\"workspaces\"]", - "applyPolicies" : "[\"Is Admin\",\"Is Reader\"]" - } } ], "scopes" : [ { "id" : "5e5817b9-23ec-4422-aa1c-521aedee90e2", diff --git a/samples/ResourceAuthorization/KeycloakConfiguration/clients/test-client-auth-rules.json b/samples/ResourceAuthorization/KeycloakConfiguration/clients/test-client-auth-rules.json index 27abadf2..cb753c68 100644 --- a/samples/ResourceAuthorization/KeycloakConfiguration/clients/test-client-auth-rules.json +++ b/samples/ResourceAuthorization/KeycloakConfiguration/clients/test-client-auth-rules.json @@ -134,7 +134,7 @@ }, { "id": "64782bb2-79bd-4904-8299-5012f66c2c85", - "name": "Admin Can Manage Workspaces", + "name": "Can Manage Workspaces", "description": "", "type": "scope", "logic": "POSITIVE", @@ -144,18 +144,6 @@ "applyPolicies": "[\"Is Admin\"]", "scopes": "[\"workspace:delete\",\"workspace:read\",\"workspace:add-user\",\"workspace:remove-user\",\"workspace:list-users\"]" } - }, - { - "id": "a97f05a6-2164-4310-924e-5bdde44692a3", - "name": "Workspaces Global Access", - "description": "", - "type": "resource", - "logic": "POSITIVE", - "decisionStrategy": "AFFIRMATIVE", - "config": { - "resources": "[\"workspaces\"]", - "applyPolicies": "[\"Is Admin\",\"Is Reader\"]" - } } ], "scopes": [ diff --git a/samples/ResourceAuthorization/Program.cs b/samples/ResourceAuthorization/Program.cs index aa3e9994..01791d1f 100644 --- a/samples/ResourceAuthorization/Program.cs +++ b/samples/ResourceAuthorization/Program.cs @@ -4,6 +4,9 @@ using Keycloak.AuthServices.Sdk; using Keycloak.AuthServices.Sdk.Kiota; using Microsoft.AspNetCore.Authentication.JwtBearer; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; using ResourceAuthorization; using KeycloakAdminClientOptions = Keycloak.AuthServices.Sdk.Kiota.KeycloakAdminClientOptions; @@ -13,6 +16,30 @@ services.AddProblemDetails(); services.AddApplicationSwagger(); +builder.Logging.AddOpenTelemetry(logging => +{ + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; +}); + +builder.Services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler()); + +services + .AddOpenTelemetry() + .WithMetrics(metrics => + metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddKeycloakAuthServicesInstrumentation() + ) + .WithTracing(tracing => + tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddKeycloakAuthServicesInstrumentation() + ) + .UseOtlpExporter(); + services.AddControllers(options => options.AddProtectedResources()); services @@ -24,7 +51,9 @@ .AddAuthorizationBuilder() .AddDefaultPolicy("", policy => policy.RequireRealmRoles("Admin", "Reader")); -services.AddKeycloakAuthorization().AddAuthorizationServer(builder.Configuration); +services + .AddKeycloakAuthorization() + .AddAuthorizationServer(builder.Configuration); var adminSection = "KeycloakAdmin"; diff --git a/samples/ResourceAuthorization/ResourceAuthorization.csproj b/samples/ResourceAuthorization/ResourceAuthorization.csproj index 57ef5951..5f35bd8e 100644 --- a/samples/ResourceAuthorization/ResourceAuthorization.csproj +++ b/samples/ResourceAuthorization/ResourceAuthorization.csproj @@ -10,11 +10,17 @@ + + + + + + diff --git a/samples/ResourceAuthorization/appsettings.Development.json b/samples/ResourceAuthorization/appsettings.Development.json index 96d1c936..e30662fa 100644 --- a/samples/ResourceAuthorization/appsettings.Development.json +++ b/samples/ResourceAuthorization/appsettings.Development.json @@ -2,9 +2,12 @@ "Logging": { "LogLevel": { "Default": "Debug", - "Microsoft.AspNetCore": "Debug" + "Microsoft.AspNetCore": "Debug", + "Keycloak.AuthServices": "Trace" } }, + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317", + "OTEL_SERVICE_NAME": "ResourceAuthorization", "Keycloak": { "realm": "Test", "auth-server-url": "http://localhost:8080/", diff --git a/samples/ResourceAuthorization/docker-compose.yml b/samples/ResourceAuthorization/docker-compose.yml index de726f18..85a0c13c 100644 --- a/samples/ResourceAuthorization/docker-compose.yml +++ b/samples/ResourceAuthorization/docker-compose.yml @@ -17,3 +17,10 @@ services: - ./KeycloakConfiguration/:/opt/keycloak/data/import/ ports: - 8080:8080 + aspire-dashboard: + image: mcr.microsoft.com/dotnet/nightly/aspire-dashboard:8.0.0-preview.5 + environment: + - DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true + ports: + - "4317:18889" + - "18888:18888" diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 00000000..37cc5e97 --- /dev/null +++ b/src/.editorconfig @@ -0,0 +1,2 @@ +[*.{cs,csx,cake,vb,vbx}] +dotnet_diagnostic.CA1848.severity = warning diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 4ee3fa98..ec272476 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -1,33 +1,29 @@ - - - - + + + - - + - - - - + + + + + - - - - - - - - - - - + + + + + + + + - - + + - + \ No newline at end of file diff --git a/src/Keycloak.AuthServices.Authorization/AuthorizationServer/AccessTokenPropagationExtensions.cs b/src/Keycloak.AuthServices.Authorization/AuthorizationServer/AccessTokenPropagationExtensions.cs index 9355a8bb..ec2e956a 100644 --- a/src/Keycloak.AuthServices.Authorization/AuthorizationServer/AccessTokenPropagationExtensions.cs +++ b/src/Keycloak.AuthServices.Authorization/AuthorizationServer/AccessTokenPropagationExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; /// @@ -14,18 +15,15 @@ public static class AccessTokenPropagationExtensions /// /// /// - public static IHttpClientBuilder AddHeaderPropagation(this IHttpClientBuilder builder) - { + public static IHttpClientBuilder AddHeaderPropagation(this IHttpClientBuilder builder) => builder.AddHttpMessageHandler( (sp) => { var contextAccessor = sp.GetRequiredService(); var options = sp.GetRequiredService>(); + var logger = sp.GetRequiredService>(); - return new AccessTokenPropagationHandler(contextAccessor, options); + return new AccessTokenPropagationHandler(contextAccessor, options, logger); } ); - - return builder; - } } diff --git a/src/Keycloak.AuthServices.Authorization/AuthorizationServer/AccessTokenPropagationHandler.cs b/src/Keycloak.AuthServices.Authorization/AuthorizationServer/AccessTokenPropagationHandler.cs index b954f693..bbe795aa 100644 --- a/src/Keycloak.AuthServices.Authorization/AuthorizationServer/AccessTokenPropagationHandler.cs +++ b/src/Keycloak.AuthServices.Authorization/AuthorizationServer/AccessTokenPropagationHandler.cs @@ -3,15 +3,16 @@ using System.Net.Http.Headers; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; /// /// Delegating handler to propagate headers /// - public class AccessTokenPropagationHandler : DelegatingHandler { private readonly IHttpContextAccessor contextAccessor; + private readonly ILogger logger; private readonly KeycloakAuthorizationServerOptions options; /// @@ -19,13 +20,15 @@ public class AccessTokenPropagationHandler : DelegatingHandler /// /// The HTTP context accessor. /// The Keycloak client options. + /// public AccessTokenPropagationHandler( IHttpContextAccessor contextAccessor, - IOptions options + IOptions options, + ILogger logger ) { this.contextAccessor = contextAccessor; - + this.logger = logger; ArgumentNullException.ThrowIfNull(options); this.options = options.Value; } @@ -40,6 +43,8 @@ CancellationToken cancellationToken if (this.contextAccessor.HttpContext == null) { + this.logger.LogHttpContextIsNull(); + return await Continue(); } @@ -47,7 +52,7 @@ CancellationToken cancellationToken var token = await httpContext.GetTokenAsync( this.options.SourceAuthenticationScheme, - "access_token" + this.options.SourceTokenName ); if (!string.IsNullOrEmpty(token)) @@ -57,6 +62,10 @@ CancellationToken cancellationToken token ); } + else + { + this.logger.LogTokenIsEmpty(); + } return await Continue(); diff --git a/src/Keycloak.AuthServices.Authorization/AuthorizationServer/AuthorizationServerClient.cs b/src/Keycloak.AuthServices.Authorization/AuthorizationServer/AuthorizationServerClient.cs index 4ffe513a..d3ee1556 100644 --- a/src/Keycloak.AuthServices.Authorization/AuthorizationServer/AuthorizationServerClient.cs +++ b/src/Keycloak.AuthServices.Authorization/AuthorizationServer/AuthorizationServerClient.cs @@ -41,34 +41,43 @@ public async Task VerifyAccessToResource( ArgumentNullException.ThrowIfNull(resource); ArgumentNullException.ThrowIfNull(scope); - var data = this.PrepareData(resource, scope); + this.logger.LogVerifyingAccess(resource, scope); - using var content = new FormUrlEncodedContent(data); - var response = await this.httpClient.PostAsync( - KeycloakConstants.TokenEndpointPath, - content, - cancellationToken - ); + try + { + using var content = new FormUrlEncodedContent(this.PrepareRequest(resource, scope)); + var response = await this.httpClient.PostAsync( + KeycloakConstants.TokenEndpointPath, + content, + cancellationToken + ); - return await this.HandleResponse( - response, - resource, - scope, - scopesValidationMode, - cancellationToken - ); + return await this.HandleResponse( + response, + resource, + scope, + scopesValidationMode, + cancellationToken + ); + } + catch (Exception exception) + { + this.logger.LogVerifyAccessToResourceFailed(exception, resource, scope); + throw; + } } - private Dictionary PrepareData(string resource, string scope) + private Dictionary PrepareRequest(string resource, string scope) { var permission = string.IsNullOrWhiteSpace(scope) ? resource : $"{resource}#{scope}"; - var audience = this.options.Value.Resource; + var audience = this.options.Value.Resource ?? string.Empty; + var responseMode = scope.Contains(',') ? "permissions" : "decision"; return new Dictionary { { "grant_type", "urn:ietf:params:oauth:grant-type:uma-ticket" }, - { "response_mode", scope.Contains(',') ? "permissions" : "decision" }, - { "audience", audience ?? string.Empty }, + { "response_mode", responseMode }, + { "audience", audience }, { "permission", permission } }; } @@ -83,75 +92,81 @@ CancellationToken cancellationToken { if (!response.IsSuccessStatusCode) { - return await this.HandleErrorResponse(response, cancellationToken); - } + var error = await response.Content.ReadAsStringAsync(cancellationToken); - var scopes = scope.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList(); + if (!error.Contains(ErrorResponse.AccessDeniedError)) + { + this.logger.LogUnableToRecognizeResponse(resource, error); + } - if (scopes is { Count: <= 1 }) - { - return true; + return false; } - var scopeResponse = await response.Content.ReadFromJsonAsync( - cancellationToken: cancellationToken + return await this.ValidateScopesAsync( + resource, + scope, + scopesValidationMode, + response, + cancellationToken ); - - return this.ValidateScopes(scopeResponse, resource, scopes, scopesValidationMode); } - private async Task HandleErrorResponse( + private async Task ValidateScopesAsync( + string resource, + string scope, + ScopesValidationMode? scopesValidationMode, HttpResponseMessage response, CancellationToken cancellationToken ) { - var error = await response.Content.ReadFromJsonAsync( - cancellationToken: cancellationToken - ); + var scopes = scope.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList(); - if ( - !string.IsNullOrWhiteSpace(error?.Error) - && error.Error != ErrorResponse.AccessDeniedError - ) + if (scopes is { Count: <= 1 }) { -#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 true; } - return false; + var scopeResponse = await response.Content.ReadFromJsonAsync( + cancellationToken: cancellationToken + ); + + return this.ValidateScopesAsync(resource, scopes, scopeResponse, scopesValidationMode); } - private bool ValidateScopes( - ScopeResponse[]? scopeResponse, + private bool ValidateScopesAsync( string resource, - List scopes, + List scopesToValidate, + ScopeResponse[]? scopeResponse, 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}"); + scopeResponse ??= Array.Empty(); + + var resourceToValidate = Array.Find( + scopeResponse, + r => string.Equals(r.Rsname, resource, StringComparison.Ordinal) + ); + + if (resourceToValidate is null) + { + throw new KeycloakException($"Unable to find a resource - {resource}"); + } scopesValidationMode ??= this.options.Value.ScopesValidationMode; + this.logger.LogValidatingScopes(resource, scopesValidationMode.Value); + if (scopesValidationMode == ScopesValidationMode.AllOf) { var resourceScopes = resourceToValidate.Scopes; - var allScopesPresent = scopes.TrueForAll(s => resourceScopes.Contains(s)); + var allScopesPresent = scopesToValidate.TrueForAll(s => resourceScopes.Contains(s)); return allScopesPresent; } else if (scopesValidationMode == ScopesValidationMode.AnyOf) { var resourceScopes = resourceToValidate.Scopes; - var anyScopePresent = scopes.Exists(s => resourceScopes.Contains(s)); + var anyScopePresent = scopesToValidate.Exists(s => resourceScopes.Contains(s)); return anyScopePresent; } diff --git a/src/Keycloak.AuthServices.Authorization/AuthorizationServer/KeycloakAuthorizationServerOptions.cs b/src/Keycloak.AuthServices.Authorization/AuthorizationServer/KeycloakAuthorizationServerOptions.cs index 39c5181d..41ebe07c 100644 --- a/src/Keycloak.AuthServices.Authorization/AuthorizationServer/KeycloakAuthorizationServerOptions.cs +++ b/src/Keycloak.AuthServices.Authorization/AuthorizationServer/KeycloakAuthorizationServerOptions.cs @@ -17,6 +17,9 @@ public sealed class KeycloakAuthorizationServerOptions : KeycloakInstallationOpt /// public string SourceAuthenticationScheme { get; set; } = "Bearer"; + /// Gets or sets the token named used by + public string SourceTokenName { get; set; } = "access_token"; + /// /// Controls if is added to the /// diff --git a/src/Keycloak.AuthServices.Authorization/Keycloak.AuthServices.Authorization.csproj b/src/Keycloak.AuthServices.Authorization/Keycloak.AuthServices.Authorization.csproj index 639bd059..2712ee0c 100644 --- a/src/Keycloak.AuthServices.Authorization/Keycloak.AuthServices.Authorization.csproj +++ b/src/Keycloak.AuthServices.Authorization/Keycloak.AuthServices.Authorization.csproj @@ -16,7 +16,8 @@ - + + diff --git a/src/Keycloak.AuthServices.Authorization/KeycloakActivitySource.cs b/src/Keycloak.AuthServices.Authorization/KeycloakActivitySource.cs new file mode 100644 index 00000000..2bfc948d --- /dev/null +++ b/src/Keycloak.AuthServices.Authorization/KeycloakActivitySource.cs @@ -0,0 +1,70 @@ +namespace Keycloak.AuthServices.Authorization; + +using System.Diagnostics; + +/// +/// Represents the activity source for the Keycloak.AuthServices.Authorization namespace. +/// +internal static class KeycloakActivitySource +{ + private const string Version = "1.0.0"; + + /// + /// Gets the default activity source instance for Keycloak.AuthServices.Authorization. + /// + public static readonly ActivitySource Default = + new("Keycloak.AuthServices.Authorization", Version); +} + +internal static class ActivityConstants +{ + public const string Namespace = "keycloak.authservices"; + + public static class Events + { + /// + /// Represents the event when verification is started. + /// + public const string VerificationStarted = "Verification Started"; + + /// + /// Represents the event when verification is completed. + /// + public const string VerificationCompleted = "Verification Completed"; + } + + public static class Activities + { + /// + /// Represents the activity for a parameterized protected resource requirement. + /// + public const string ProtectedResourceRequirement = + "ParameterizedProtectedResourceRequirement"; + + /// + /// Represents the activity for protected resource verification. + /// + public const string ProtectedResourceVerification = "ProtectedResource"; + + public const string DecisionRequirement = "ProtectedResource"; + + } + + public static class Tags + { + /// + /// Represents the tag for the resource. + /// + public const string Resource = $"{Namespace}.resource"; + + /// + /// Represents the tag for the scopes. + /// + public const string Scopes = $"{Namespace}.scopes"; + + /// + /// Represents the tag for the outcome. + /// + public const string Outcome = $"{Namespace}.outcome"; + } +} diff --git a/src/Keycloak.AuthServices.Authorization/KeycloakMetrics.cs b/src/Keycloak.AuthServices.Authorization/KeycloakMetrics.cs new file mode 100644 index 00000000..72df02d1 --- /dev/null +++ b/src/Keycloak.AuthServices.Authorization/KeycloakMetrics.cs @@ -0,0 +1,78 @@ +namespace Keycloak.AuthServices.Authorization; + +using System.Diagnostics.Metrics; + +/// +/// Represents a class for tracking Keycloak authorization metrics. +/// +public class KeycloakMetrics +{ + private readonly Counter requirementsFailed; + private readonly Counter requirementsSucceeded; + private readonly Counter requirementsErrored; + private readonly Counter requirementsSkipped; + + /// + /// Initializes a new instance of the class. + /// + /// The meter factory used to create metrics. + public KeycloakMetrics(IMeterFactory meterFactory) + { +#pragma warning disable CA2000 // Dispose objects before losing scope + var meter = meterFactory.Create("Keycloak.AuthServices.Authorization"); +#pragma warning restore CA2000 // Dispose objects before losing scope + this.requirementsFailed = meter.CreateCounter( + $"{ActivityConstants.Namespace}.requirements.fail" + ); + this.requirementsSucceeded = meter.CreateCounter( + $"{ActivityConstants.Namespace}.requirements.succeed" + ); + this.requirementsErrored = meter.CreateCounter( + $"{ActivityConstants.Namespace}.requirements.error" + ); + this.requirementsSkipped = meter.CreateCounter( + $"{ActivityConstants.Namespace}.requirements.skipped" + ); + } + + /// + /// Records a failed requirement. + /// + /// The name of the failed requirement. + public void FailRequirement(string requirement) => + this.requirementsFailed.Add( + 1, + new KeyValuePair("requirement", requirement) + ); + + /// + /// Records a succeeded requirement. + /// + /// The name of the succeeded requirement. + public void SucceedRequirement(string requirement) => + this.requirementsSucceeded.Add( + 1, + new KeyValuePair("requirement", requirement) + ); + + /// + /// Records an errored requirement. + /// + /// The name of the errored requirement. + public void ErrorRequirement(string requirement) => + this.requirementsErrored.Add( + 1, + new KeyValuePair("requirement", requirement) + ); + + + /// + /// Records an skipped requirement. + /// + /// The name of the skipped requirement. + public void SkipRequirement(string requirement) => + this.requirementsSkipped.Add( + 1, + new KeyValuePair("requirement", requirement) + ); +} diff --git a/src/Keycloak.AuthServices.Authorization/LogExtensions.cs b/src/Keycloak.AuthServices.Authorization/LogExtensions.cs deleted file mode 100644 index 64a1421b..00000000 --- a/src/Keycloak.AuthServices.Authorization/LogExtensions.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace Keycloak.AuthServices.Authorization; - -using Microsoft.Extensions.Logging; - -internal static partial class LogExtensions -{ - [LoggerMessage( - 100, - LogLevel.Debug, - "[{Requirement}] Access outcome '{Outcome}' for user '{UserName}'" - )] - public static partial void LogAuthorizationResult( - this ILogger logger, - string requirement, - bool outcome, - string? userName - ); - - [LoggerMessage( - 103, - LogLevel.Information, - "[{Requirement}] Authorization failed for user '{UserName}'" - )] - public static partial void LogAuthorizationFailed( - this ILogger logger, - string requirement, - string? userName - ); - - [LoggerMessage( - 101, - LogLevel.Warning, - "[{Requirement}] Has been skipped because of '{Reason}' for user '{UserName}'" - )] - public static partial void LogRequirementSkipped( - this ILogger logger, - string requirement, - string reason, - string? userName - ); - - [LoggerMessage(102, LogLevel.Debug, "User - '{UserName}' has verification table: {Verification}")] - public static partial void LogVerification( - this ILogger logger, - string verification, - string? userName - ); -} diff --git a/src/Keycloak.AuthServices.Authorization/LoggerExtensions.cs b/src/Keycloak.AuthServices.Authorization/LoggerExtensions.cs new file mode 100644 index 00000000..ec3c0118 --- /dev/null +++ b/src/Keycloak.AuthServices.Authorization/LoggerExtensions.cs @@ -0,0 +1,128 @@ +namespace Keycloak.AuthServices.Authorization; + +using Keycloak.AuthServices.Authorization.AuthorizationServer; +using Microsoft.Extensions.Logging; + +internal static partial class LoggerExtensions +{ + [LoggerMessage( + 100, + LogLevel.Debug, + "[{Requirement}] Access outcome '{Outcome}' for user '{UserName}'" + )] + public static partial void LogAuthorizationResult( + this ILogger logger, + string requirement, + bool outcome, + string? userName + ); + + [LoggerMessage( + 103, + LogLevel.Information, + "[{Requirement}] Authorization failed for user '{UserName}'" + )] + public static partial void LogAuthorizationFailed( + this ILogger logger, + string requirement, + string? userName + ); + + [LoggerMessage(101, LogLevel.Debug, "[{Requirement}] Has been skipped because of '{Reason}'")] + public static partial void LogRequirementSkipped( + this ILogger logger, + string requirement, + string reason = "User is not Authenticated" + ); + + [LoggerMessage( + 102, + LogLevel.Debug, + "User - '{UserName}' has verification table: {Verification}" + )] + public static partial void LogVerificationTable( + this ILogger logger, + string verification, + string? userName + ); + + [LoggerMessage( + 112, + LogLevel.Debug, + "User - '{UserName}' has verification plan: {Verification}" + )] + public static partial void LogVerificationPlan( + this ILogger logger, + string verification, + string? userName + ); + + [LoggerMessage( + 104, + LogLevel.Error, + "Exception occurred during resource[{Resource}#{Scopes}] verification" + )] + public static partial void LogAuthorizationError( + this ILogger logger, + string resource, + string? scopes + ); + + [LoggerMessage(105, LogLevel.Debug, "HttpContext is null, continuing without token")] + public static partial void LogHttpContextIsNull(this ILogger logger); + + [LoggerMessage(106, LogLevel.Information, "Token is null or empty, continuing without token")] + public static partial void LogTokenIsEmpty(this ILogger logger); + + [LoggerMessage( + 107, + LogLevel.Debug, + "Verifying access to resource '{Resource}' with scope '{Scope}'" + )] + public static partial void LogVerifyingAccess( + this ILogger logger, + string resource, + string scope + ); + + [LoggerMessage( + 108, + LogLevel.Debug, + "Validating scopes for resource '{Resource}' in {ValidationMode}" + )] + public static partial void LogValidatingScopes( + this ILogger logger, + string resource, + ScopesValidationMode validationMode + ); + + [LoggerMessage( + 109, + LogLevel.Error, + "An unexpected error occurred while verifying access to resource '{Resource}' with scope '{Scope}'" + )] + public static partial void LogVerifyAccessToResourceFailed( + this ILogger logger, + Exception exception, + string resource, + string scope + ); + + [LoggerMessage( + 110, + LogLevel.Warning, + "Verification on resource '{Resource}' was not able to recognize response. Verification terminated due to '{Reason}'" + )] + public static partial void LogUnableToRecognizeResponse( + this ILogger logger, + string resource, + string reason + ); + + [LoggerMessage(111, LogLevel.Debug, "Resource '{Resource}' resolved as '{ResourceValue}'")] + public static partial void LogResourceResolved( + this ILogger logger, + string resource, + string resourceValue + ); +} diff --git a/src/Keycloak.AuthServices.Authorization/PoliciesBuilderExtensions.cs b/src/Keycloak.AuthServices.Authorization/PoliciesBuilderExtensions.cs index 6fba7bae..5331b1a3 100644 --- a/src/Keycloak.AuthServices.Authorization/PoliciesBuilderExtensions.cs +++ b/src/Keycloak.AuthServices.Authorization/PoliciesBuilderExtensions.cs @@ -48,6 +48,7 @@ string[] roles ArgumentNullException.ThrowIfNull(roles); return builder + .RequireAuthenticatedUser() .RequireClaim(KeycloakConstants.ResourceAccessClaimType) .AddRequirements(new ResourceAccessRequirement(client, roles)); } @@ -67,6 +68,7 @@ params string[] roles ArgumentNullException.ThrowIfNull(roles); return builder + .RequireAuthenticatedUser() .RequireClaim(KeycloakConstants.RealmAccessClaimType) .AddRequirements(new RealmAccessRequirement(roles)); } diff --git a/src/Keycloak.AuthServices.Authorization/Requirements/DecisionRequirement.cs b/src/Keycloak.AuthServices.Authorization/Requirements/DecisionRequirement.cs index b86b3d0d..20ecf96b 100644 --- a/src/Keycloak.AuthServices.Authorization/Requirements/DecisionRequirement.cs +++ b/src/Keycloak.AuthServices.Authorization/Requirements/DecisionRequirement.cs @@ -2,7 +2,9 @@ namespace Keycloak.AuthServices.Authorization.Requirements; using Keycloak.AuthServices.Authorization.AuthorizationServer; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; +using static Keycloak.AuthServices.Authorization.ActivityConstants; /// /// Decision requirement @@ -62,22 +64,31 @@ public override string ToString() => /// /// -public partial class DecisionRequirementHandler : AuthorizationHandler +public class DecisionRequirementHandler : AuthorizationHandler { private readonly IAuthorizationServerClient client; + private readonly IHttpContextAccessor httpContextAccessor; + private readonly KeycloakMetrics metrics; private readonly ILogger logger; /// /// /// + /// + /// /// /// public DecisionRequirementHandler( IAuthorizationServerClient client, + IHttpContextAccessor httpContextAccessor, + KeycloakMetrics metrics, ILogger logger ) { this.client = client ?? throw new ArgumentNullException(nameof(client)); + this.httpContextAccessor = + httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); + this.metrics = metrics; this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -90,37 +101,57 @@ DecisionRequirement requirement ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(requirement); - if (!(context.User.Identity?.IsAuthenticated ?? false)) + using var activity = KeycloakActivitySource.Default.StartActivity( + Activities.DecisionRequirement + ); + var userName = context.User.Identity?.Name; + + if (!context.User.IsAuthenticated()) { + this.metrics.SkipRequirement(nameof(ParameterizedProtectedResourceRequirement)); this.logger.LogRequirementSkipped( - nameof(ParameterizedProtectedResourceRequirementHandler), - "User is not Authenticated", - context.User.Identity?.Name + nameof(ParameterizedProtectedResourceRequirementHandler) ); return; } - var success = await this.client.VerifyAccessToResource( + var resource = Utils.ResolveResource( requirement.Resource, - (requirement as IProtectedResourceData).GetScopesExpression(), + this.httpContextAccessor.HttpContext + ); + this.logger.LogResourceResolved(requirement.Resource, resource); + + var scopes = (requirement as IProtectedResourceData).GetScopesExpression(); + + var verifier = new ProtectedResourceVerifier(this.client, this.metrics, this.logger); + + var success = await verifier.Verify( + resource, + scopes, + nameof(DecisionRequirement), requirement.ScopesValidationMode, CancellationToken.None ); + activity?.AddTag(Tags.Outcome, success); + this.logger.LogAuthorizationResult( - requirement.ToString(), + nameof(ParameterizedProtectedResourceRequirementHandler), success, - context.User.Identity?.Name + userName ); if (success) { + this.metrics.SucceedRequirement(nameof(DecisionRequirement)); context.Succeed(requirement); } else { - this.logger.LogAuthorizationFailed(requirement.ToString(), context.User.Identity?.Name); + this.metrics.FailRequirement(nameof(DecisionRequirement)); + this.logger.LogAuthorizationFailed(requirement.ToString()!, userName); + context.Fail(); } } diff --git a/src/Keycloak.AuthServices.Authorization/Requirements/ParameterizedProtectedResourceRequirement.cs b/src/Keycloak.AuthServices.Authorization/Requirements/ParameterizedProtectedResourceRequirement.cs index e9cce08f..84e8d37a 100644 --- a/src/Keycloak.AuthServices.Authorization/Requirements/ParameterizedProtectedResourceRequirement.cs +++ b/src/Keycloak.AuthServices.Authorization/Requirements/ParameterizedProtectedResourceRequirement.cs @@ -1,10 +1,11 @@ namespace Keycloak.AuthServices.Authorization.Requirements; +using Keycloak.AuthServices.Authorization; using Keycloak.AuthServices.Authorization.AuthorizationServer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; +using static Keycloak.AuthServices.Authorization.ActivityConstants; /// /// Decision requirement @@ -24,6 +25,7 @@ public class ParameterizedProtectedResourceRequirementHandler { private readonly IAuthorizationServerClient client; private readonly IHttpContextAccessor httpContextAccessor; + private readonly KeycloakMetrics metrics; private readonly ILogger logger; /// @@ -35,12 +37,14 @@ public class ParameterizedProtectedResourceRequirementHandler public ParameterizedProtectedResourceRequirementHandler( IAuthorizationServerClient client, IHttpContextAccessor httpContextAccessor, + KeycloakMetrics metrics, ILogger logger ) { - this.client = client ?? throw new ArgumentNullException(nameof(client)); + this.client = client; this.httpContextAccessor = httpContextAccessor; - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.metrics = metrics; + this.logger = logger; } /// @@ -52,89 +56,77 @@ ParameterizedProtectedResourceRequirement requirement ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(requirement); - if (!(context.User.Identity?.IsAuthenticated ?? false)) + using var activity = KeycloakActivitySource.Default.StartActivity( + Activities.ProtectedResourceRequirement + ); + var userName = context.User.Identity?.Name; + + if (!context.User.IsAuthenticated()) { + this.metrics.SkipRequirement(nameof(ParameterizedProtectedResourceRequirement)); this.logger.LogRequirementSkipped( - nameof(ParameterizedProtectedResourceRequirementHandler), - "User is not Authenticated", - context.User.Identity?.Name + nameof(ParameterizedProtectedResourceRequirementHandler) ); return; } var endpoint = this.httpContextAccessor.HttpContext?.GetEndpoint(); - var userName = context.User.Identity?.Name; var requirementData = endpoint?.Metadata?.GetOrderedMetadata() ?? Array.Empty(); var verificationPlan = new VerificationPlan(requirementData); + this.logger.LogVerificationPlan(verificationPlan.ToString(), userName); + + var success = true; foreach (var entry in verificationPlan) { var scopes = entry.GetScopesExpression(); + var resource = Utils.ResolveResource( + entry.Resource, + this.httpContextAccessor.HttpContext + ); + this.logger.LogResourceResolved(entry.Resource, resource); - var resource = ResolveResource(entry.Resource, this.httpContextAccessor.HttpContext); + var verifier = new ProtectedResourceVerifier(this.client, this.metrics, this.logger); - var success = await this.client.VerifyAccessToResource( + success = await verifier.Verify( resource, scopes, - CancellationToken.None + nameof(ParameterizedProtectedResourceRequirement), + cancellationToken: CancellationToken.None ); - verificationPlan.Complete(entry.Resource, success); if (!success) { - this.logger.LogVerification(verificationPlan.ToString(), userName); - this.logger.LogAuthorizationResult( - nameof(ParameterizedProtectedResourceRequirementHandler), - false, - userName - ); - this.logger.LogAuthorizationFailed(requirement.ToString()!, userName); - - context.Fail(); - - return; + break; } } - this.logger.LogVerification(verificationPlan.ToString(), userName); + activity?.AddTag(Tags.Outcome, success); + + this.logger.LogVerificationTable(verificationPlan.ToString(), userName); this.logger.LogAuthorizationResult( nameof(ParameterizedProtectedResourceRequirementHandler), - true, + success, userName ); - context.Succeed(requirement); - } - - private static string ResolveResource(string resource, HttpContext? httpContext) - { - if (httpContext is null) + if (success) { - return resource; + this.metrics.SucceedRequirement(nameof(ParameterizedProtectedResourceRequirement)); + context.Succeed(requirement); } - - var pathParameters = httpContext.GetRouteData()?.Values; - - if (pathParameters != null && resource.Contains('}') && resource.Contains('{')) + else { - foreach (var parameter in pathParameters) - { - var parameterName = parameter.Key; + this.metrics.FailRequirement(nameof(ParameterizedProtectedResourceRequirement)); + this.logger.LogAuthorizationFailed(requirement.ToString()!, userName); - if (resource.Contains($"{{{parameterName}}}")) - { - var parameterValue = parameter.Value?.ToString(); - resource = resource.Replace($"{{{parameterName}}}", parameterValue); - } - } + context.Fail(); } - - return resource; } } diff --git a/src/Keycloak.AuthServices.Authorization/Requirements/ProtectedResourceVerifier.cs b/src/Keycloak.AuthServices.Authorization/Requirements/ProtectedResourceVerifier.cs new file mode 100644 index 00000000..9c3b46a4 --- /dev/null +++ b/src/Keycloak.AuthServices.Authorization/Requirements/ProtectedResourceVerifier.cs @@ -0,0 +1,70 @@ +namespace Keycloak.AuthServices.Authorization.Requirements; + +using System.Diagnostics; +using Keycloak.AuthServices.Authorization.AuthorizationServer; +using Microsoft.Extensions.Logging; +using static Keycloak.AuthServices.Authorization.ActivityConstants; + +internal sealed class ProtectedResourceVerifier +{ + private readonly IAuthorizationServerClient client; + private readonly KeycloakMetrics metrics; + private readonly ILogger logger; + + public ProtectedResourceVerifier( + IAuthorizationServerClient client, + KeycloakMetrics metrics, + ILogger logger + ) + { + this.client = client; + this.metrics = metrics; + this.logger = logger; + } + + public async Task Verify( + string resource, + string scopes, + string requirement, + ScopesValidationMode? scopesValidationMode = default, + CancellationToken cancellationToken = default + ) + { + using var resourceActivity = KeycloakActivitySource.Default.StartActivity( + Activities.ProtectedResourceVerification + ); + + resourceActivity?.AddEvent(new(Events.VerificationStarted)); + resourceActivity?.AddTag(Tags.Resource, resource); + resourceActivity?.AddTag(Tags.Scopes, scopes); + + var success = false; + + try + { + success = await this.client.VerifyAccessToResource( + resource, + scopes, + scopesValidationMode, + cancellationToken + ); + } + catch (Exception exception) + { + this.logger.LogAuthorizationError(resource, scopes); + this.metrics.ErrorRequirement(requirement); + + resourceActivity?.SetStatus( + ActivityStatusCode.Error, + $"Unable to complete verification - {exception.Message}" + ); + + throw; + } + + resourceActivity?.AddEvent(new(Events.VerificationCompleted)); + resourceActivity?.AddTag(Tags.Outcome, success); + + return success; + } +} diff --git a/src/Keycloak.AuthServices.Authorization/Requirements/RealmAccessRequirement.cs b/src/Keycloak.AuthServices.Authorization/Requirements/RealmAccessRequirement.cs index 4db76f74..f0317089 100644 --- a/src/Keycloak.AuthServices.Authorization/Requirements/RealmAccessRequirement.cs +++ b/src/Keycloak.AuthServices.Authorization/Requirements/RealmAccessRequirement.cs @@ -28,15 +28,23 @@ public override string ToString() => /// /// -public partial class RealmAccessRequirementHandler : AuthorizationHandler +public class RealmAccessRequirementHandler : AuthorizationHandler { + private readonly KeycloakMetrics metrics; private readonly ILogger logger; /// /// + /// /// - public RealmAccessRequirementHandler(ILogger logger) => + public RealmAccessRequirementHandler( + KeycloakMetrics metrics, + ILogger logger + ) + { + this.metrics = metrics; this.logger = logger; + } /// protected override Task HandleRequirementAsync( @@ -47,23 +55,38 @@ RealmAccessRequirement requirement ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(requirement); + var userName = context.User.Identity?.Name; + + if (!context.User.IsAuthenticated()) + { + this.metrics.SkipRequirement(nameof(RealmAccessRequirement)); + this.logger.LogRequirementSkipped( + nameof(ParameterizedProtectedResourceRequirementHandler) + ); + + return Task.CompletedTask; + } + var success = false; if (context.User.Claims.TryGetRealmResource(out var resourceAccess)) { success = resourceAccess.Roles.Intersect(requirement.Roles).Any(); - - if (success) - { - context.Succeed(requirement); - } } - this.logger.LogAuthorizationResult( - requirement.ToString(), - success, - context.User.Identity?.Name - ); + this.logger.LogAuthorizationResult(requirement.ToString()!, success, userName); + + if (success) + { + this.metrics.SucceedRequirement(nameof(RealmAccessRequirement)); + + context.Succeed(requirement); + } + else + { + this.metrics.FailRequirement(nameof(RealmAccessRequirement)); + this.logger.LogAuthorizationFailed(requirement.ToString()!, userName); + } return Task.CompletedTask; } diff --git a/src/Keycloak.AuthServices.Authorization/Requirements/ResourceAccessRequirement.cs b/src/Keycloak.AuthServices.Authorization/Requirements/ResourceAccessRequirement.cs index c0f6684d..9399112d 100644 --- a/src/Keycloak.AuthServices.Authorization/Requirements/ResourceAccessRequirement.cs +++ b/src/Keycloak.AuthServices.Authorization/Requirements/ResourceAccessRequirement.cs @@ -44,18 +44,22 @@ public partial class ResourceAccessRequirementHandler : AuthorizationHandler { private readonly IOptions keycloakOptions; + private readonly KeycloakMetrics metrics; private readonly ILogger logger; /// /// /// + /// /// public ResourceAccessRequirementHandler( IOptions keycloakOptions, + KeycloakMetrics metrics, ILogger logger ) { this.keycloakOptions = keycloakOptions; + this.metrics = metrics; this.logger = logger; } @@ -68,6 +72,18 @@ ResourceAccessRequirement requirement ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(requirement); + var userName = context.User.Identity?.Name; + + if (!context.User.IsAuthenticated()) + { + this.metrics.SkipRequirement(nameof(RealmAccessRequirement)); + this.logger.LogRequirementSkipped( + nameof(ParameterizedProtectedResourceRequirementHandler) + ); + + return Task.CompletedTask; + } + var clientId = requirement.Resource ?? this.keycloakOptions.Value.RolesResource @@ -78,6 +94,7 @@ ResourceAccessRequirement requirement if (string.IsNullOrWhiteSpace(clientId)) { + this.metrics.ErrorRequirement(nameof(ResourceAccessRequirement)); throw new KeycloakException( $"Unable to resolve Resource for Role Validation - please make sure {nameof(KeycloakAuthorizationOptions)} are configured. \n\n See documentation for more details - https://nikiforovall.github.io/keycloak-authorization-services-dotnet/configuration/configuration-authorization.html#require-resource-roles" ); @@ -89,18 +106,21 @@ ResourceAccessRequirement requirement ) { success = resourceAccess.Roles.Intersect(requirement.Roles).Any(); - - if (success) - { - context.Succeed(requirement); - } } - this.logger.LogAuthorizationResult( - requirement.ToString(), - success, - context.User.Identity?.Name - ); + this.logger.LogAuthorizationResult(requirement.ToString()!, success, userName); + + if (success) + { + this.metrics.SucceedRequirement(nameof(ResourceAccessRequirement)); + + context.Succeed(requirement); + } + else + { + this.metrics.FailRequirement(nameof(ResourceAccessRequirement)); + this.logger.LogAuthorizationFailed(requirement.ToString()!, userName); + } return Task.CompletedTask; } diff --git a/src/Keycloak.AuthServices.Authorization/Requirements/RptRequirement.cs b/src/Keycloak.AuthServices.Authorization/Requirements/RptRequirement.cs index 4f8a25e5..c215e582 100644 --- a/src/Keycloak.AuthServices.Authorization/Requirements/RptRequirement.cs +++ b/src/Keycloak.AuthServices.Authorization/Requirements/RptRequirement.cs @@ -4,6 +4,7 @@ namespace Keycloak.AuthServices.Authorization.Requirements; using System.Security.Claims; using System.Text.Json; using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Logging; /// /// Requesting Party Token (RPT) requirement @@ -38,6 +39,14 @@ public RptRequirement(string resource, string scope) /// public class RptRequirementHandler : AuthorizationHandler { + private readonly ILogger logger; + + /// + /// + /// + /// + public RptRequirementHandler(ILogger logger) => this.logger = logger; + /// /// /// @@ -50,6 +59,16 @@ RptRequirement requirement { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(requirement); + + if (!context.User.IsAuthenticated()) + { + this.logger.LogRequirementSkipped( + nameof(RptRequirementHandler) + ); + + return Task.CompletedTask; + } + // the client application is responsible for acquiring of the token // should request special RPT access_token that contains this section var authorizationClaim = context.User.FindFirstValue("authorization"); @@ -58,8 +77,7 @@ RptRequirement requirement return Task.CompletedTask; } - /* Sample value for authorizationClaim - { + /*{ "permissions":[ { "scopes":["read"], @@ -94,6 +112,7 @@ RptRequirement requirement } context.Succeed(requirement); + return Task.CompletedTask; } diff --git a/src/Keycloak.AuthServices.Authorization/Requirements/VerificationPlan.cs b/src/Keycloak.AuthServices.Authorization/Requirements/VerificationPlan.cs index f9aadeaa..c52ae92b 100644 --- a/src/Keycloak.AuthServices.Authorization/Requirements/VerificationPlan.cs +++ b/src/Keycloak.AuthServices.Authorization/Requirements/VerificationPlan.cs @@ -101,6 +101,11 @@ public void Complete(string resource, bool result) => /// A string representation of the verification plan. public override string ToString() { + if (!this.Any()) + { + return ""; + } + var sb = new StringBuilder(Environment.NewLine); sb.AppendLine( diff --git a/src/Keycloak.AuthServices.Authorization/ServiceCollectionExtensions.cs b/src/Keycloak.AuthServices.Authorization/ServiceCollectionExtensions.cs index b2fe6ce4..f5d7f05e 100644 --- a/src/Keycloak.AuthServices.Authorization/ServiceCollectionExtensions.cs +++ b/src/Keycloak.AuthServices.Authorization/ServiceCollectionExtensions.cs @@ -92,6 +92,8 @@ public static IServiceCollection AddKeycloakAuthorization( services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(sp => { var keycloakOptions = sp.GetRequiredService< @@ -209,8 +211,7 @@ public static IHttpClientBuilder AddAuthorizationServer( IAuthorizationHandler, ParameterizedProtectedResourceRequirementHandler >(); - // TODO: determine correct lifetime. - services.AddSingleton(); + services.AddScoped(); // (!) resolved locally, will not work with PostConfigure and IOptions pattern configureKeycloakOptions ??= _ => { }; diff --git a/src/Keycloak.AuthServices.Authorization/Utils.cs b/src/Keycloak.AuthServices.Authorization/Utils.cs new file mode 100644 index 00000000..2b65402e --- /dev/null +++ b/src/Keycloak.AuthServices.Authorization/Utils.cs @@ -0,0 +1,37 @@ +namespace Keycloak.AuthServices.Authorization; + +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +internal static class Utils +{ + public static string ResolveResource(string resource, HttpContext? httpContext) + { + if (httpContext is null) + { + return resource; + } + + var pathParameters = httpContext.GetRouteData()?.Values; + + if (pathParameters != null && resource.Contains('}') && resource.Contains('{')) + { + foreach (var parameter in pathParameters) + { + var parameterName = parameter.Key; + + if (resource.Contains($"{{{parameterName}}}")) + { + var parameterValue = parameter.Value?.ToString(); + resource = resource.Replace($"{{{parameterName}}}", parameterValue); + } + } + } + + return resource; + } + + public static bool IsAuthenticated(this ClaimsPrincipal? principal) => + principal?.Identity?.IsAuthenticated ?? false; +} diff --git a/src/Keycloak.AuthServices.OpenTelemetry/Keycloak.AuthServices.OpenTelemetry.csproj b/src/Keycloak.AuthServices.OpenTelemetry/Keycloak.AuthServices.OpenTelemetry.csproj new file mode 100644 index 00000000..52c1aefc --- /dev/null +++ b/src/Keycloak.AuthServices.OpenTelemetry/Keycloak.AuthServices.OpenTelemetry.csproj @@ -0,0 +1,15 @@ + + + + Keycloak.AuthServices.OpenTelemetry + Instrumentation for OpenTelemetry .NET + $(PackageTags);distributed-tracing;opentelemetry; + true + 1.0.0 + + + + + + + diff --git a/src/Keycloak.AuthServices.OpenTelemetry/MeterProviderBuilderExtensions.cs b/src/Keycloak.AuthServices.OpenTelemetry/MeterProviderBuilderExtensions.cs new file mode 100644 index 00000000..b88617bb --- /dev/null +++ b/src/Keycloak.AuthServices.OpenTelemetry/MeterProviderBuilderExtensions.cs @@ -0,0 +1,24 @@ +namespace OpenTelemetry.Metrics; + +/// +/// Extension methods to simplify registering of ASP.NET Core request instrumentation. +/// +public static class MeterProviderBuilderExtensions +{ + /// + /// + /// being configured. + /// The instance of to chain the calls. + public static MeterProviderBuilder AddKeycloakAuthServicesInstrumentation( + this MeterProviderBuilder builder + ) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.ConfigureMeters(); + } + + internal static MeterProviderBuilder ConfigureMeters(this MeterProviderBuilder builder) => + builder + .AddMeter("Keycloak.AuthServices.Authorization"); +} diff --git a/src/Keycloak.AuthServices.OpenTelemetry/TracerProviderBuilderExtensions.cs b/src/Keycloak.AuthServices.OpenTelemetry/TracerProviderBuilderExtensions.cs new file mode 100644 index 00000000..a59dbc39 --- /dev/null +++ b/src/Keycloak.AuthServices.OpenTelemetry/TracerProviderBuilderExtensions.cs @@ -0,0 +1,24 @@ +namespace OpenTelemetry.Trace; + +/// +/// Provides extension methods for configuring Keycloak AuthServices instrumentation on the . +/// +public static class TracerProviderBuilderExtensions +{ + /// + /// Adds Keycloak AuthServices instrumentation to the . + /// + /// being configured. + /// The instance of to chain the calls. + + public static TracerProviderBuilder AddKeycloakAuthServicesInstrumentation( + this TracerProviderBuilder builder + ) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.AddSource("Keycloak.AuthServices.Authorization"); + + return builder; + } +} diff --git a/tests/.editorconfig b/tests/.editorconfig index 257d74eb..defe08bb 100644 --- a/tests/.editorconfig +++ b/tests/.editorconfig @@ -1,4 +1,5 @@ -[*.cs] +[*.{cs,csx,cake,vb,vbx}] + dotnet_diagnostic.CA1707.severity = none # CA1711: Identifiers should not have incorrect suffix diff --git a/tests/Keycloak.AuthServices.IntegrationTests/AuthorizationServerPolicyTests.cs b/tests/Keycloak.AuthServices.IntegrationTests/AuthorizationServerPolicyTests.cs index eaa33130..ac0955d4 100644 --- a/tests/Keycloak.AuthServices.IntegrationTests/AuthorizationServerPolicyTests.cs +++ b/tests/Keycloak.AuthServices.IntegrationTests/AuthorizationServerPolicyTests.cs @@ -64,14 +64,14 @@ public async Task RequireProtectedResource_DefaultResource_Verified() await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); _.StatusCodeShouldBe(HttpStatusCode.OK); }); await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); _.StatusCodeShouldBe(HttpStatusCode.Forbidden); }); @@ -124,14 +124,14 @@ public async Task RequireProtectedResource_Scopes_Verified() await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); _.StatusCodeShouldBe(HttpStatusCode.OK); }); await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); _.StatusCodeShouldBe(HttpStatusCode.Forbidden); }); @@ -184,14 +184,14 @@ public async Task RequireProtectedResource_MultipleScopesAllOf_Verified() ); await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); _.StatusCodeShouldBe(HttpStatusCode.OK); }); await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); _.StatusCodeShouldBe(HttpStatusCode.Forbidden); }); @@ -243,14 +243,14 @@ public async Task RequireProtectedResource_MultipleScopesAnyOf_Verified() ); await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); _.StatusCodeShouldBe(HttpStatusCode.OK); }); await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); _.StatusCodeShouldBe(HttpStatusCode.OK); }); @@ -307,19 +307,19 @@ public async Task RequireProtectedResource_MultipleScopesMissingScope_Verified() ); await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); _.StatusCodeShouldBe(HttpStatusCode.Forbidden); }); await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); _.StatusCodeShouldBe(HttpStatusCode.Forbidden); }); } - private static string RunPolicyBuyName(string policyName) => - $"/endpoints/RunPolicyBuyName?policy={policyName}"; + private static string RunPolicyByName(string policyName) => + $"/endpoints/RunPolicyByName?policy={policyName}"; } diff --git a/tests/Keycloak.AuthServices.IntegrationTests/Playground.cs b/tests/Keycloak.AuthServices.IntegrationTests/Playground.cs index c3a3eb3a..fbb5d67b 100644 --- a/tests/Keycloak.AuthServices.IntegrationTests/Playground.cs +++ b/tests/Keycloak.AuthServices.IntegrationTests/Playground.cs @@ -61,12 +61,12 @@ public async Task PlaygroundRequireProtectedResource_Scopes_Verified() await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); _.StatusCodeShouldBe(HttpStatusCode.OK); }); } - private static string RunPolicyBuyName(string policyName) => - $"/endpoints/RunPolicyBuyName?policy={policyName}"; + private static string RunPolicyByName(string policyName) => + $"/endpoints/RunPolicyByName?policy={policyName}"; } diff --git a/tests/Keycloak.AuthServices.IntegrationTests/PolicyTests.cs b/tests/Keycloak.AuthServices.IntegrationTests/PolicyTests.cs index e8a98f06..303c8562 100644 --- a/tests/Keycloak.AuthServices.IntegrationTests/PolicyTests.cs +++ b/tests/Keycloak.AuthServices.IntegrationTests/PolicyTests.cs @@ -56,14 +56,14 @@ public async Task RequireRealmRoles_AdminRole_Verified() await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); _.StatusCodeShouldBe(HttpStatusCode.OK); }); await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); _.StatusCodeShouldBe(HttpStatusCode.Forbidden); }); @@ -113,14 +113,14 @@ public async Task RequireClientRoles_TestClientRole_Verified() await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); _.StatusCodeShouldBe(HttpStatusCode.Forbidden); }); await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); _.StatusCodeShouldBe(HttpStatusCode.OK); }); @@ -168,14 +168,14 @@ public async Task RequireClientRoles_TestClientRoleWithInlineConfiguration_Verif await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); _.StatusCodeShouldBe(HttpStatusCode.Forbidden); }); await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); _.StatusCodeShouldBe(HttpStatusCode.OK); }); @@ -221,14 +221,14 @@ public async Task RequireClientRoles_TestClientRoleWithConfiguration_Verified() await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); _.StatusCodeShouldBe(HttpStatusCode.Forbidden); }); await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); _.StatusCodeShouldBe(HttpStatusCode.OK); }); @@ -279,14 +279,14 @@ public async Task RequireRealmRoles_AdminRoleWithMapping_Verified() await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); _.StatusCodeShouldBe(HttpStatusCode.OK); }); await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); _.StatusCodeShouldBe(HttpStatusCode.Forbidden); }); @@ -337,19 +337,19 @@ public async Task RequireClientRoles_TestClientRoleWithMapping_Verified() await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); _.StatusCodeShouldBe(HttpStatusCode.Forbidden); }); await host.Scenario(_ => { - _.Get.Url(RunPolicyBuyName(policyName)); + _.Get.Url(RunPolicyByName(policyName)); _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); _.StatusCodeShouldBe(HttpStatusCode.OK); }); } - private static string RunPolicyBuyName(string policyName) => - $"/endpoints/RunPolicyBuyName?policy={policyName}"; + private static string RunPolicyByName(string policyName) => + $"/endpoints/RunPolicyByName?policy={policyName}"; } diff --git a/tests/Keycloak.AuthServices.IntegrationTests/ProtectedResourceWithControllersPolicyTests.cs b/tests/Keycloak.AuthServices.IntegrationTests/ProtectedResourceWithControllersPolicyTests.cs index 21f3d477..49ef7bf1 100644 --- a/tests/Keycloak.AuthServices.IntegrationTests/ProtectedResourceWithControllersPolicyTests.cs +++ b/tests/Keycloak.AuthServices.IntegrationTests/ProtectedResourceWithControllersPolicyTests.cs @@ -16,7 +16,7 @@ public class ProtectedResourceWithControllersPolicyTests(ITestOutputHelper testO private static readonly string AppSettings = "appsettings.json"; [Fact] - public async Task ProtectedResourceAttribute_DeleteWorkspace_Verified() + public async Task ProtectedResourceAttribute_DeleteWorkspaceAndAccessPublic_Verified() { await using var host = await AlbaHost.For( SetupAuthorizationServer(testOutputHelper), diff --git a/tests/Keycloak.AuthServices.IntegrationTests/appsettings.json b/tests/Keycloak.AuthServices.IntegrationTests/appsettings.json index 266d1a68..b87fe7ff 100644 --- a/tests/Keycloak.AuthServices.IntegrationTests/appsettings.json +++ b/tests/Keycloak.AuthServices.IntegrationTests/appsettings.json @@ -1,4 +1,13 @@ { + "Logging": { + "LogLevel": { + "Default": "Trace", + "Microsoft.AspNetCore.Mvc": "Information", + "Microsoft.AspNetCore.Routing.Matching": "Information", + "System.Net.Http.HttpClient.IAuthorizationServerClient": "Debug", + "Microsoft.AspNetCore.DataProtection": "Warning" + } + }, "Keycloak": { "realm": "Test", "auth-server-url": "http://localhost:8080/", diff --git a/tests/TestWebApi/Program.cs b/tests/TestWebApi/Program.cs index 3b0f8167..7920426f 100644 --- a/tests/TestWebApi/Program.cs +++ b/tests/TestWebApi/Program.cs @@ -18,7 +18,7 @@ var endpoints = app.MapGroup("/endpoints").RequireAuthorization(); endpoints.MapGet("1", () => new { Success = true }); -endpoints.MapGet("RunPolicyBuyName", AuthorizeAsync); +endpoints.MapGet("RunPolicyByName", AuthorizeAsync); var protectedResources = app.MapGroup("/pr");