diff --git a/DevProxy.Tests/ApiSecurityTokenTests.cs b/DevProxy.Tests/ApiSecurityTokenTests.cs
new file mode 100644
index 00000000..0912a64c
--- /dev/null
+++ b/DevProxy.Tests/ApiSecurityTokenTests.cs
@@ -0,0 +1,92 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Xunit;
+
+namespace DevProxy.Tests;
+
+public class ApiSecurityTokenTests
+{
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void TryResolveToken_NoOverride_GeneratesRandomToken(string? value)
+ {
+ Assert.True(ApiSecurity.TryResolveToken(value, out var first, out var error));
+ Assert.Null(error);
+ Assert.Equal(64, first.Length);
+ Assert.True(first.All(Uri.IsHexDigit));
+
+ Assert.True(ApiSecurity.TryResolveToken(value, out var second, out _));
+ Assert.NotEqual(first, second);
+ }
+
+ [Fact]
+ public void TryResolveToken_ValidOverride_UsesValue()
+ {
+ var value = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));
+
+ Assert.True(ApiSecurity.TryResolveToken(value, out var token, out var error));
+ Assert.Null(error);
+ Assert.Equal(value, token);
+ }
+
+ [Fact]
+ public void TryResolveToken_OverrideWithSurroundingWhitespace_IsTrimmed()
+ {
+ var value = new string('a', 32);
+
+ Assert.True(ApiSecurity.TryResolveToken($" {value}\n", out var token, out _));
+ Assert.Equal(value, token);
+ }
+
+ [Theory]
+ [InlineData("abcdefghijklmnopqrstuvwxyz-._~+/0123==")]
+ [InlineData("ABCDEFGHIJKLMNOPQRSTUVWXYZ012345")]
+ public void TryResolveToken_Base64TokenCharacters_Accepted(string value)
+ {
+ Assert.True(ApiSecurity.TryResolveToken(value, out var token, out _));
+ Assert.Equal(value, token);
+ }
+
+ [Theory]
+ [InlineData("short")]
+ [InlineData("0123456789abcdef0123456789abcde")]
+ public void TryResolveToken_TooShort_Fails(string value)
+ {
+ Assert.False(ApiSecurity.TryResolveToken(value, out _, out var error));
+ Assert.Contains(ApiSecurity.TokenEnvironmentVariable, error, StringComparison.Ordinal);
+ Assert.Contains("32", error, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("0123456789abcdef 0123456789abcdef")]
+ [InlineData("0123456789abcdef0123456789abcdef\u00e9")]
+ [InlineData("0123456789abcdef0123456789abcdef;")]
+ [InlineData("0123456789abcdef=0123456789abcdef")]
+ public void TryResolveToken_InvalidCharacters_Fails(string value)
+ {
+ Assert.False(ApiSecurity.TryResolveToken(value, out _, out var error));
+ Assert.Contains(ApiSecurity.TokenEnvironmentVariable, error, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("================================")]
+ [InlineData("a===============================")]
+ public void TryResolveToken_PaddingNotCountedTowardMinimum_Fails(string value)
+ {
+ Assert.False(ApiSecurity.TryResolveToken(value, out _, out var error));
+ Assert.Contains("padding", error, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TryResolveToken_MinimumLengthWithPadding_Accepted()
+ {
+ var value = new string('a', 32) + "==";
+
+ Assert.True(ApiSecurity.TryResolveToken(value, out var token, out _));
+ Assert.Equal(value, token);
+ }
+}
diff --git a/DevProxy/ApiSecurity.cs b/DevProxy/ApiSecurity.cs
index a7f5ad00..47c0f0a5 100644
--- a/DevProxy/ApiSecurity.cs
+++ b/DevProxy/ApiSecurity.cs
@@ -7,10 +7,83 @@ namespace DevProxy;
internal static class ApiSecurity
{
- private static readonly string Token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
- private static readonly byte[] TokenBytes = Encoding.UTF8.GetBytes(Token);
+ internal const string TokenEnvironmentVariable = "DEV_PROXY_API_TOKEN";
+ internal const int MinimumTokenLength = 32;
+
+ private static readonly Lock TokenLock = new();
+ private static string? _token;
+ private static byte[]? _tokenBytes;
+ private static string Token => EnsureToken().Token;
+ private static byte[] TokenBytes => EnsureToken().TokenBytes;
private static bool _tokenDisplayed;
+ ///
+ /// Resolves the API token from the value of DEV_PROXY_API_TOKEN.
+ /// When the value is null or whitespace, a random token is generated.
+ ///
+ internal static bool TryResolveToken(string? environmentValue, out string token, out string? error)
+ {
+ error = null;
+ if (string.IsNullOrWhiteSpace(environmentValue))
+ {
+ token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
+ return true;
+ }
+
+ token = environmentValue.Trim();
+ if (token.TrimEnd('=').Length < MinimumTokenLength)
+ {
+ error = $"{TokenEnvironmentVariable} must be at least {MinimumTokenLength} characters long, not counting trailing = padding.";
+ return false;
+ }
+
+ // RFC 6750 b64token characters, so the value can be sent as a Bearer credential
+ if (!token.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '.' or '_' or '~' or '+' or '/' or '=') ||
+ token.TrimEnd('=').Contains('=', StringComparison.Ordinal))
+ {
+ error = $"{TokenEnvironmentVariable} may contain only ASCII letters, digits, and the characters - . _ ~ + / (optionally followed by trailing = padding).";
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Resolves the API token from the current (effective) value of DEV_PROXY_API_TOKEN.
+ /// Call after environment overrides are applied. Once resolved, the token is kept for the
+ /// lifetime of the process, so it survives configuration reloads.
+ ///
+ public static bool TryInitializeToken(out string? error)
+ {
+ lock (TokenLock)
+ {
+ error = null;
+ if (_token is not null)
+ {
+ return true;
+ }
+
+ if (!TryResolveToken(Environment.GetEnvironmentVariable(TokenEnvironmentVariable), out var token, out error))
+ {
+ return false;
+ }
+
+ _tokenBytes = Encoding.UTF8.GetBytes(token);
+ _token = token;
+ return true;
+ }
+ }
+
+ private static (string Token, byte[] TokenBytes) EnsureToken()
+ {
+ if (!TryInitializeToken(out var error))
+ {
+ throw new InvalidOperationException(error);
+ }
+
+ return (_token!, _tokenBytes!);
+ }
+
public static bool ShouldDisplayToken => Environment.GetEnvironmentVariable("CI") is null;
public static string? DisplayToken => ShouldDisplayToken ? Token : null;
diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs
index 13fb0418..6577c0bc 100644
--- a/DevProxy/Commands/DevProxyCommand.cs
+++ b/DevProxy/Commands/DevProxyCommand.cs
@@ -239,7 +239,7 @@ public DevProxyCommand(
IServiceProvider serviceProvider,
UpdateNotification updateNotification,
DevProxyConfigOptions options,
- ILogger logger) : base($"Start Dev Proxy\n\nAPI:\n Dev Proxy exposes an authenticated REST API for runtime management.\n Use --api-port (default: {proxyConfiguration.ApiPort}) and --api-ip-address (default: 127.0.0.1).\n The API bind address is independent of --ip-address.\n Run 'devproxy status' for the API URL and token, or 'devproxy api show' for endpoints.")
+ ILogger logger) : base($"Start Dev Proxy\n\nAPI:\n Dev Proxy exposes an authenticated REST API for runtime management.\n Use --api-port (default: {proxyConfiguration.ApiPort}) and --api-ip-address (default: 127.0.0.1).\n The API bind address is independent of --ip-address.\n Run 'devproxy status' for the API URL and token, or 'devproxy api show' for endpoints.\n Set the DEV_PROXY_API_TOKEN environment variable (at least 32 characters) to use a fixed API token instead of a generated one.")
{
_serviceProvider = serviceProvider;
_plugins = plugins;
@@ -284,6 +284,13 @@ private async Task InvokeAsync(ParseResult parseResult, CancellationToken c
{
throw new InvalidOperationException("WebApplication instance is not set. Please provide it when invoking the command.");
}
+ // Environment overrides from config and --env are applied by now, so this
+ // validates and pins the effective token before anything reads it.
+ if (!ApiSecurity.TryInitializeToken(out var tokenError))
+ {
+ _logger.LogError("{TokenError}", tokenError);
+ return 1;
+ }
if (!_plugins.Any())
{
_logger.LogError("You haven't configured any plugins. Please add plugins to your configuration file. Dev Proxy will exit.");
diff --git a/skills/dev-proxy/references/configuration.md b/skills/dev-proxy/references/configuration.md
index 0f0cecbd..7cbd2222 100644
--- a/skills/dev-proxy/references/configuration.md
+++ b/skills/dev-proxy/references/configuration.md
@@ -144,6 +144,8 @@ Plugin-specific override:
Every API request requires `Authorization: Bearer `. Startup and `devproxy status` show the per-process token in text and JSON, including redirected output and daemon logs. Tokens survive configuration reloads but change on process restart.
+To use a known token, for example when an orchestrator starts Dev Proxy and calls its API, set the `DEV_PROXY_API_TOKEN` environment variable before starting the proxy, or pass it with `--env` or the config `env` section. The value must be at least 32 characters, not counting trailing `=` padding, and use only ASCII letters, digits, `-`, `.`, `_`, `~`, `+`, `/`, and trailing `=`; surrounding whitespace is trimmed. Invalid values stop startup with an error. The token is still saved for `devproxy status`, `devproxy api token`, and `devproxy stop`.
+
When the `CI` environment variable is set, automatic token output is suppressed. Explicit retrieval remains available:
```bash