[codex] Fail fast on default JWT signing keys (#7496)
* Fail fast on default JWT signing keys * Address JWT signing key review feedback * Refine JWT signing key validation feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com> * Reject JWT signing keys with surrounding whitespace --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sfmskywalker <938393+sfmskywalker@users.noreply.github.com>
This commit is contained in:
parent
5080bb7007
commit
e9d59bc5b1
|
|
@ -34,6 +34,8 @@ docker run -t -i -e ASPNETCORE_ENVIRONMENT='Development' -e HTTP_PORTS=8080 -e H
|
|||
|
||||
> This Docker image is based on a reference ASP.NET application that hosts both the workflow server and designer and is not intended for production use.
|
||||
|
||||
For any non-development deployment, inject a secure random JWT signing key through environment variables or a secrets manager instead of using committed appsettings values. For code-first hosts such as `Elsa.Server.Web`, set `Identity__Tokens__SigningKey`. For shell-based hosts, set the shell feature key, for example `CShells__Shells__Default__Features__Identity__SigningKey`.
|
||||
|
||||
By default, you can access http://localhost:13000 and log in with:
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ Compare: [`3.5.3...3.6.0`](https://github.com/elsa-workflows/elsa-core/compare/3
|
|||
|
||||
## ⚠️ Breaking changes / upgrade notes
|
||||
|
||||
- **JWT signing key validation**: Hosts using Elsa Identity now fail startup when the JWT signing key is missing, shorter than 32 ASCII characters, or set to a known public default outside the explicit `Development` or `Demo` environments. Replace committed/sample values with a secure random key from environment variables or a secrets manager, such as `Identity__Tokens__SigningKey` for code-first hosts or the equivalent shell feature configuration key. ([#7476](https://github.com/elsa-workflows/elsa-core/issues/7476))
|
||||
|
||||
- **EF Core package names have changed**: EF Core persistence packages were renamed from `Elsa.EntityFrameworkCore.*` to `Elsa.Persistence.EFCore.*`. If your application references any of the old package names, you must update them to the new package names when upgrading to 3.6.0. Be sure to review your project files, internal package feeds, CI pipelines, and deployment manifests for old package references.
|
||||
|
||||
- **Database migrations required (EF Core — all providers)**: `ActivityNodeId` columns in `ActivityExecutionRecords` and `WorkflowExecutionLogRecords` have been widened to unlimited types (`nvarchar(max)` / `longtext` / `NCLOB`) to support deeply nested workflows. The corresponding B-tree indexes (`IX_ActivityExecutionRecord_ActivityNodeId`, `IX_WorkflowExecutionLogRecord_ActivityNodeId`) are dropped as part of the V3_6 migrations. Run EF Core migrations before upgrading any SQL Server, MySQL, or Oracle deployment to 3.6.0. ([71438596f3](https://github.com/elsa-workflows/elsa-core/commit/71438596f3)) ([#7338](https://github.com/elsa-workflows/elsa-core/pull/7338))
|
||||
|
|
|
|||
|
|
@ -76,6 +76,8 @@ Notable toggles in `Program.cs`:
|
|||
|
||||
The sample configures identity, default authentication, workflow management/runtime with SQLite, workflow API, fluent storage, ElsaScript blob storage, scheduling, C#, JavaScript, Python, Liquid, HTTP, and optional tenants/structured logs.
|
||||
|
||||
When running outside the explicit `Development` or `Demo` environments, configure a secure random JWT signing key with at least 32 ASCII characters. For the code-first reference server, prefer `Identity__Tokens__SigningKey` from an environment variable or secrets manager instead of committing the value to appsettings. Shell-based hosts use the shell feature path, for example `CShells__Shells__Default__Features__Identity__SigningKey`.
|
||||
|
||||
## Docker Quick Try
|
||||
|
||||
The root [README](../../README.md) documents the public Docker quick start:
|
||||
|
|
@ -85,6 +87,8 @@ 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
|
||||
```
|
||||
|
||||
Production containers must inject a secure JWT signing key through environment variables or a secrets manager. The appsettings placeholder and known public sample keys are rejected during startup outside `Development` or `Demo`.
|
||||
|
||||
Default development login is available only when a development configuration explicitly provisions it:
|
||||
|
||||
```text
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ See [src/apps/Elsa.Server.Web/Program.cs](../../src/apps/Elsa.Server.Web/Program
|
|||
|
||||
Identity JWTs include a `token_use` claim. API bearer authentication accepts only access tokens (`token_use=access`), while `/identity/refresh-token` uses a dedicated refresh-token bearer scheme and accepts only refresh tokens (`token_use=refresh`). Clients should not send refresh tokens to normal API endpoints or access tokens to the refresh endpoint.
|
||||
|
||||
JWT signing keys must be configured with a secure random value before production startup. Missing keys, weak keys shorter than 32 ASCII characters, and known public defaults are rejected by options startup validation. Known public defaults are only tolerated in the explicit `Development` or `Demo` environments for local/demo hosts. Use environment variables or a secrets manager, such as `Identity__Tokens__SigningKey` for code-first hosts or `CShells__Shells__Default__Features__Identity__SigningKey` for shell-based hosts.
|
||||
|
||||
## Default Admin Bootstrap
|
||||
|
||||
The default admin bootstrap is documented in [src/modules/Elsa.Identity/README.md](../../src/modules/Elsa.Identity/README.md) and [ADR 0010](../adr/0010-default-admin-user-bootstrap-for-initial-identity-access.md).
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Elsa.Identity.Options;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
// ReSharper disable once CheckNamespace
|
||||
|
|
@ -9,12 +10,64 @@ namespace Elsa.Extensions;
|
|||
/// </summary>
|
||||
public class ValidateIdentityTokenOptions : IValidateOptions<IdentityTokenOptions>
|
||||
{
|
||||
private const int MinimumSigningKeyByteLength = 32;
|
||||
private const string DemoEnvironmentName = "Demo";
|
||||
|
||||
private static readonly HashSet<string> KnownDefaultSigningKeys = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"sufficiently-large-secret-signing-key",
|
||||
"CHANGE_ME_TO_A_SECURE_RANDOM_KEY"
|
||||
};
|
||||
|
||||
private readonly IHostEnvironment? _environment;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ValidateIdentityTokenOptions"/> class.
|
||||
/// </summary>
|
||||
public ValidateIdentityTokenOptions()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ValidateIdentityTokenOptions"/> class.
|
||||
/// </summary>
|
||||
public ValidateIdentityTokenOptions(IHostEnvironment environment)
|
||||
{
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValidateOptionsResult Validate(string? name, IdentityTokenOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.SigningKey))
|
||||
return ValidateOptionsResult.Fail("SigningKey is required");
|
||||
return ValidateOptionsResult.Fail("SigningKey is required. Configure a secure random JWT signing key through configuration, environment variables, or a secrets manager.");
|
||||
|
||||
var signingKey = options.SigningKey.Trim();
|
||||
|
||||
if (!string.Equals(signingKey, options.SigningKey, StringComparison.Ordinal))
|
||||
return ValidateOptionsResult.Fail("SigningKey must not contain leading or trailing whitespace. Configure the exact secure random JWT signing key through configuration, environment variables, or a secrets manager.");
|
||||
|
||||
if (KnownDefaultSigningKeys.Contains(signingKey))
|
||||
{
|
||||
if (!IsDemoOrDevelopment())
|
||||
return ValidateOptionsResult.Fail("SigningKey uses a known public default value. Replace it with a secure random JWT signing key through configuration, environment variables, or a secrets manager. Known defaults are allowed only in the Development or Demo environment.");
|
||||
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
|
||||
if (!IsPrintableAscii(signingKey))
|
||||
return ValidateOptionsResult.Fail("SigningKey contains non-printable or non-ASCII characters. Configure a secure random JWT signing key using only printable ASCII characters (0x20-0x7E) through configuration, environment variables, or a secrets manager.");
|
||||
|
||||
if (signingKey.Length < MinimumSigningKeyByteLength)
|
||||
return ValidateOptionsResult.Fail($"SigningKey must be at least {MinimumSigningKeyByteLength} ASCII characters long. Configure a secure random JWT signing key through configuration, environment variables, or a secrets manager.");
|
||||
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsDemoOrDevelopment()
|
||||
{
|
||||
return _environment is not null && (_environment.IsDevelopment() || string.Equals(_environment.EnvironmentName, DemoEnvironmentName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool IsPrintableAscii(string value) => value.All(x => x is >= ' ' and <= '~');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
# Elsa.Identity
|
||||
|
||||
## JWT Signing Key Configuration
|
||||
|
||||
Identity token signing requires a secure random key. Configure it through environment variables or a secrets manager and keep it out of committed appsettings files.
|
||||
|
||||
- Code-first hosts using `Identity:Tokens` should set `Identity__Tokens__SigningKey`.
|
||||
- Shell-based hosts should set the shell feature path, for example `CShells__Shells__Default__Features__Identity__SigningKey`.
|
||||
- Production startup rejects missing keys, keys shorter than 32 ASCII characters, and known public defaults. Known public defaults are tolerated only in the explicit `Development` or `Demo` environments.
|
||||
|
||||
## Default Admin User Bootstrap
|
||||
|
||||
Elsa supports bootstrapping an initial admin role and user through the `DefaultAdminUser` feature.
|
||||
|
|
@ -21,9 +29,7 @@ Example (`appsettings.json`):
|
|||
{
|
||||
"Name": "Default",
|
||||
"Features": {
|
||||
"Identity": {
|
||||
"SigningKey": "CHANGE_ME_TO_A_SECURE_RANDOM_KEY"
|
||||
},
|
||||
"Identity": {},
|
||||
"DefaultAuthentication": {},
|
||||
"DefaultAdminUser": {
|
||||
"AdminUserName": "admin",
|
||||
|
|
@ -52,7 +58,7 @@ services.AddElsa(elsa =>
|
|||
{
|
||||
identity.TokenOptions += options =>
|
||||
{
|
||||
options.SigningKey = "CHANGE_ME_TO_A_SECURE_RANDOM_KEY";
|
||||
options.SigningKey = builder.Configuration.GetRequiredSection("Identity:Tokens")["SigningKey"]!;
|
||||
};
|
||||
|
||||
identity.UseDefaultAdmin(admin => admin
|
||||
|
|
|
|||
|
|
@ -1,56 +1,120 @@
|
|||
using Elsa.Identity.Options;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Elsa.Identity.UnitTests.Options;
|
||||
|
||||
public class IdentityTokenOptionsValidationTests
|
||||
{
|
||||
public static TheoryData<Action<IdentityTokenOptions>> InvalidConfigurations => new()
|
||||
private const string SecureSigningKey = "test-signing-key-with-at-least-32-characters";
|
||||
|
||||
public static TheoryData<Action<IdentityTokenOptions>, string> InvalidConfigurations => new()
|
||||
{
|
||||
options => options.SigningKey = string.Empty,
|
||||
options => options.SigningKey = " ",
|
||||
{ options => options.SigningKey = string.Empty, "SigningKey is required" },
|
||||
{ options => options.SigningKey = " ", "SigningKey is required" },
|
||||
{ options => options.SigningKey = $" {SecureSigningKey}", "must not contain leading or trailing whitespace" },
|
||||
{ options => options.SigningKey = $"{SecureSigningKey} ", "must not contain leading or trailing whitespace" },
|
||||
{ options => options.SigningKey = "short-signing-key", "at least 32 ASCII characters" },
|
||||
{ options => options.SigningKey = new string('é', 32), "non-printable or non-ASCII characters" },
|
||||
{ options => options.SigningKey = "sufficiently-large-secret-signing-key", "known public default" },
|
||||
{ options => options.SigningKey = "CHANGE_ME_TO_A_SECURE_RANDOM_KEY", "known public default" },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void AcceptsConfiguredSigningKey()
|
||||
{
|
||||
using var serviceProvider = CreateServiceProvider(options => options.SigningKey = "test-signing-key");
|
||||
using var serviceProvider = CreateServiceProvider(options => options.SigningKey = SecureSigningKey);
|
||||
|
||||
var options = serviceProvider.GetRequiredService<IOptions<IdentityTokenOptions>>().Value;
|
||||
|
||||
Assert.Equal("test-signing-key", options.SigningKey);
|
||||
Assert.Equal(SecureSigningKey, options.SigningKey);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Development")]
|
||||
[InlineData("Demo")]
|
||||
public void AcceptsKnownDefaultSigningKeyInExplicitDemoOrDevelopmentMode(string environmentName)
|
||||
{
|
||||
using var serviceProvider = CreateServiceProvider(
|
||||
options => options.SigningKey = "CHANGE_ME_TO_A_SECURE_RANDOM_KEY",
|
||||
environmentName);
|
||||
|
||||
var options = serviceProvider.GetRequiredService<IOptions<IdentityTokenOptions>>().Value;
|
||||
|
||||
Assert.Equal("CHANGE_ME_TO_A_SECURE_RANDOM_KEY", options.SigningKey);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("sufficiently-large-secret-signing-key")]
|
||||
[InlineData("CHANGE_ME_TO_A_SECURE_RANDOM_KEY")]
|
||||
public void RejectsKnownDefaultSigningKeyInExplicitProductionMode(string knownDefaultKey)
|
||||
{
|
||||
using var serviceProvider = CreateServiceProvider(
|
||||
options => options.SigningKey = knownDefaultKey,
|
||||
"Production");
|
||||
|
||||
var exception = Assert.Throws<OptionsValidationException>(() => _ = serviceProvider.GetRequiredService<IOptions<IdentityTokenOptions>>().Value);
|
||||
|
||||
Assert.Contains(exception.Failures, failure => failure.Contains("known public default"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("sufficiently-large-secret-signing-key")]
|
||||
[InlineData("CHANGE_ME_TO_A_SECURE_RANDOM_KEY")]
|
||||
public void RejectsKnownDefaultSigningKeyDuringStartupValidationInExplicitProductionMode(string knownDefaultKey)
|
||||
{
|
||||
using var serviceProvider = CreateServiceProvider(
|
||||
options => options.SigningKey = knownDefaultKey,
|
||||
"Production");
|
||||
|
||||
var startupValidator = serviceProvider.GetRequiredService<IStartupValidator>();
|
||||
var exception = Assert.Throws<OptionsValidationException>(startupValidator.Validate);
|
||||
|
||||
Assert.Contains(exception.Failures, failure => failure.Contains("known public default"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidConfigurations))]
|
||||
public void RejectsMissingSigningKey(Action<IdentityTokenOptions> configure)
|
||||
public void RejectsInvalidSigningKey(Action<IdentityTokenOptions> configure, string expectedFailure)
|
||||
{
|
||||
using var serviceProvider = CreateServiceProvider(configure);
|
||||
|
||||
var exception = Assert.Throws<OptionsValidationException>(() => _ = serviceProvider.GetRequiredService<IOptions<IdentityTokenOptions>>().Value);
|
||||
|
||||
Assert.Contains("SigningKey is required", exception.Failures);
|
||||
Assert.Contains(exception.Failures, failure => failure.Contains(expectedFailure));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidConfigurations))]
|
||||
public void RejectsMissingSigningKeyDuringStartupValidation(Action<IdentityTokenOptions> configure)
|
||||
public void RejectsInvalidSigningKeyDuringStartupValidation(Action<IdentityTokenOptions> configure, string expectedFailure)
|
||||
{
|
||||
using var serviceProvider = CreateServiceProvider(configure);
|
||||
|
||||
var startupValidator = serviceProvider.GetRequiredService<IStartupValidator>();
|
||||
var exception = Assert.Throws<OptionsValidationException>(startupValidator.Validate);
|
||||
|
||||
Assert.Contains("SigningKey is required", exception.Failures);
|
||||
Assert.Contains(exception.Failures, failure => failure.Contains(expectedFailure));
|
||||
}
|
||||
|
||||
private static ServiceProvider CreateServiceProvider(Action<IdentityTokenOptions>? configure = null)
|
||||
private static ServiceProvider CreateServiceProvider(Action<IdentityTokenOptions>? configure = null, string? environmentName = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
if (environmentName is not null)
|
||||
services.AddSingleton<IHostEnvironment>(new TestHostEnvironment(environmentName));
|
||||
|
||||
services.AddIdentityTokenOptionsValidation();
|
||||
services.Configure(configure ?? (_ => { }));
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
}
|
||||
|
||||
private class TestHostEnvironment(string environmentName) : IHostEnvironment
|
||||
{
|
||||
public string EnvironmentName { get; set; } = environmentName;
|
||||
public string ApplicationName { get; set; } = nameof(IdentityTokenOptionsValidationTests);
|
||||
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
|
||||
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue