diff --git a/src/Bifrost.Web/Configuration/HeimdallOptions.cs b/src/Bifrost.Web/Configuration/HeimdallOptions.cs
index 01eaec9..2dda782 100644
--- a/src/Bifrost.Web/Configuration/HeimdallOptions.cs
+++ b/src/Bifrost.Web/Configuration/HeimdallOptions.cs
@@ -14,7 +14,11 @@ public sealed class HeimdallOptions
public string PatreonTierTitle { get; init; } = "Inner Sanctum";
- public string AppSecret { get; init; } = string.Empty;
+ /// Deprecated: rides in the process environment. Use ; the file wins when both are set.
+ public string AppSecret { get; set; } = string.Empty;
+
+ /// Path of a file holding the app secret (systemd LoadCredential style: %d/heimdall-app-secret).
+ public string AppSecretFile { get; init; } = string.Empty;
public string PatronSupportIntakeSecret { get; init; } = string.Empty;
@@ -39,4 +43,37 @@ public sealed class HeimdallOptions
IsConfigured &&
EnablePatronSupportIntake &&
!string.IsNullOrWhiteSpace(PatronSupportIntakeSecret);
+
+ ///
+ /// Resolves once, when the options are built. A set must be a
+ /// readable, non-empty file, or startup fails; the message names the option and path, never a secret.
+ ///
+ public static void ResolveAppSecret(HeimdallOptions options, ILogger logger)
+ {
+ if (!string.IsNullOrWhiteSpace(options.AppSecret))
+ {
+ logger.LogWarning("Heimdall:AppSecret is deprecated: it rides in the process environment. Use Heimdall:AppSecretFile, which wins when both are set.");
+ }
+
+ if (!string.IsNullOrWhiteSpace(options.AppSecretFile))
+ {
+ string secret;
+ try
+ {
+ secret = File.ReadAllText(options.AppSecretFile).TrimEnd('\r', '\n');
+ }
+ catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
+ {
+ throw new InvalidOperationException(
+ $"Heimdall:AppSecretFile must name a readable file; '{options.AppSecretFile}' could not be read ({error.GetType().Name}).");
+ }
+
+ if (string.IsNullOrWhiteSpace(secret))
+ {
+ throw new InvalidOperationException($"Heimdall:AppSecretFile '{options.AppSecretFile}' is empty.");
+ }
+
+ options.AppSecret = secret;
+ }
+ }
}
diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs
index a5d0752..2ab16cf 100644
--- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs
+++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs
@@ -22,6 +22,9 @@ public sealed class StartupConfigurationValidator(
{
var failures = new List();
+ // Building the options resolves Heimdall:AppSecretFile, and a bad file stops startup in every environment.
+ _ = heimdallOptions.Value;
+
// Checked in every environment: a bad value here would otherwise pass startup and throw on every cast.
var conceptVotes = conceptVoteOptions.Value;
if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits))
diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs
index c7e4587..be3df54 100644
--- a/src/Bifrost.Web/Program.cs
+++ b/src/Bifrost.Web/Program.cs
@@ -64,7 +64,8 @@ builder.Services
builder.Services
.AddOptions()
- .BindConfiguration(HeimdallOptions.SectionName);
+ .BindConfiguration(HeimdallOptions.SectionName)
+ .PostConfigure>(HeimdallOptions.ResolveAppSecret);
builder.Services
.AddOptions()
diff --git a/src/Bifrost.Web/appsettings.json b/src/Bifrost.Web/appsettings.json
index 47bbb7b..09b3310 100644
--- a/src/Bifrost.Web/appsettings.json
+++ b/src/Bifrost.Web/appsettings.json
@@ -28,6 +28,7 @@
"BaseUrl": "https://heimdall.gamecult.org",
"AppSlug": "bifrost",
"AppSecret": "",
+ "AppSecretFile": "",
"DiscordGuildId": "",
"DiscordAllowedRoleIds": [],
"PatreonTierTitle": "Inner Sanctum",
diff --git a/tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs b/tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs
new file mode 100644
index 0000000..5e3bca6
--- /dev/null
+++ b/tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs
@@ -0,0 +1,157 @@
+using Bifrost.Web.Configuration;
+using Bifrost.Web.Tests.Support;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Bifrost.Web.Tests;
+
+public sealed class HeimdallAppSecretFileTests : IDisposable
+{
+ private const string FileSecret = "file-secret-7f3a91c2";
+ private const string EnvSecret = "env-secret-0b5d44e8";
+
+ private readonly string _directory = Directory.CreateTempSubdirectory("bifrost-appsecret-").FullName;
+ private readonly CapturingLoggerProvider _logs = new();
+
+ public void Dispose() => Directory.Delete(_directory, recursive: true);
+
+ private string Write(string content)
+ {
+ var path = Path.Combine(_directory, Guid.NewGuid().ToString("N"));
+ File.WriteAllText(path, content);
+ return path;
+ }
+
+ private HeimdallOptions Resolve(string appSecret = "", string appSecretFile = "")
+ {
+ var options = new HeimdallOptions { AppSecret = appSecret, AppSecretFile = appSecretFile };
+ HeimdallOptions.ResolveAppSecret(options, _logs.CreateLogger("test"));
+ return options;
+ }
+
+ private void AssertNoSecretLogged()
+ {
+ foreach (var line in _logs.Lines)
+ {
+ Assert.DoesNotContain(FileSecret, line);
+ Assert.DoesNotContain(EnvSecret, line);
+ }
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("\n")]
+ [InlineData("\r\n")]
+ public void The_file_is_the_secret_with_only_the_trailing_newline_trimmed(string ending)
+ {
+ var options = Resolve(appSecretFile: Write(FileSecret + ending));
+
+ Assert.Equal(FileSecret, options.AppSecret);
+ Assert.False(options.IsDiscordConfigured); // no guild configured
+ Assert.True(options.IsPatreonConfigured);
+ Assert.Empty(_logs.Lines);
+ }
+
+ [Fact]
+ public void Inner_whitespace_survives_the_trim()
+ {
+ Assert.Equal(" a b ", Resolve(appSecretFile: Write(" a b \n")).AppSecret);
+ }
+
+ [Fact]
+ public void The_file_wins_over_the_environment_value_and_the_deprecation_is_logged()
+ {
+ var options = Resolve(EnvSecret, Write(FileSecret + "\n"));
+
+ Assert.Equal(FileSecret, options.AppSecret);
+ Assert.Contains(_logs.Lines, line => line.Contains("deprecated"));
+ AssertNoSecretLogged();
+ }
+
+ [Fact]
+ public void A_missing_file_fails_closed_naming_option_and_path_but_no_secret()
+ {
+ var path = Path.Combine(_directory, "absent");
+
+ var failure = Assert.Throws(() => Resolve(EnvSecret, path));
+
+ Assert.Contains("Heimdall:AppSecretFile", failure.Message);
+ Assert.Contains(path, failure.Message);
+ Assert.DoesNotContain(EnvSecret, failure.Message);
+ }
+
+ [Fact]
+ public void A_directory_in_place_of_the_file_fails_closed()
+ {
+ Assert.Throws(() => Resolve(appSecretFile: _directory));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("\n")]
+ [InlineData(" \r\n")]
+ public void An_empty_file_fails_closed_even_when_the_environment_value_is_set(string content)
+ {
+ var path = Write(content);
+
+ var failure = Assert.Throws(() => Resolve(EnvSecret, path));
+
+ Assert.Contains("Heimdall:AppSecretFile", failure.Message);
+ Assert.Contains(path, failure.Message);
+ Assert.DoesNotContain(EnvSecret, failure.Message);
+ }
+
+ [Fact]
+ public void The_environment_value_alone_still_works_and_warns_without_logging_it()
+ {
+ var options = Resolve(EnvSecret);
+
+ Assert.Equal(EnvSecret, options.AppSecret);
+ Assert.Single(_logs.Lines, line => line.Contains("deprecated") && line.Contains("Heimdall:AppSecretFile"));
+ AssertNoSecretLogged();
+ }
+
+ [Fact]
+ public void Neither_set_is_unconfigured_as_before_and_silent()
+ {
+ var options = Resolve();
+
+ Assert.Equal(string.Empty, options.AppSecret);
+ Assert.False(options.IsPatreonConfigured);
+ Assert.Empty(_logs.Lines);
+ }
+
+ private TestWebApplicationFactory Host(string file) => new TestHost(file, _logs);
+
+ private sealed class TestHost(string file, CapturingLoggerProvider logs) : TestWebApplicationFactory
+ {
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ base.ConfigureWebHost(builder);
+ builder.ConfigureAppConfiguration((_, configuration) =>
+ configuration.AddInMemoryCollection(new Dictionary { ["Heimdall:AppSecretFile"] = file }));
+ builder.ConfigureLogging(logging => logging.AddProvider(logs));
+ }
+ }
+
+ [Fact]
+ public void The_running_host_resolves_the_file_into_its_options()
+ {
+ using var host = Host(Write(FileSecret + "\n"));
+
+ Assert.Equal(FileSecret, host.Services.GetRequiredService>().Value.AppSecret);
+ }
+
+ [Fact]
+ public void The_running_host_refuses_to_start_on_a_missing_file_in_every_environment()
+ {
+ using var host = Host(Path.Combine(_directory, "absent"));
+
+ var failure = Assert.ThrowsAny(() => host.CreateClient());
+
+ Assert.Contains("Heimdall:AppSecretFile", failure.ToString());
+ }
+}