Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions DevProxy.Tests/ApiSecurityTokenTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// 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);
}
}
49 changes: 47 additions & 2 deletions DevProxy/ApiSecurity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,55 @@ 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 Lazy<string> LazyToken = new(() =>
TryResolveToken(Environment.GetEnvironmentVariable(TokenEnvironmentVariable), out var token, out var error)
? token
: throw new InvalidOperationException(error));
private static readonly Lazy<byte[]> LazyTokenBytes = new(() => Encoding.UTF8.GetBytes(Token));
private static string Token => LazyToken.Value;
private static byte[] TokenBytes => LazyTokenBytes.Value;
private static bool _tokenDisplayed;

/// <summary>
/// Resolves the API token from the value of <c>DEV_PROXY_API_TOKEN</c>.
/// When the value is null or whitespace, a random token is generated.
/// </summary>
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.Length < MinimumTokenLength)
{
error = $"{TokenEnvironmentVariable} must be at least {MinimumTokenLength} characters long.";
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))
Comment thread
waldekmastykarz marked this conversation as resolved.
Resolved
{
error = $"{TokenEnvironmentVariable} may contain only ASCII letters, digits, and the characters - . _ ~ + / (optionally followed by trailing = padding).";
return false;
}

return true;
}

/// <summary>
/// Validates <c>DEV_PROXY_API_TOKEN</c> so startup can fail with a clear error.
/// </summary>
public static bool TryValidateTokenEnvironment(out string? error) =>
TryResolveToken(Environment.GetEnvironmentVariable(TokenEnvironmentVariable), out _, out error);

public static bool ShouldDisplayToken => Environment.GetEnvironmentVariable("CI") is null;
public static string? DisplayToken => ShouldDisplayToken ? Token : null;

Expand Down
Loading
Loading