diff --git a/Directory.Packages.props b/Directory.Packages.props index c8614dd7..006607e6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,6 +9,7 @@ + diff --git a/Integrations/MCP/ScriptBee.MCP/Generated/GatewayApi.g.cs b/Integrations/MCP/ScriptBee.MCP/Generated/GatewayApi.g.cs index 059a81cf..891c7f84 100644 --- a/Integrations/MCP/ScriptBee.MCP/Generated/GatewayApi.g.cs +++ b/Integrations/MCP/ScriptBee.MCP/Generated/GatewayApi.g.cs @@ -857,7 +857,7 @@ public partial interface IGatewayApi /// Thrown when the request returns a non-success status code. [Headers("Accept: application/json")] [Get("/api/config/auth")] - Task Config(CancellationToken cancellationToken = default); + Task Auth(CancellationToken cancellationToken = default); /// Delete analysis /// Deletes a specific analysis and all its associated artifacts. @@ -1138,7 +1138,7 @@ namespace ScriptBee.MCP.Gateway.Generated.Contracts { using System = global::System; - + [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] public partial class AllAvailablePluginsResponse @@ -1278,6 +1278,9 @@ public partial class AuthConfig [JsonPropertyName("authority")] public string Authority { get; set; } + [JsonPropertyName("authWellknownEndpointUrl")] + public string AuthWellknownEndpointUrl { get; set; } + [JsonPropertyName("clientId")] public string ClientId { get; set; } @@ -2895,4 +2898,4 @@ public FileParameter(System.IO.Stream data, string fileName, string contentType) #pragma warning restore 8603 #pragma warning restore 8604 #pragma warning restore 8625 -#pragma warning restore 8765 +#pragma warning restore 8765 \ No newline at end of file diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Web/Auth/AllowAllAuthorizationHandler.cs b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Auth/AllowAllAuthorizationHandler.cs new file mode 100644 index 00000000..72f01f0c --- /dev/null +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Auth/AllowAllAuthorizationHandler.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; + +namespace ScriptBee.Web.Auth; + +public class AllowAllAuthorizationHandler : IAuthorizationHandler +{ + public Task HandleAsync(AuthorizationHandlerContext context) + { + foreach (var requirement in context.PendingRequirements.ToList()) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } +} diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Web/Auth/AuthorizeActionAttribute.cs b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Auth/AuthorizeActionAttribute.cs new file mode 100644 index 00000000..6d99b02c --- /dev/null +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Auth/AuthorizeActionAttribute.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Authorization; + +namespace ScriptBee.Web.Auth; + +[AttributeUsage(AttributeTargets.Method)] +public class AuthorizeActionAttribute(string action) + : AuthorizeAttribute, + IAuthorizationRequirementData +{ + public IEnumerable GetRequirements() => + [new OpaActionRequirement(action)]; +} diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Web/Auth/EndpointAuthorizationExtensions.cs b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Auth/EndpointAuthorizationExtensions.cs new file mode 100644 index 00000000..da09fadf --- /dev/null +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Auth/EndpointAuthorizationExtensions.cs @@ -0,0 +1,9 @@ +namespace ScriptBee.Web.Auth; + +public static class EndpointAuthorizationExtensions +{ + public static RouteHandlerBuilder RequireAction(this RouteHandlerBuilder builder, string action) + { + return builder.RequireAuthorization(new AuthorizeActionAttribute(action)); + } +} diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Web/Auth/OpaActionRequirement.cs b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Auth/OpaActionRequirement.cs new file mode 100644 index 00000000..a4d5d820 --- /dev/null +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Auth/OpaActionRequirement.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Authorization; + +namespace ScriptBee.Web.Auth; + +public class OpaActionRequirement(string action) : IAuthorizationRequirement +{ + public string Action { get; } = action; +} diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Web/Config/AuthenticationConfig.cs b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Config/AuthenticationConfig.cs index ae88c68e..7490ba70 100644 --- a/ScriptBeeWebApp/src/Gateway/Adapters/Web/Config/AuthenticationConfig.cs +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Config/AuthenticationConfig.cs @@ -3,7 +3,14 @@ namespace ScriptBee.Web.Config; public class AuthenticationConfig { public string? AuthMode { get; init; } + public required bool RequireHttpsMetadata { get; init; } public string? Authority { get; init; } + public string? Audience { get; init; } + public string? AuthWellknownEndpointUrl { get; init; } public string? ClientId { get; init; } public string? Scope { get; init; } + public string? OpaUrl { get; init; } + + public bool IsDevelopment => + AuthMode?.Equals("Development", StringComparison.OrdinalIgnoreCase) ?? false; } diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Web/EndpointDefinitions/Config/AuthConfigEndpoint.cs b/ScriptBeeWebApp/src/Gateway/Adapters/Web/EndpointDefinitions/Config/AuthConfigEndpoint.cs index f01145ae..6b33a2ff 100644 --- a/ScriptBeeWebApp/src/Gateway/Adapters/Web/EndpointDefinitions/Config/AuthConfigEndpoint.cs +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Web/EndpointDefinitions/Config/AuthConfigEndpoint.cs @@ -28,6 +28,7 @@ IOptions authConfigOptions { AuthMode = config.AuthMode, Authority = config.Authority, + AuthWellknownEndpointUrl = config.AuthWellknownEndpointUrl, ClientId = config.ClientId, Scope = config.Scope, } diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Web/EndpointDefinitions/Config/Contracts/WebAuthConfig.cs b/ScriptBeeWebApp/src/Gateway/Adapters/Web/EndpointDefinitions/Config/Contracts/WebAuthConfig.cs index 69f5e236..85569c67 100644 --- a/ScriptBeeWebApp/src/Gateway/Adapters/Web/EndpointDefinitions/Config/Contracts/WebAuthConfig.cs +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Web/EndpointDefinitions/Config/Contracts/WebAuthConfig.cs @@ -4,6 +4,7 @@ public class WebAuthConfig { public string? AuthMode { get; init; } public string? Authority { get; init; } + public string? AuthWellknownEndpointUrl { get; init; } public string? ClientId { get; init; } public string? Scope { get; init; } } diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Web/Extensions/AuthenticationExtensions.cs b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Extensions/AuthenticationExtensions.cs index 4c8d2f87..8fe615b8 100644 --- a/ScriptBeeWebApp/src/Gateway/Adapters/Web/Extensions/AuthenticationExtensions.cs +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Extensions/AuthenticationExtensions.cs @@ -1,13 +1,91 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.IdentityModel.Tokens; +using ScriptBee.Web.Auth; using ScriptBee.Web.Config; namespace ScriptBee.Web.Extensions; public static class AuthenticationExtensions { - public static IServiceCollection AddAuthenticationConfig(this IServiceCollection services) + private const string AuthenticationConfigSectionName = "Authentication"; + + public static IServiceCollection AddAuthenticationConfig( + this IServiceCollection services, + ConfigurationManager configurationManager + ) { - services.AddOptions().BindConfiguration("Authentication"); + services + .AddOptions() + .BindConfiguration(AuthenticationConfigSectionName); + + var authConfig = configurationManager + .GetSection(AuthenticationConfigSectionName) + .Get()!; + + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = authConfig.Authority; + options.Audience = authConfig.Audience; + options.RequireHttpsMetadata = authConfig.RequireHttpsMetadata; + + if (authConfig.IsDevelopment) + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = false, + ValidateLifetime = true, + }; + } + else + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidIssuer = authConfig.Authority, + ValidAudience = authConfig.Audience, + }; + } + }); + + services.AddHttpContextAccessor(); + services.AddHttpClient( + "OpaClient", + client => client.BaseAddress = new Uri(GetOpaUrl(authConfig)) + ); + + if (authConfig.IsDevelopment) + { + services.AddSingleton(); + } + else + { + services.AddSingleton(); + // TODO FIXIT(#332): Add OPA authorization handler + // services.AddSingleton(); + } + + services.AddAuthorization(); return services; } + + private static string GetOpaUrl(AuthenticationConfig config) + { + if (config.IsDevelopment) + { + return ""; + } + + return string.IsNullOrEmpty(config.OpaUrl) + ? throw new InvalidOperationException( + "OpaUrl is not configured and is mandatory. Please set Authentication:OpaUrl in your configuration." + ) + : config.OpaUrl; + } } diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Web/Program.cs b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Program.cs index 96039f89..f908c1bf 100644 --- a/ScriptBeeWebApp/src/Gateway/Adapters/Web/Program.cs +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Program.cs @@ -41,7 +41,7 @@ }) .AddValidatorsFromAssemblyContaining() .AddProblemDetailsDefaults() - .AddAuthenticationConfig() + .AddAuthenticationConfig(builder.Configuration) .AddMongoDb(mongoConnectionString) .AddCommonServices() .AddArtifactFileAdapters() @@ -80,6 +80,9 @@ app.UseAntiforgery(); app.UseAntiforgeryHeader(); +app.UseAuthentication(); +app.UseAuthorization(); + app.MapHealthChecksEndpoint(); app.UseSerilogRequestLogging(); diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Web/Web.csproj b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Web.csproj index 6e3f515c..53c1e515 100644 --- a/ScriptBeeWebApp/src/Gateway/Adapters/Web/Web.csproj +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Web/Web.csproj @@ -6,6 +6,7 @@ + diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Web/appsettings.Development.json b/ScriptBeeWebApp/src/Gateway/Adapters/Web/appsettings.Development.json index f8e492de..cc09afdc 100644 --- a/ScriptBeeWebApp/src/Gateway/Adapters/Web/appsettings.Development.json +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Web/appsettings.Development.json @@ -10,7 +10,13 @@ } }, "Authentication": { - "AuthMode": "Development" + "AuthMode": "Development", + "RequireHttpsMetadata": false, + "Authority": "http://localhost:8080/default", + "Audience": "test-client-id", + "ClientId": "test-client-id", + "Scope": "openid profile email", + "OpaUrl": "http://localhost:8181/" }, "ScriptBee": { "Analysis": { diff --git a/ScriptBeeWebApp/src/Gateway/Adapters/Web/appsettings.json b/ScriptBeeWebApp/src/Gateway/Adapters/Web/appsettings.json index 20a14e7d..4388c88d 100644 --- a/ScriptBeeWebApp/src/Gateway/Adapters/Web/appsettings.json +++ b/ScriptBeeWebApp/src/Gateway/Adapters/Web/appsettings.json @@ -24,6 +24,9 @@ } ] }, + "Authentication": { + "RequireHttpsMetadata": true + }, "ConnectionStrings": { "mongodb": "mongodb://root:example@localhost:27017/ScriptBee?authSource=admin" }, diff --git a/ScriptBeeWebApp/test/Gateway/Adapters/Web.Tests/Auth/AllowAllAuthorizationHandlerTests.cs b/ScriptBeeWebApp/test/Gateway/Adapters/Web.Tests/Auth/AllowAllAuthorizationHandlerTests.cs new file mode 100644 index 00000000..346db8dd --- /dev/null +++ b/ScriptBeeWebApp/test/Gateway/Adapters/Web.Tests/Auth/AllowAllAuthorizationHandlerTests.cs @@ -0,0 +1,32 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using ScriptBee.Web.Auth; + +namespace ScriptBee.Web.Tests.Auth; + +file record DummyRequirement : IAuthorizationRequirement; + +public class AllowAllAuthorizationHandlerTests +{ + private readonly AllowAllAuthorizationHandler _handler = new(); + + [Fact] + public async Task HandleAsync_ShouldSucceedAllPendingRequirements() + { + // Arrange + var requirement1 = new DummyRequirement(); + var requirement2 = new DummyRequirement(); + + var user = new ClaimsPrincipal(new ClaimsIdentity()); + var requirements = new IAuthorizationRequirement[] { requirement1, requirement2 }; + + var context = new AuthorizationHandlerContext(requirements, user, resource: null); + + // Act + await _handler.HandleAsync(context); + + // Assert + Assert.True(context.HasSucceeded); + Assert.False(context.HasFailed); + } +} diff --git a/ScriptBeeWebApp/test/Gateway/Adapters/Web.Tests/EndpointDefinitions/Config/TestData/GetAuthConfig/response.json b/ScriptBeeWebApp/test/Gateway/Adapters/Web.Tests/EndpointDefinitions/Config/TestData/GetAuthConfig/response.json index 734e76de..d6636d33 100644 --- a/ScriptBeeWebApp/test/Gateway/Adapters/Web.Tests/EndpointDefinitions/Config/TestData/GetAuthConfig/response.json +++ b/ScriptBeeWebApp/test/Gateway/Adapters/Web.Tests/EndpointDefinitions/Config/TestData/GetAuthConfig/response.json @@ -1,6 +1,6 @@ { "authMode": "Development", - "authority": null, - "clientId": null, - "scope": null + "authority": "http://localhost:8080/default", + "clientId": "test-client-id", + "scope": "openid profile email" } diff --git a/docs/architecture/configuration/gateway_configuration.md b/docs/architecture/configuration/gateway_configuration.md index 1f7fb152..84de78eb 100644 --- a/docs/architecture/configuration/gateway_configuration.md +++ b/docs/architecture/configuration/gateway_configuration.md @@ -10,6 +10,13 @@ ## Authentication +### `AUTHENTICATION__REQUIREHTTPSSECUREMETADATA` + +- **Type:** `bool` +- **Default:** `true` +- **Description:** Whether to require HTTPS for the OpenID Connect metadata endpoint. This should be set to `true` in + production. + ### `AUTHENTICATION__AUTHORITY` - **Type:** `string` @@ -17,6 +24,20 @@ - **Description:** The URL of the OpenID Connect authority (e.g., `https://login.microsoftonline.com/{tenantId}/v2.0` for Azure AD). +### `AUTHENTICATION__AUDIENCE` + +- **Type:** `string` +- **Default:** _None_ +- **Description:** The audience for the OpenID Connect application. (e.g. `api://my-app-backend`) + +### `AUTHENTICATION__AUTHWELLKNOWNENDPOINTURL` + +- **Type:** `string` +- **Default:** _None_ +- **Description:** An optional URL of the OpenID Connect well-known endpoint (e.g., + `https://login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configuration` for Azure AD). Normally the + authority URL is used to discover the well-known endpoint, but in some cases, you may want to override it. + ### `AUTHENTICATION__CLIENTID` - **Type:** `string` @@ -30,6 +51,12 @@ - **Description:** The scope for the OpenID Connect application. (e.g. `openid profile email api://my-app-backend/access_as_user`) +### `AUTHENTICATION__OPAURL` + +- **Type:** `string` +- **Default:** _None_ +- **Description:** The URL of the Open Policy Agent (OPA) server for authorization. + ### `AUTHENTICATION__AUTHMODE` - **Type:** `string` diff --git a/docs/public/gateway_swagger.json b/docs/public/gateway_swagger.json index 126c8fb0..8e30c4a6 100644 --- a/docs/public/gateway_swagger.json +++ b/docs/public/gateway_swagger.json @@ -2472,6 +2472,9 @@ "authority": { "type": ["null", "string"] }, + "authWellknownEndpointUrl": { + "type": ["null", "string"] + }, "clientId": { "type": ["null", "string"] },