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"]
},