diff --git a/doc/adr/0010-default-admin-user-bootstrap-for-initial-identity-access.md b/doc/adr/0010-default-admin-user-bootstrap-for-initial-identity-access.md
index 9675f8069..8e0f917ae 100644
--- a/doc/adr/0010-default-admin-user-bootstrap-for-initial-identity-access.md
+++ b/doc/adr/0010-default-admin-user-bootstrap-for-initial-identity-access.md
@@ -73,7 +73,7 @@ This narrows the purpose of `SecurityRoot` and keeps it from being the default a
- **More responsibility for integrators**: Deployments must intentionally configure `DefaultAdminUser` or provide another trusted bootstrap path if no administrator exists yet.
- **Migration awareness**: Existing documentation and operational guidance that referenced `SecurityRoot` for user bootstrap must be updated.
-- **Potential misconfiguration risk**: A weak or default admin password remains a deployment concern and must be handled carefully by integrators.
+- **Potential misconfiguration risk**: A weak admin password remains a deployment concern and must be handled carefully by integrators.
### Neutral
@@ -87,4 +87,3 @@ This narrows the purpose of `SecurityRoot` and keeps it from being the default a
- User-management endpoints should document only their permission requirements, not `SecurityRoot`.
- Authentication configuration may still use `SecurityRoot` for operations that intentionally remain root-level.
- Integrators should prefer environment-specific configuration for default admin credentials and rotate them according to their security practices.
-
diff --git a/doc/wiki/build-run-operate.md b/doc/wiki/build-run-operate.md
index 079ac17e7..01f80b723 100644
--- a/doc/wiki/build-run-operate.md
+++ b/doc/wiki/build-run-operate.md
@@ -85,14 +85,14 @@ docker pull elsaworkflows/elsa-server-and-studio-v3:latest
docker run -t -i -e ASPNETCORE_ENVIRONMENT='Development' -e HTTP_PORTS=8080 -e HTTP__BASEURL=http://localhost:13000 -p 13000:8080 elsaworkflows/elsa-server-and-studio-v3:latest
```
-Default development login:
+Default development login is available only when a development configuration explicitly provisions it:
```text
Username: admin
Password: password
```
-Do not use default credentials in production.
+Do not use development credentials in production.
## ASP.NET Middleware Order
diff --git a/src/apps/Elsa.ModularServer.Web/appsettings.Development.json b/src/apps/Elsa.ModularServer.Web/appsettings.Development.json
index 960be5067..df9fb2694 100644
--- a/src/apps/Elsa.ModularServer.Web/appsettings.Development.json
+++ b/src/apps/Elsa.ModularServer.Web/appsettings.Development.json
@@ -3,5 +3,21 @@
"LogLevel": {
"Default": "Information"
}
+ },
+ "CShells": {
+ "Shells": {
+ "Default": {
+ "Features": {
+ "DefaultAdminUser": {
+ "AdminUserName": "admin",
+ "AdminPassword": "password",
+ "AdminRoleName": "admin",
+ "AdminRolePermissions": [
+ "*"
+ ]
+ }
+ }
+ }
+ }
}
}
diff --git a/src/apps/Elsa.ModularServer.Web/appsettings.json b/src/apps/Elsa.ModularServer.Web/appsettings.json
index ac49e62c9..c48ddff4b 100644
--- a/src/apps/Elsa.ModularServer.Web/appsettings.json
+++ b/src/apps/Elsa.ModularServer.Web/appsettings.json
@@ -23,14 +23,6 @@
"Identity": {
"SigningKey": "CHANGE_ME_TO_A_SECURE_RANDOM_KEY"
},
- "DefaultAdminUser": {
- "AdminUserName": "admin",
- "AdminPassword": "password",
- "AdminRoleName": "admin",
- "AdminRolePermissions": [
- "*"
- ]
- },
"FastEndpoints": {
"GlobalRoutePrefix": "elsa/api"
},
diff --git a/src/apps/Elsa.Server.Web/README.md b/src/apps/Elsa.Server.Web/README.md
index 89efb3af2..bdbd855f0 100644
--- a/src/apps/Elsa.Server.Web/README.md
+++ b/src/apps/Elsa.Server.Web/README.md
@@ -3,11 +3,8 @@
This project represents an Elsa application that hosts workflows and exposes API endpoints to manage & execute workflows.
## Secrets
-The following are the secrets stored in hashed form in appsettings.json:
-**API key**: `48587230567A646D394B435A6277734A-4802fa49-e91e-45e8-b00f-b5492377e20b`
-**Admin user**: `admin`
-**Admin password**: `password`
+`appsettings.json` does not include production-usable default admin credentials or API keys. Configure initial users and applications through environment-specific configuration or a secret manager.
## OpenTelemetry (MacOS)
@@ -25,4 +22,4 @@ OTEL_DOTNET_AUTO_TRACES_ADDITIONAL_SOURCES=Proto.Actor,Elsa.Workflows
OTEL_DOTNET_AUTO_TRACES_CONSOLE_EXPORTER_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
OTEL_EXPORTER_OTLP_PROTOCOL=grpc
-OTEL_RESOURCE_ATTRIBUTES=service.name=Elsa Server,service.version=3.3.0,service.instance.id=instance-123,deployment.environment=development
\ No newline at end of file
+OTEL_RESOURCE_ATTRIBUTES=service.name=Elsa Server,service.version=3.3.0,service.instance.id=instance-123,deployment.environment=development
diff --git a/src/apps/Elsa.Server.Web/appsettings.Development.json b/src/apps/Elsa.Server.Web/appsettings.Development.json
new file mode 100644
index 000000000..23d236224
--- /dev/null
+++ b/src/apps/Elsa.Server.Web/appsettings.Development.json
@@ -0,0 +1,55 @@
+{
+ "Identity": {
+ "Tokens": {
+ "SigningKey": "development-only-secret-signing-key-change-before-production"
+ },
+ "Users": [
+ {
+ "Id": "a2323f46-42db-4e15-af8b-94238717d817",
+ "Name": "admin",
+ "HashedPassword": "TfKzh9RLix6FPcCNeHLkGrysFu3bYxqzGqduNdi8v1U=",
+ "HashedPasswordSalt": "JEy9kBlhHCNsencitRHlGxmErmSgY+FVyMJulCH27Ds=",
+ "Roles": [
+ "admin"
+ ],
+ "TenantId": ""
+ },
+ {
+ "Id": "b0cd0e506e713a9d",
+ "Name": "alice",
+ "Roles": [
+ "admin"
+ ],
+ "HashedPassword": "8B0fFK/f/kk9GkVtzXfRJ2Y6cNyYVvLTfKouWcAcuPg=",
+ "HashedPasswordSalt": "xlNWvEng8fRvo0McyJopbRJ2MJ9NIYV/4IY5dOZeiiw=",
+ "TenantId": "tenant-2"
+ },
+ {
+ "Id": "789d5056cb3bb8e9",
+ "Name": "bob",
+ "Roles": [
+ "admin"
+ ],
+ "HashedPassword": "GMHHvUw7RfIIBq+GAWZ/AlHR50TbgONYfGQ92Xge0EI=",
+ "HashedPasswordSalt": "V4uHKg1PFBc1bsngCQoptibOu14faj9obSn5B23h1wE=",
+ "TenantId": "tenant-1"
+ }
+ ],
+ "Applications": [
+ {
+ "Id": "d57030226341448daff5a2935aba2d3f",
+ "Name": "Postman",
+ "Roles": [
+ "admin"
+ ],
+ "ClientId": "HXr0Vzdm9KCZbwsJ",
+ "ClientSecret": "a<~QGGHTEA%u4;CU&'Wga5ED:_&Gd1C)",
+ "HashedApiKey": "Z5ClHs3mbzx8Pnw3+PxbMq8A/Y+VKMCCDTGYtax8JFM=",
+ "HashedApiKeySalt": "kBisa1X8FwBfN2zmyGMFRgIVVBleghhQAJ4WGyTkaD0=",
+ "HashedClientSecret": "jEv58d0SVbGQ3nBZM0lkzHghG4Y+lMKW80wipz+9vHk=",
+ "HashedClientSecretSalt": "xRKy14Ok1/tU3kLf/8V1fcbLIegy9vcM90Peu2tzohU=",
+ "TenantId": "default"
+ }
+ ]
+ }
+}
diff --git a/src/apps/Elsa.Server.Web/appsettings.json b/src/apps/Elsa.Server.Web/appsettings.json
index 0010606f2..d997223f0 100644
--- a/src/apps/Elsa.Server.Web/appsettings.json
+++ b/src/apps/Elsa.Server.Web/appsettings.json
@@ -68,7 +68,7 @@
},
"Identity": {
"Tokens": {
- "SigningKey": "sufficiently-large-secret-signing-key",
+ "SigningKey": "CHANGE_ME_TO_A_SECURE_RANDOM_KEY",
"AccessTokenLifetime": "1:00:00:00",
"RefreshTokenLifetime": "7:00:00:00"
},
@@ -81,54 +81,6 @@
],
"TenantId": "default"
}
- ],
- "Users": [
- {
- "Id": "a2323f46-42db-4e15-af8b-94238717d817",
- "Name": "admin",
- "HashedPassword": "TfKzh9RLix6FPcCNeHLkGrysFu3bYxqzGqduNdi8v1U=",
- "HashedPasswordSalt": "JEy9kBlhHCNsencitRHlGxmErmSgY+FVyMJulCH27Ds=",
- "Roles": [
- "admin"
- ],
- "TenantId": ""
- },
- {
- "Id": "b0cd0e506e713a9d",
- "Name": "alice",
- "Roles": [
- "admin"
- ],
- "HashedPassword": "8B0fFK/f/kk9GkVtzXfRJ2Y6cNyYVvLTfKouWcAcuPg=",
- "HashedPasswordSalt": "xlNWvEng8fRvo0McyJopbRJ2MJ9NIYV/4IY5dOZeiiw=",
- "TenantId": "tenant-2"
- },
- {
- "Id": "789d5056cb3bb8e9",
- "Name": "bob",
- "Roles": [
- "admin"
- ],
- "HashedPassword": "GMHHvUw7RfIIBq+GAWZ/AlHR50TbgONYfGQ92Xge0EI=",
- "HashedPasswordSalt": "V4uHKg1PFBc1bsngCQoptibOu14faj9obSn5B23h1wE=",
- "TenantId": "tenant-1"
- }
- ],
- "Applications": [
- {
- "Id": "d57030226341448daff5a2935aba2d3f",
- "Name": "Postman",
- "Roles": [
- "admin"
- ],
- "ClientId": "HXr0Vzdm9KCZbwsJ",
- "ClientSecret": "a<~QGGHTEA%u4;CU&'Wga5ED:_&Gd1C)",
- "HashedApiKey": "Z5ClHs3mbzx8Pnw3+PxbMq8A/Y+VKMCCDTGYtax8JFM=",
- "HashedApiKeySalt": "kBisa1X8FwBfN2zmyGMFRgIVVBleghhQAJ4WGyTkaD0=",
- "HashedClientSecret": "jEv58d0SVbGQ3nBZM0lkzHghG4Y+lMKW80wipz+9vHk=",
- "HashedClientSecretSalt": "xRKy14Ok1/tU3kLf/8V1fcbLIegy9vcM90Peu2tzohU=",
- "TenantId": "default"
- }
]
},
"Scripting": {
diff --git a/src/modules/Elsa.Identity/Features/DefaultAuthenticationFeature.cs b/src/modules/Elsa.Identity/Features/DefaultAuthenticationFeature.cs
index f967ecaaf..6aadbafd8 100644
--- a/src/modules/Elsa.Identity/Features/DefaultAuthenticationFeature.cs
+++ b/src/modules/Elsa.Identity/Features/DefaultAuthenticationFeature.cs
@@ -4,6 +4,7 @@ using Elsa.Features.Abstractions;
using Elsa.Features.Attributes;
using Elsa.Features.Services;
using Elsa.Identity.Constants;
+using Elsa.Identity.Options;
using Elsa.Identity.Providers;
using Elsa.Requirements;
using Microsoft.AspNetCore.Authentication;
@@ -40,15 +41,44 @@ public class DefaultAuthenticationFeature : FeatureBase
/// The current .
public DefaultAuthenticationFeature UseApiKeyAuthorization() where T : class, IApiKeyProvider
{
+ ApiKeyProviderType = typeof(T);
_configureApiKeyAuthorization = builder => builder.AddApiKeyInAuthorizationHeader();
return this;
}
///
- /// Configures the API key provider type to .
+ /// Configures the API key provider type to . The provider denies all keys unless configured.
///
/// The current .
public DefaultAuthenticationFeature UseAdminApiKey() => UseApiKeyAuthorization();
+
+ ///
+ /// Configures the admin API key provider with an explicit API key.
+ ///
+ /// The API key to accept.
+ /// The current .
+ public DefaultAuthenticationFeature UseAdminApiKey(string apiKey)
+ {
+ Services.Configure(options => options.ApiKey = apiKey);
+ return UseAdminApiKey();
+ }
+
+ ///
+ /// Configures the admin API key provider with an explicit API key.
+ ///
+ /// The admin API key options to configure.
+ /// The current .
+ public DefaultAuthenticationFeature UseAdminApiKey(Action configure)
+ {
+ Services.Configure(configure);
+ return UseAdminApiKey();
+ }
+
+ ///
+ /// Enables the all-zero development admin API key. Do not use in production.
+ ///
+ /// The current .
+ public DefaultAuthenticationFeature UseDevelopmentAdminApiKey() => UseAdminApiKey(AdminApiKeyProvider.DevelopmentApiKey);
///
/// Disables the local host requirement for the security root policy.
@@ -64,6 +94,7 @@ public class DefaultAuthenticationFeature : FeatureBase
public override void Apply()
{
Services.ConfigureOptions();
+ Services.Configure(_ => { });
Services.AddIdentityTokenOptionsValidation();
var authBuilder = Services
diff --git a/src/modules/Elsa.Identity/Features/IdentityFeature.cs b/src/modules/Elsa.Identity/Features/IdentityFeature.cs
index 66b8bd735..15a22f69d 100644
--- a/src/modules/Elsa.Identity/Features/IdentityFeature.cs
+++ b/src/modules/Elsa.Identity/Features/IdentityFeature.cs
@@ -102,7 +102,7 @@ public class IdentityFeature : FeatureBase
}
///
- /// Configures the feature to use .
+ /// Configures the feature to use . The provider denies all users unless configured.
///
public void UseAdminUserProvider()
{
@@ -110,6 +110,24 @@ public class IdentityFeature : FeatureBase
RoleProvider = sp => sp.GetRequiredService();
}
+ ///
+ /// Configures the feature to use with an explicit admin user.
+ ///
+ public void UseAdminUserProvider(Action configure)
+ {
+ UseAdminUserProvider();
+ Services.Configure(configure);
+ }
+
+ ///
+ /// Configures the feature to use the development admin user. Do not use in production.
+ ///
+ public void UseDevelopmentAdminUserProvider() => UseAdminUserProvider(options =>
+ {
+ options.UserName = "admin";
+ options.Password = "password";
+ });
+
///
/// Configures the feature to use .
///
@@ -149,6 +167,7 @@ public class IdentityFeature : FeatureBase
{
Services.Configure(TokenOptions);
Services.Configure(ApiKeyDefaults.AuthenticationScheme, ApiKeyOptions);
+ Services.Configure(_ => { });
Services.Configure(UsersOptions);
Services.Configure(ApplicationsOptions);
Services.Configure(RolesOptions);
diff --git a/src/modules/Elsa.Identity/Options/AdminApiKeyOptions.cs b/src/modules/Elsa.Identity/Options/AdminApiKeyOptions.cs
new file mode 100644
index 000000000..21d8b53da
--- /dev/null
+++ b/src/modules/Elsa.Identity/Options/AdminApiKeyOptions.cs
@@ -0,0 +1,22 @@
+namespace Elsa.Identity.Options;
+
+///
+/// Options for the built-in admin API key provider.
+///
+public class AdminApiKeyOptions
+{
+ ///
+ /// Gets or sets the API key to accept. Leave empty to disable the provider.
+ ///
+ public string ApiKey { get; set; } = "";
+
+ ///
+ /// Gets or sets the owner name assigned to the API key identity.
+ ///
+ public string OwnerName { get; set; } = "admin";
+
+ ///
+ /// Gets or sets the permissions assigned to the API key identity.
+ ///
+ public ICollection Permissions { get; set; } = ["*"];
+}
diff --git a/src/modules/Elsa.Identity/Options/AdminUserProviderOptions.cs b/src/modules/Elsa.Identity/Options/AdminUserProviderOptions.cs
new file mode 100644
index 000000000..a0bdb47a7
--- /dev/null
+++ b/src/modules/Elsa.Identity/Options/AdminUserProviderOptions.cs
@@ -0,0 +1,27 @@
+namespace Elsa.Identity.Options;
+
+///
+/// Options for the built-in admin user provider.
+///
+public class AdminUserProviderOptions
+{
+ ///
+ /// Gets or sets the user ID assigned to the configured admin user.
+ ///
+ public string UserId { get; set; } = "admin";
+
+ ///
+ /// Gets or sets the admin user name to accept. Leave empty to disable the provider.
+ ///
+ public string UserName { get; set; } = "";
+
+ ///
+ /// Gets or sets the admin password to accept. Leave empty to disable the provider.
+ ///
+ public string Password { get; set; } = "";
+
+ ///
+ /// Gets or sets the roles assigned to the configured admin user.
+ ///
+ public ICollection Roles { get; set; } = ["admin"];
+}
diff --git a/src/modules/Elsa.Identity/Providers/AdminApiKeyProvider.cs b/src/modules/Elsa.Identity/Providers/AdminApiKeyProvider.cs
index 0fb8a189b..9a4069f4b 100644
--- a/src/modules/Elsa.Identity/Providers/AdminApiKeyProvider.cs
+++ b/src/modules/Elsa.Identity/Providers/AdminApiKeyProvider.cs
@@ -1,27 +1,44 @@
using System.Security.Claims;
using AspNetCore.Authentication.ApiKey;
using Elsa.Identity.Models;
+using Elsa.Identity.Options;
+using Microsoft.Extensions.Options;
namespace Elsa.Identity.Providers;
///
-/// Provides an with admin privileges for the default admin API key.
+/// Provides an with admin privileges for an explicitly configured admin API key.
///
-public class AdminApiKeyProvider : IApiKeyProvider
+public class AdminApiKeyProvider(IOptions options) : IApiKeyProvider
{
///
- /// The default admin API key.
+ /// Initializes a new instance of the class with no accepted API key.
///
- public static readonly string DefaultApiKey = Guid.Empty.ToString();
+ [Obsolete("Use the options-based constructor. The built-in admin API key is disabled unless explicitly configured.")]
+ public AdminApiKeyProvider() : this(Microsoft.Extensions.Options.Options.Create(new AdminApiKeyOptions()))
+ {
+ }
+
+ ///
+ /// The all-zero development admin API key. Do not enable in production.
+ ///
+ public static readonly string DevelopmentApiKey = Guid.Empty.ToString();
+
+ ///
+ /// The legacy development admin API key.
+ ///
+ [Obsolete("Use DevelopmentApiKey. The built-in admin API key is disabled unless explicitly configured.")]
+ public static readonly string DefaultApiKey = DevelopmentApiKey;
///
public Task ProvideAsync(string key)
{
- if(key != DefaultApiKey)
+ var apiKeyOptions = options.Value;
+ if (string.IsNullOrWhiteSpace(apiKeyOptions.ApiKey) || key != apiKeyOptions.ApiKey)
return Task.FromResult(null);
- var claims = new List { new("permissions", "*") };
- var apiKey = new ApiKey(key, "admin", claims);
+ var claims = apiKeyOptions.Permissions.Select(permission => new Claim("permissions", permission)).ToList();
+ var apiKey = new ApiKey(key, apiKeyOptions.OwnerName, claims);
return Task.FromResult(apiKey)!;
}
-}
\ No newline at end of file
+}
diff --git a/src/modules/Elsa.Identity/Providers/AdminUserProvider.cs b/src/modules/Elsa.Identity/Providers/AdminUserProvider.cs
index a1721c945..46d21432a 100644
--- a/src/modules/Elsa.Identity/Providers/AdminUserProvider.cs
+++ b/src/modules/Elsa.Identity/Providers/AdminUserProvider.cs
@@ -1,36 +1,59 @@
using Elsa.Identity.Contracts;
using Elsa.Identity.Entities;
using Elsa.Identity.Models;
+using Elsa.Identity.Options;
+using Microsoft.Extensions.Options;
namespace Elsa.Identity.Providers;
///
-/// Represents a user provider that always returns a single admin user. This is useful for development purposes.
+/// Represents a user provider that returns a single explicitly configured admin user. This is useful for development purposes.
///
public class AdminUserProvider : IUserProvider
{
- private readonly User _adminUser;
+ private readonly User? _adminUser;
///
/// Initializes a new instance of the class.
///
- public AdminUserProvider(ISecretHasher secretHasher)
+ [Obsolete("Use the options-based constructor. The built-in admin user is disabled unless explicitly configured.")]
+ public AdminUserProvider(ISecretHasher secretHasher) : this(secretHasher, Microsoft.Extensions.Options.Options.Create(new AdminUserProviderOptions()))
{
- var hashedSecret = secretHasher.HashSecret("password");
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public AdminUserProvider(ISecretHasher secretHasher, IOptions options)
+ {
+ var providerOptions = options.Value;
+ if (string.IsNullOrWhiteSpace(providerOptions.UserName) || string.IsNullOrWhiteSpace(providerOptions.Password))
+ return;
+
+ var hashedSecret = secretHasher.HashSecret(providerOptions.Password);
_adminUser = new User
{
- Id = "admin",
- Name = "admin",
+ Id = providerOptions.UserId,
+ Name = providerOptions.UserName,
HashedPassword = hashedSecret.EncodeSecret(),
HashedPasswordSalt = hashedSecret.EncodeSalt(),
- Roles = { "admin" }
+ Roles = providerOptions.Roles.ToList()
};
}
///
public Task FindAsync(UserFilter filter, CancellationToken cancellationToken = default)
{
- return Task.FromResult(_adminUser)!;
+ if (_adminUser == null)
+ return Task.FromResult(null);
+
+ if (filter.Id != null && filter.Id != _adminUser.Id)
+ return Task.FromResult(null);
+
+ if (filter.Name != null && filter.Name != _adminUser.Name)
+ return Task.FromResult(null);
+
+ return Task.FromResult(_adminUser);
}
-}
\ No newline at end of file
+}
diff --git a/src/modules/Elsa.Identity/README.md b/src/modules/Elsa.Identity/README.md
index 5fec28bb4..0f3a3acb9 100644
--- a/src/modules/Elsa.Identity/README.md
+++ b/src/modules/Elsa.Identity/README.md
@@ -27,7 +27,7 @@ Example (`appsettings.json`):
"DefaultAuthentication": {},
"DefaultAdminUser": {
"AdminUserName": "admin",
- "AdminPassword": "password",
+ "AdminPassword": "REPLACE_WITH_SECURE_BOOTSTRAP_PASSWORD",
"AdminRoleName": "admin",
"AdminRolePermissions": ["*"]
}
@@ -57,7 +57,7 @@ services.AddElsa(elsa =>
identity.UseDefaultAdmin(admin => admin
.WithAdminUserName("admin")
- .WithAdminPassword("password")
+ .WithAdminPassword("REPLACE_WITH_SECURE_BOOTSTRAP_PASSWORD")
.WithAdminRoleName("admin")
.WithAdminRolePermissions(new List { "*" }));
})
@@ -68,13 +68,12 @@ services.AddElsa(elsa =>
You can also use the shorthand overload:
```csharp
-identity.UseDefaultAdmin("admin", "password", "admin", new List { "*" });
+identity.UseDefaultAdmin("admin", "REPLACE_WITH_SECURE_BOOTSTRAP_PASSWORD", "admin", new List { "*" });
```
### Operational notes
- The initializer is idempotent: existing admin role/user are not recreated.
-- Do not keep development defaults (`admin` / `password`) in production.
+- Do not keep development defaults in production.
- Prefer environment variables or a secret manager for admin credentials.
- After first bootstrap, rotate credentials according to your security policy.
-
diff --git a/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs b/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs
index f7f198c10..51f892a61 100644
--- a/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs
+++ b/src/modules/Elsa.Identity/ShellFeatures/DefaultAuthenticationFeature.cs
@@ -2,7 +2,9 @@ using AspNetCore.Authentication.ApiKey;
using CShells.Features;
using Elsa.Extensions;
using Elsa.Identity.Constants;
+using Elsa.Identity.Options;
using Elsa.Identity.Providers;
+using Elsa.PackageManifest.Generator.Hints;
using Elsa.Requirements;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -28,10 +30,41 @@ public class DefaultAuthenticationFeature : IShellFeature
///
public Type ApiKeyProviderType { get; set; } = typeof(DefaultApiKeyProvider);
+ ///
+ /// Gets or sets an explicit API key for . Leave empty to disable the provider.
+ ///
+ [ManifestSetting(
+ DisplayName = "Admin API Key",
+ Description = "Explicit API key for the admin API key provider. Leave empty to disable built-in admin API key authentication.",
+ Category = "Security",
+ Secret = true,
+ Sensitive = true,
+ RestartRequired = true)]
+ public string AdminApiKey { get; set; } = "";
+
+ ///
+ /// Gets or sets whether the all-zero development admin API key should be enabled. Do not enable in production.
+ ///
+ [ManifestSetting(
+ DisplayName = "Use Development Admin API Key",
+ Description = "Enables the all-zero development admin API key. Do not enable in production.",
+ Category = "Security",
+ DefaultValue = "false",
+ RestartRequired = true)]
+ public bool UseDevelopmentAdminApiKey { get; set; }
+
public void ConfigureServices(IServiceCollection services)
{
+ var resolvedAdminApiKey = UseDevelopmentAdminApiKey ? AdminApiKeyProvider.DevelopmentApiKey : AdminApiKey;
+ if (!string.IsNullOrWhiteSpace(resolvedAdminApiKey))
+ ApiKeyProviderType = typeof(AdminApiKeyProvider);
+
services.ConfigureOptions();
services.AddIdentityTokenOptionsValidation();
+ services.Configure(options =>
+ {
+ options.ApiKey = resolvedAdminApiKey;
+ });
var authBuilder = services
.AddAuthentication(MultiScheme)
diff --git a/src/modules/Elsa.Identity/ShellFeatures/IdentityFeature.cs b/src/modules/Elsa.Identity/ShellFeatures/IdentityFeature.cs
index 3aff621c5..d1e8bb354 100644
--- a/src/modules/Elsa.Identity/ShellFeatures/IdentityFeature.cs
+++ b/src/modules/Elsa.Identity/ShellFeatures/IdentityFeature.cs
@@ -34,6 +34,7 @@ public class IdentityFeature : IFastEndpointsShellFeature
options.Realm = "Elsa Workflows";
options.KeyName = "ApiKey";
});
+ services.Configure(_ => { });
services.Configure(_ => { });
services.Configure(_ => { });
services.Configure(_ => { });
diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs
index 04ae1027f..05b9ee254 100644
--- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs
+++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs
@@ -74,7 +74,7 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl
elsa.AddWorkflowsFrom();
elsa.AddActivitiesFrom();
elsa.AddActivityHost();
- elsa.UseDefaultAuthentication(defaultAuthentication => defaultAuthentication.UseAdminApiKey());
+ elsa.UseDefaultAuthentication(defaultAuthentication => defaultAuthentication.UseDevelopmentAdminApiKey());
elsa.UseFluentStorageProvider(sp =>
{
var assemblyLocation = Assembly.GetExecutingAssembly().Location;
@@ -177,6 +177,6 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl
protected override void ConfigureClient(HttpClient client)
{
- client.DefaultRequestHeaders.Authorization = new("ApiKey", AdminApiKeyProvider.DefaultApiKey);
+ client.DefaultRequestHeaders.Authorization = new("ApiKey", AdminApiKeyProvider.DevelopmentApiKey);
}
-}
\ No newline at end of file
+}
diff --git a/test/unit/Elsa.Identity.UnitTests/Providers/AdminCredentialProviderTests.cs b/test/unit/Elsa.Identity.UnitTests/Providers/AdminCredentialProviderTests.cs
new file mode 100644
index 000000000..e966b1f04
--- /dev/null
+++ b/test/unit/Elsa.Identity.UnitTests/Providers/AdminCredentialProviderTests.cs
@@ -0,0 +1,91 @@
+using Elsa.Identity.Options;
+using Elsa.Identity.Providers;
+using Elsa.Identity.Services;
+using OptionsFactory = Microsoft.Extensions.Options.Options;
+
+namespace Elsa.Identity.UnitTests.Providers;
+
+public class AdminCredentialProviderTests
+{
+ private readonly DefaultSecretHasher _secretHasher = new();
+
+ [Fact]
+ public async Task AdminApiKeyProviderDeniesDevelopmentApiKeyByDefault()
+ {
+ var provider = CreateAdminApiKeyProvider();
+
+ var apiKey = await provider.ProvideAsync(AdminApiKeyProvider.DevelopmentApiKey);
+
+ Assert.Null(apiKey);
+ }
+
+ [Fact]
+ public async Task AdminApiKeyProviderAcceptsDevelopmentApiKeyWhenExplicitlyConfigured()
+ {
+ var provider = CreateAdminApiKeyProvider(options => options.ApiKey = AdminApiKeyProvider.DevelopmentApiKey);
+
+ var apiKey = await provider.ProvideAsync(AdminApiKeyProvider.DevelopmentApiKey);
+
+ Assert.NotNull(apiKey);
+ Assert.Equal("admin", apiKey.OwnerName);
+ Assert.Contains(apiKey.Claims, claim => claim.Type == "permissions" && claim.Value == "*");
+ }
+
+ [Theory]
+ [InlineData("admin")]
+ [InlineData("anyone")]
+ public async Task AdminUserProviderDeniesStaticPasswordByDefault(string userName)
+ {
+ var validator = CreateCredentialsValidator();
+
+ var user = await validator.ValidateAsync(userName, "password");
+
+ Assert.Null(user);
+ }
+
+ [Fact]
+ public async Task AdminUserProviderAcceptsDevelopmentCredentialsWhenExplicitlyConfigured()
+ {
+ var validator = CreateCredentialsValidator(options =>
+ {
+ options.UserName = "admin";
+ options.Password = "password";
+ });
+
+ var user = await validator.ValidateAsync("admin", "password");
+
+ Assert.NotNull(user);
+ Assert.Equal("admin", user.Name);
+ }
+
+ [Fact]
+ public async Task AdminUserProviderDeniesArbitraryUsernameWhenDevelopmentCredentialsAreConfigured()
+ {
+ var validator = CreateCredentialsValidator(options =>
+ {
+ options.UserName = "admin";
+ options.Password = "password";
+ });
+
+ var user = await validator.ValidateAsync("anyone", "password");
+
+ Assert.Null(user);
+ }
+
+ private static AdminApiKeyProvider CreateAdminApiKeyProvider(Action? configure = null)
+ {
+ var options = new AdminApiKeyOptions();
+ configure?.Invoke(options);
+
+ return new(OptionsFactory.Create(options));
+ }
+
+ private DefaultUserCredentialsValidator CreateCredentialsValidator(Action? configure = null)
+ {
+ var options = new AdminUserProviderOptions();
+ configure?.Invoke(options);
+
+ var userProvider = new AdminUserProvider(_secretHasher, OptionsFactory.Create(options));
+ return new(userProvider, _secretHasher);
+ }
+}
diff --git a/test/unit/Elsa.Identity.UnitTests/ShellFeatures/DefaultAuthenticationFeatureTests.cs b/test/unit/Elsa.Identity.UnitTests/ShellFeatures/DefaultAuthenticationFeatureTests.cs
new file mode 100644
index 000000000..bc2c17a01
--- /dev/null
+++ b/test/unit/Elsa.Identity.UnitTests/ShellFeatures/DefaultAuthenticationFeatureTests.cs
@@ -0,0 +1,57 @@
+using AspNetCore.Authentication.ApiKey;
+using Elsa.Identity.Options;
+using Elsa.Identity.Providers;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using ShellDefaultAuthenticationFeature = Elsa.Identity.ShellFeatures.DefaultAuthenticationFeature;
+
+namespace Elsa.Identity.UnitTests.ShellFeatures;
+
+public class DefaultAuthenticationFeatureTests
+{
+ private readonly ShellDefaultAuthenticationFeature _feature = new();
+ private readonly ServiceCollection _services = new();
+
+ [Fact]
+ public void UsesDefaultApiKeyProviderWhenAdminApiKeyIsNotConfigured()
+ {
+ using var serviceProvider = Activate();
+
+ Assert.Equal(typeof(DefaultApiKeyProvider), _feature.ApiKeyProviderType);
+ Assert.Equal(string.Empty, serviceProvider.GetRequiredService>().Value.ApiKey);
+ }
+
+ [Fact]
+ public async Task UsesAdminApiKeyProviderWhenAdminApiKeyIsConfigured()
+ {
+ _feature.AdminApiKey = "configured-admin-api-key";
+
+ using var serviceProvider = Activate();
+
+ var provider = Assert.IsType(serviceProvider.GetRequiredService());
+ var apiKey = await provider.ProvideAsync(_feature.AdminApiKey);
+
+ Assert.Equal(typeof(AdminApiKeyProvider), _feature.ApiKeyProviderType);
+ Assert.NotNull(apiKey);
+ }
+
+ [Fact]
+ public async Task UsesAdminApiKeyProviderWhenDevelopmentAdminApiKeyIsEnabled()
+ {
+ _feature.UseDevelopmentAdminApiKey = true;
+
+ using var serviceProvider = Activate();
+
+ var provider = Assert.IsType(serviceProvider.GetRequiredService());
+ var apiKey = await provider.ProvideAsync(AdminApiKeyProvider.DevelopmentApiKey);
+
+ Assert.Equal(typeof(AdminApiKeyProvider), _feature.ApiKeyProviderType);
+ Assert.NotNull(apiKey);
+ }
+
+ private ServiceProvider Activate()
+ {
+ _feature.ConfigureServices(_services);
+ return _services.BuildServiceProvider();
+ }
+}