Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 696be17139 | |||
| 4094e64ee0 | |||
| b42c3c8b3b | |||
| 420a813967 | |||
| ec1155de6d | |||
| 0c539834dc |
@@ -589,6 +589,20 @@ The gateway should split the key into a stable key id and secret component,
|
||||
load the key record by id, hash the presented secret, and compare using a
|
||||
constant-time comparison.
|
||||
|
||||
`ApiKeyParser` accepts only `authorization: Bearer mxgw_<key-id>_<secret>`.
|
||||
Malformed headers fail before any database lookup. The parsed raw secret is
|
||||
kept only long enough for `ApiKeySecretHasher` to compute an HMAC-SHA256 hash
|
||||
using the configured `Authentication:PepperSecretName` lookup in application
|
||||
configuration. The raw secret is not stored in the auth database, identity
|
||||
model, logs, or verification result.
|
||||
|
||||
`ApiKeyVerifier` loads the stored key record by key id, rejects revoked keys,
|
||||
hashes the presented secret, and compares the stored and presented hashes with
|
||||
`CryptographicOperations.FixedTimeEquals`. A successful verification returns an
|
||||
`ApiKeyIdentity` with key id, key prefix, display name, and scopes. Failure
|
||||
results distinguish malformed credentials, missing keys, revoked keys, missing
|
||||
pepper configuration, and hash mismatch for internal authorization handling.
|
||||
|
||||
Recommended scopes:
|
||||
|
||||
- `session:open`
|
||||
@@ -612,6 +626,23 @@ SQLite auth storage should use startup migrations with a `schema_version` table.
|
||||
Migrations should run inside transactions and fail startup if the database
|
||||
schema is newer than the running binary understands.
|
||||
|
||||
The v1 auth store uses `Microsoft.Data.Sqlite` and creates the
|
||||
`schema_version`, `api_keys`, and `api_key_audit` tables through
|
||||
`SqliteAuthStoreMigrator`. `AuthStoreMigrationHostedService` runs those
|
||||
migrations at gateway startup when API-key authentication and
|
||||
`Authentication:RunMigrationsOnStartup` are enabled. A database with a newer
|
||||
schema version fails startup instead of being modified by an older gateway
|
||||
binary.
|
||||
|
||||
`IApiKeyStore` reads stored key records and exposes an active-key lookup that
|
||||
excludes rows with `revoked_utc` set. Hash verification belongs to the API-key
|
||||
hashing layer, but the store preserves the `secret_hash` bytes, display name,
|
||||
scopes, timestamps, and revocation state needed by that layer.
|
||||
|
||||
`IApiKeyAuditStore` appends audit events to `api_key_audit` and returns recent
|
||||
events for diagnostics and future administrative tools. Audit records store key
|
||||
ids and event metadata only; they do not store raw API key secrets.
|
||||
|
||||
Commands requiring authorization:
|
||||
|
||||
- writes,
|
||||
|
||||
@@ -26,6 +26,33 @@ Style guides:
|
||||
- [C# Style Guide](./style-guides/CSharpStyleGuide.md)
|
||||
- [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md)
|
||||
|
||||
## Build And Test
|
||||
|
||||
Build the SDK-style worker project with the .NET SDK MSBuild entry point. The
|
||||
project targets .NET Framework 4.8, but the SDK resolver comes from the .NET SDK
|
||||
installation:
|
||||
|
||||
```powershell
|
||||
dotnet msbuild src\MxGateway.Worker\MxGateway.Worker.csproj /restore /p:Configuration=Debug /p:Platform=x86
|
||||
```
|
||||
|
||||
`docs/toolchain-links.md` records the Visual Studio MSBuild executable for
|
||||
classic .NET Framework and COM interop builds:
|
||||
|
||||
```powershell
|
||||
& "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe" src\MxGateway.Worker\MxGateway.Worker.csproj /p:Configuration=Debug /p:Platform=x86
|
||||
```
|
||||
|
||||
Run the worker tests with the same platform target:
|
||||
|
||||
```powershell
|
||||
dotnet test src\MxGateway.Worker.Tests\MxGateway.Worker.Tests.csproj -p:Platform=x86
|
||||
```
|
||||
|
||||
The only MXAccess interop reference belongs in `MxGateway.Worker`. Gateway and
|
||||
test projects may reference the worker project for metadata and scaffold tests,
|
||||
but they must not reference `ArchestrA.MXAccess.dll` directly.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
The worker owns:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFrameworks>net10.0;net48</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -17,6 +17,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.1.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -2,6 +2,7 @@ using MxGateway.Contracts;
|
||||
using MxGateway.Server.Configuration;
|
||||
using MxGateway.Server.Diagnostics;
|
||||
using MxGateway.Server.Metrics;
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
|
||||
namespace MxGateway.Server;
|
||||
|
||||
@@ -23,6 +24,7 @@ public static class GatewayApplication
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddGatewayConfiguration();
|
||||
builder.Services.AddSqliteAuthStore();
|
||||
builder.Services.AddHealthChecks();
|
||||
builder.Services.AddSingleton<GatewayMetrics>();
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MxGateway.Contracts\MxGateway.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed record ApiKeyAuditEntry(
|
||||
string? KeyId,
|
||||
string EventType,
|
||||
string? RemoteAddress,
|
||||
string? Details);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed record ApiKeyAuditRecord(
|
||||
long AuditId,
|
||||
string? KeyId,
|
||||
string EventType,
|
||||
string? RemoteAddress,
|
||||
DateTimeOffset CreatedUtc,
|
||||
string? Details);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed record ApiKeyIdentity(
|
||||
string KeyId,
|
||||
string KeyPrefix,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes);
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed class ApiKeyParser : IApiKeyParser
|
||||
{
|
||||
private const string BearerPrefix = "Bearer ";
|
||||
private const string TokenPrefix = "mxgw_";
|
||||
|
||||
public bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey)
|
||||
{
|
||||
apiKey = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(authorizationHeader)
|
||||
|| !authorizationHeader.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string token = authorizationHeader[BearerPrefix.Length..].Trim();
|
||||
|
||||
if (!token.StartsWith(TokenPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string keyPayload = token[TokenPrefix.Length..];
|
||||
int separatorIndex = keyPayload.IndexOf('_', StringComparison.Ordinal);
|
||||
|
||||
if (separatorIndex <= 0 || separatorIndex == keyPayload.Length - 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string keyId = keyPayload[..separatorIndex];
|
||||
string secret = keyPayload[(separatorIndex + 1)..];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(keyId) || string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
apiKey = new ParsedApiKey(keyId, secret);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed class ApiKeyPepperUnavailableException(string pepperSecretName)
|
||||
: InvalidOperationException($"API key pepper secret '{pepperSecretName}' is not configured.");
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed record ApiKeyRecord(
|
||||
string KeyId,
|
||||
string KeyPrefix,
|
||||
byte[] SecretHash,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes,
|
||||
DateTimeOffset CreatedUtc,
|
||||
DateTimeOffset? LastUsedUtc,
|
||||
DateTimeOffset? RevokedUtc);
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public static class ApiKeyScopeSerializer
|
||||
{
|
||||
public static string Serialize(IReadOnlySet<string> scopes)
|
||||
{
|
||||
return JsonSerializer.Serialize(scopes.Order(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
public static IReadOnlySet<string> Deserialize(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
string[]? scopes = JsonSerializer.Deserialize<string[]>(value);
|
||||
|
||||
return new HashSet<string>(scopes ?? [], StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MxGateway.Server.Configuration;
|
||||
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed class ApiKeySecretHasher(
|
||||
IConfiguration configuration,
|
||||
IOptions<GatewayOptions> options) : IApiKeySecretHasher
|
||||
{
|
||||
public byte[] HashSecret(string secret)
|
||||
{
|
||||
string pepper = GetPepper();
|
||||
byte[] pepperBytes = Encoding.UTF8.GetBytes(pepper);
|
||||
byte[] secretBytes = Encoding.UTF8.GetBytes(secret);
|
||||
|
||||
using HMACSHA256 hmac = new(pepperBytes);
|
||||
|
||||
return hmac.ComputeHash(secretBytes);
|
||||
}
|
||||
|
||||
private string GetPepper()
|
||||
{
|
||||
string pepperSecretName = options.Value.Authentication.PepperSecretName;
|
||||
string? pepper = configuration[pepperSecretName];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pepper))
|
||||
{
|
||||
throw new ApiKeyPepperUnavailableException(pepperSecretName);
|
||||
}
|
||||
|
||||
return pepper;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public enum ApiKeyVerificationFailure
|
||||
{
|
||||
None,
|
||||
MissingOrMalformedCredentials,
|
||||
PepperUnavailable,
|
||||
KeyNotFound,
|
||||
KeyRevoked,
|
||||
SecretMismatch
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed record ApiKeyVerificationResult(
|
||||
bool Succeeded,
|
||||
ApiKeyIdentity? Identity,
|
||||
ApiKeyVerificationFailure Failure)
|
||||
{
|
||||
public static ApiKeyVerificationResult Success(ApiKeyIdentity identity)
|
||||
{
|
||||
return new ApiKeyVerificationResult(
|
||||
Succeeded: true,
|
||||
Identity: identity,
|
||||
Failure: ApiKeyVerificationFailure.None);
|
||||
}
|
||||
|
||||
public static ApiKeyVerificationResult Fail(ApiKeyVerificationFailure failure)
|
||||
{
|
||||
return new ApiKeyVerificationResult(
|
||||
Succeeded: false,
|
||||
Identity: null,
|
||||
Failure: failure);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed class ApiKeyVerifier(
|
||||
IApiKeyParser parser,
|
||||
IApiKeySecretHasher hasher,
|
||||
IApiKeyStore keyStore) : IApiKeyVerifier
|
||||
{
|
||||
public async Task<ApiKeyVerificationResult> VerifyAsync(
|
||||
string? authorizationHeader,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!parser.TryParseAuthorizationHeader(authorizationHeader, out ParsedApiKey? parsedKey)
|
||||
|| parsedKey is null)
|
||||
{
|
||||
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.MissingOrMalformedCredentials);
|
||||
}
|
||||
|
||||
ApiKeyRecord? storedKey = await keyStore.FindByKeyIdAsync(parsedKey.KeyId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (storedKey is null)
|
||||
{
|
||||
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.KeyNotFound);
|
||||
}
|
||||
|
||||
if (storedKey.RevokedUtc is not null)
|
||||
{
|
||||
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.KeyRevoked);
|
||||
}
|
||||
|
||||
byte[] presentedHash;
|
||||
try
|
||||
{
|
||||
presentedHash = hasher.HashSecret(parsedKey.Secret);
|
||||
}
|
||||
catch (ApiKeyPepperUnavailableException)
|
||||
{
|
||||
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.PepperUnavailable);
|
||||
}
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(presentedHash, storedKey.SecretHash))
|
||||
{
|
||||
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch);
|
||||
}
|
||||
|
||||
await keyStore.MarkKeyUsedAsync(storedKey.KeyId, DateTimeOffset.UtcNow, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
|
||||
KeyId: storedKey.KeyId,
|
||||
KeyPrefix: storedKey.KeyPrefix,
|
||||
DisplayName: storedKey.DisplayName,
|
||||
Scopes: storedKey.Scopes));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MxGateway.Server.Configuration;
|
||||
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed class AuthSqliteConnectionFactory(IOptions<GatewayOptions> options)
|
||||
{
|
||||
public SqliteConnection CreateConnection()
|
||||
{
|
||||
string sqlitePath = options.Value.Authentication.SqlitePath;
|
||||
string? directory = Path.GetDirectoryName(sqlitePath);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
SqliteConnectionStringBuilder builder = new()
|
||||
{
|
||||
DataSource = sqlitePath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate
|
||||
};
|
||||
|
||||
return new SqliteConnection(builder.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed class AuthStoreMigrationException(string message) : InvalidOperationException(message);
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using MxGateway.Server.Configuration;
|
||||
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed class AuthStoreMigrationHostedService(
|
||||
IOptions<GatewayOptions> options,
|
||||
IAuthStoreMigrator migrator) : IHostedService
|
||||
{
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
AuthenticationOptions authentication = options.Value.Authentication;
|
||||
|
||||
if (authentication.Mode == AuthenticationMode.ApiKey && authentication.RunMigrationsOnStartup)
|
||||
{
|
||||
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public static class AuthStoreServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddSqliteAuthStore(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IApiKeyParser, ApiKeyParser>();
|
||||
services.AddSingleton<IApiKeySecretHasher, ApiKeySecretHasher>();
|
||||
services.AddSingleton<IApiKeyVerifier, ApiKeyVerifier>();
|
||||
services.AddSingleton<AuthSqliteConnectionFactory>();
|
||||
services.AddSingleton<IAuthStoreMigrator, SqliteAuthStoreMigrator>();
|
||||
services.AddSingleton<IApiKeyStore, SqliteApiKeyStore>();
|
||||
services.AddSingleton<IApiKeyAuditStore, SqliteApiKeyAuditStore>();
|
||||
services.AddHostedService<AuthStoreMigrationHostedService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public interface IApiKeyAuditStore
|
||||
{
|
||||
Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public interface IApiKeyParser
|
||||
{
|
||||
bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public interface IApiKeySecretHasher
|
||||
{
|
||||
byte[] HashSecret(string secret);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public interface IApiKeyStore
|
||||
{
|
||||
Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken cancellationToken);
|
||||
|
||||
Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken);
|
||||
|
||||
Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public interface IApiKeyVerifier
|
||||
{
|
||||
Task<ApiKeyVerificationResult> VerifyAsync(
|
||||
string? authorizationHeader,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public interface IAuthStoreMigrator
|
||||
{
|
||||
Task MigrateAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed record ParsedApiKey(string KeyId, string Secret);
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed class SqliteApiKeyAuditStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAuditStore
|
||||
{
|
||||
public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteConnection connection = connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO api_key_audit (key_id, event_type, remote_address, created_utc, details)
|
||||
VALUES ($key_id, $event_type, $remote_address, $created_utc, $details);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", (object?)entry.KeyId ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$event_type", entry.EventType);
|
||||
command.Parameters.AddWithValue("$remote_address", (object?)entry.RemoteAddress ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$created_utc", DateTimeOffset.UtcNow.ToString("O"));
|
||||
command.Parameters.AddWithValue("$details", (object?)entry.Details ?? DBNull.Value);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken)
|
||||
{
|
||||
if (count <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
await using SqliteConnection connection = connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT audit_id, key_id, event_type, remote_address, created_utc, details
|
||||
FROM api_key_audit
|
||||
ORDER BY audit_id DESC
|
||||
LIMIT $count;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$count", count);
|
||||
|
||||
List<ApiKeyAuditRecord> records = [];
|
||||
|
||||
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
records.Add(new ApiKeyAuditRecord(
|
||||
AuditId: reader.GetInt64(0),
|
||||
KeyId: reader.IsDBNull(1) ? null : reader.GetString(1),
|
||||
EventType: reader.GetString(2),
|
||||
RemoteAddress: reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
CreatedUtc: DateTimeOffset.Parse(
|
||||
reader.GetString(4),
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
Details: reader.IsDBNull(5) ? null : reader.GetString(5)));
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStore
|
||||
{
|
||||
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken cancellationToken)
|
||||
{
|
||||
return FindByKeyIdAsync(keyId, requireActive: false, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken)
|
||||
{
|
||||
return FindByKeyIdAsync(keyId, requireActive: true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteConnection connection = connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
UPDATE api_keys
|
||||
SET last_used_utc = $last_used_utc
|
||||
WHERE key_id = $key_id AND revoked_utc IS NULL;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
command.Parameters.AddWithValue("$last_used_utc", usedUtc.ToString("O"));
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<ApiKeyRecord?> FindByKeyIdAsync(
|
||||
string keyId,
|
||||
bool requireActive,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteConnection connection = connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = requireActive
|
||||
? """
|
||||
SELECT key_id, key_prefix, secret_hash, display_name, scopes, created_utc, last_used_utc, revoked_utc
|
||||
FROM api_keys
|
||||
WHERE key_id = $key_id AND revoked_utc IS NULL;
|
||||
"""
|
||||
: """
|
||||
SELECT key_id, key_prefix, secret_hash, display_name, scopes, created_utc, last_used_utc, revoked_utc
|
||||
FROM api_keys
|
||||
WHERE key_id = $key_id;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
|
||||
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReadApiKeyRecord(reader);
|
||||
}
|
||||
|
||||
private static ApiKeyRecord ReadApiKeyRecord(SqliteDataReader reader)
|
||||
{
|
||||
return new ApiKeyRecord(
|
||||
KeyId: reader.GetString(0),
|
||||
KeyPrefix: reader.GetString(1),
|
||||
SecretHash: (byte[])reader["secret_hash"],
|
||||
DisplayName: reader.GetString(3),
|
||||
Scopes: ApiKeyScopeSerializer.Deserialize(reader.GetString(4)),
|
||||
CreatedUtc: DateTimeOffset.Parse(reader.GetString(5), System.Globalization.CultureInfo.InvariantCulture),
|
||||
LastUsedUtc: ReadNullableDateTimeOffset(reader, 6),
|
||||
RevokedUtc: ReadNullableDateTimeOffset(reader, 7));
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ReadNullableDateTimeOffset(SqliteDataReader reader, int ordinal)
|
||||
{
|
||||
return reader.IsDBNull(ordinal)
|
||||
? null
|
||||
: DateTimeOffset.Parse(reader.GetString(ordinal), System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public static class SqliteAuthSchema
|
||||
{
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
public const string SchemaVersionTable = "schema_version";
|
||||
|
||||
public const string ApiKeysTable = "api_keys";
|
||||
|
||||
public const string ApiKeyAuditTable = "api_key_audit";
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connectionFactory) : IAuthStoreMigrator
|
||||
{
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteConnection connection = connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using SqliteTransaction transaction =
|
||||
(SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
int existingVersion = await ReadExistingSchemaVersionAsync(connection, transaction, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existingVersion > SqliteAuthSchema.CurrentVersion)
|
||||
{
|
||||
throw new AuthStoreMigrationException(
|
||||
$"Auth database schema version {existingVersion} is newer than supported version {SqliteAuthSchema.CurrentVersion}.");
|
||||
}
|
||||
|
||||
await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<int> ReadExistingSchemaVersionAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteCommand tableExistsCommand = connection.CreateCommand();
|
||||
tableExistsCommand.Transaction = transaction;
|
||||
tableExistsCommand.CommandText = """
|
||||
SELECT COUNT(*)
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = $table_name;
|
||||
""";
|
||||
tableExistsCommand.Parameters.AddWithValue("$table_name", SqliteAuthSchema.SchemaVersionTable);
|
||||
|
||||
long tableCount = (long)(await tableExistsCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) ?? 0L);
|
||||
|
||||
if (tableCount == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
await using SqliteCommand versionCommand = connection.CreateCommand();
|
||||
versionCommand.Transaction = transaction;
|
||||
versionCommand.CommandText = """
|
||||
SELECT version
|
||||
FROM schema_version
|
||||
WHERE id = 1;
|
||||
""";
|
||||
|
||||
object? version = await versionCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return version is null || version == DBNull.Value
|
||||
? 0
|
||||
: Convert.ToInt32(version, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static async Task ApplyVersionOneAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteNonQueryAsync(
|
||||
connection,
|
||||
transaction,
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
version INTEGER NOT NULL,
|
||||
applied_utc TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
key_id TEXT PRIMARY KEY,
|
||||
key_prefix TEXT NOT NULL,
|
||||
secret_hash BLOB NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL,
|
||||
created_utc TEXT NOT NULL,
|
||||
last_used_utc TEXT NULL,
|
||||
revoked_utc TEXT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_key_audit (
|
||||
audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key_id TEXT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
remote_address TEXT NULL,
|
||||
created_utc TEXT NOT NULL,
|
||||
details TEXT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_api_keys_revoked_utc
|
||||
ON api_keys (revoked_utc);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_api_key_audit_key_id_created_utc
|
||||
ON api_key_audit (key_id, created_utc);
|
||||
""",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand versionCommand = connection.CreateCommand();
|
||||
versionCommand.Transaction = transaction;
|
||||
versionCommand.CommandText = """
|
||||
INSERT INTO schema_version (id, version, applied_utc)
|
||||
VALUES (1, $version, $applied_utc)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
version = excluded.version,
|
||||
applied_utc = excluded.applied_utc;
|
||||
""";
|
||||
versionCommand.Parameters.AddWithValue("$version", SqliteAuthSchema.CurrentVersion);
|
||||
versionCommand.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O"));
|
||||
|
||||
await versionCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task ExecuteNonQueryAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
string commandText,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = commandText;
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,15 @@ public sealed class GatewayLogRedactorTests
|
||||
Assert.DoesNotContain("super-secret", redacted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedactApiKey_RemovesSecretContainingUnderscores()
|
||||
{
|
||||
string? redacted = GatewayLogRedactor.RedactApiKey("Bearer mxgw_operator01_super_secret_value");
|
||||
|
||||
Assert.Equal("Bearer mxgw_operator01_[redacted]", redacted);
|
||||
Assert.DoesNotContain("super_secret_value", redacted);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("AuthenticateUser")]
|
||||
[InlineData("WriteSecured")]
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
|
||||
namespace MxGateway.Tests.Security.Authentication;
|
||||
|
||||
public sealed class ApiKeyParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryParseAuthorizationHeader_ValidBearerToken_ReturnsKeyIdAndSecret()
|
||||
{
|
||||
ApiKeyParser parser = new();
|
||||
|
||||
bool parsed = parser.TryParseAuthorizationHeader(
|
||||
"Bearer mxgw_operator01_secret_value",
|
||||
out ParsedApiKey? apiKey);
|
||||
|
||||
Assert.True(parsed);
|
||||
Assert.NotNull(apiKey);
|
||||
Assert.Equal("operator01", apiKey.KeyId);
|
||||
Assert.Equal("secret_value", apiKey.Secret);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("mxgw_operator01_secret")]
|
||||
[InlineData("Bearer not-a-gateway-key")]
|
||||
[InlineData("Bearer mxgw__secret")]
|
||||
[InlineData("Bearer mxgw_operator01_")]
|
||||
public void TryParseAuthorizationHeader_MalformedToken_ReturnsFalse(string? authorizationHeader)
|
||||
{
|
||||
ApiKeyParser parser = new();
|
||||
|
||||
bool parsed = parser.TryParseAuthorizationHeader(authorizationHeader, out ParsedApiKey? apiKey);
|
||||
|
||||
Assert.False(parsed);
|
||||
Assert.Null(apiKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MxGateway.Server.Configuration;
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
|
||||
namespace MxGateway.Tests.Security.Authentication;
|
||||
|
||||
public sealed class ApiKeySecretHasherTests
|
||||
{
|
||||
[Fact]
|
||||
public void HashSecret_SamePepperAndSecret_ReturnsSameHash()
|
||||
{
|
||||
ApiKeySecretHasher hasher = CreateHasher("pepper-one");
|
||||
|
||||
byte[] firstHash = hasher.HashSecret("raw-secret");
|
||||
byte[] secondHash = hasher.HashSecret("raw-secret");
|
||||
|
||||
Assert.Equal(firstHash, secondHash);
|
||||
Assert.NotEqual("raw-secret"u8.ToArray(), firstHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashSecret_DifferentPepper_ReturnsDifferentHash()
|
||||
{
|
||||
byte[] firstHash = CreateHasher("pepper-one").HashSecret("raw-secret");
|
||||
byte[] secondHash = CreateHasher("pepper-two").HashSecret("raw-secret");
|
||||
|
||||
Assert.NotEqual(firstHash, secondHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashSecret_MissingPepper_Throws()
|
||||
{
|
||||
ApiKeySecretHasher hasher = CreateHasher(pepper: null);
|
||||
|
||||
Assert.Throws<ApiKeyPepperUnavailableException>(() => hasher.HashSecret("raw-secret"));
|
||||
}
|
||||
|
||||
private static ApiKeySecretHasher CreateHasher(string? pepper)
|
||||
{
|
||||
Dictionary<string, string?> values = [];
|
||||
|
||||
if (pepper is not null)
|
||||
{
|
||||
values["TestPepper"] = pepper;
|
||||
}
|
||||
|
||||
IConfigurationRoot configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(values)
|
||||
.Build();
|
||||
|
||||
GatewayOptions options = new()
|
||||
{
|
||||
Authentication = new AuthenticationOptions
|
||||
{
|
||||
PepperSecretName = "TestPepper"
|
||||
}
|
||||
};
|
||||
|
||||
return new ApiKeySecretHasher(configuration, Options.Create(options));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MxGateway.Server.Configuration;
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
|
||||
namespace MxGateway.Tests.Security.Authentication;
|
||||
|
||||
public sealed class ApiKeyVerifierTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidKey_ReturnsIdentityAndScopes()
|
||||
{
|
||||
ApiKeySecretHasher hasher = CreateHasher("pepper");
|
||||
FakeApiKeyStore store = new(CreateRecord(hasher, revokedUtc: null));
|
||||
ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store);
|
||||
|
||||
ApiKeyVerificationResult result = await verifier.VerifyAsync(
|
||||
"Bearer mxgw_operator01_correct-secret",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.NotNull(result.Identity);
|
||||
Assert.Equal("operator01", result.Identity.KeyId);
|
||||
Assert.Equal("Operator Key", result.Identity.DisplayName);
|
||||
Assert.Contains("session:open", result.Identity.Scopes);
|
||||
Assert.Contains("events:read", result.Identity.Scopes);
|
||||
Assert.True(store.MarkedUsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidKey_DoesNotExposeRawSecretInResult()
|
||||
{
|
||||
ApiKeySecretHasher hasher = CreateHasher("pepper");
|
||||
FakeApiKeyStore store = new(CreateRecord(hasher, revokedUtc: null));
|
||||
ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store);
|
||||
|
||||
ApiKeyVerificationResult result = await verifier.VerifyAsync(
|
||||
"Bearer mxgw_operator01_correct-secret",
|
||||
CancellationToken.None);
|
||||
|
||||
string serialized = JsonSerializer.Serialize(result);
|
||||
|
||||
Assert.DoesNotContain("correct-secret", serialized, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("Bearer mxgw_operator01")]
|
||||
[InlineData("Bearer wrong")]
|
||||
public async Task VerifyAsync_MalformedKey_FailsUnauthenticated(string? authorizationHeader)
|
||||
{
|
||||
ApiKeyVerifier verifier = new(
|
||||
new ApiKeyParser(),
|
||||
CreateHasher("pepper"),
|
||||
new FakeApiKeyStore(storedKey: null));
|
||||
|
||||
ApiKeyVerificationResult result = await verifier.VerifyAsync(
|
||||
authorizationHeader,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(ApiKeyVerificationFailure.MissingOrMalformedCredentials, result.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_UnknownKey_Fails()
|
||||
{
|
||||
ApiKeyVerifier verifier = new(
|
||||
new ApiKeyParser(),
|
||||
CreateHasher("pepper"),
|
||||
new FakeApiKeyStore(storedKey: null));
|
||||
|
||||
ApiKeyVerificationResult result = await verifier.VerifyAsync(
|
||||
"Bearer mxgw_missing_secret",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(ApiKeyVerificationFailure.KeyNotFound, result.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WrongSecret_Fails()
|
||||
{
|
||||
ApiKeySecretHasher hasher = CreateHasher("pepper");
|
||||
FakeApiKeyStore store = new(CreateRecord(hasher, revokedUtc: null));
|
||||
ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store);
|
||||
|
||||
ApiKeyVerificationResult result = await verifier.VerifyAsync(
|
||||
"Bearer mxgw_operator01_wrong-secret",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(ApiKeyVerificationFailure.SecretMismatch, result.Failure);
|
||||
Assert.False(store.MarkedUsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_RevokedKey_Fails()
|
||||
{
|
||||
ApiKeySecretHasher hasher = CreateHasher("pepper");
|
||||
FakeApiKeyStore store = new(CreateRecord(hasher, DateTimeOffset.UtcNow));
|
||||
ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store);
|
||||
|
||||
ApiKeyVerificationResult result = await verifier.VerifyAsync(
|
||||
"Bearer mxgw_operator01_correct-secret",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(ApiKeyVerificationFailure.KeyRevoked, result.Failure);
|
||||
Assert.False(store.MarkedUsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_MissingPepper_Fails()
|
||||
{
|
||||
FakeApiKeyStore store = new(CreateRecord(CreateHasher("pepper"), revokedUtc: null));
|
||||
ApiKeyVerifier verifier = new(new ApiKeyParser(), CreateHasher(pepper: null), store);
|
||||
|
||||
ApiKeyVerificationResult result = await verifier.VerifyAsync(
|
||||
"Bearer mxgw_operator01_correct-secret",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(ApiKeyVerificationFailure.PepperUnavailable, result.Failure);
|
||||
}
|
||||
|
||||
private static ApiKeyRecord CreateRecord(ApiKeySecretHasher hasher, DateTimeOffset? revokedUtc)
|
||||
{
|
||||
return new ApiKeyRecord(
|
||||
KeyId: "operator01",
|
||||
KeyPrefix: "mxgw_operator01",
|
||||
SecretHash: hasher.HashSecret("correct-secret"),
|
||||
DisplayName: "Operator Key",
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"session:open",
|
||||
"events:read"
|
||||
},
|
||||
CreatedUtc: DateTimeOffset.UtcNow,
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: revokedUtc);
|
||||
}
|
||||
|
||||
private static ApiKeySecretHasher CreateHasher(string? pepper)
|
||||
{
|
||||
Dictionary<string, string?> values = [];
|
||||
|
||||
if (pepper is not null)
|
||||
{
|
||||
values["TestPepper"] = pepper;
|
||||
}
|
||||
|
||||
IConfigurationRoot configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(values)
|
||||
.Build();
|
||||
|
||||
GatewayOptions options = new()
|
||||
{
|
||||
Authentication = new AuthenticationOptions
|
||||
{
|
||||
PepperSecretName = "TestPepper"
|
||||
}
|
||||
};
|
||||
|
||||
return new ApiKeySecretHasher(configuration, Options.Create(options));
|
||||
}
|
||||
|
||||
private sealed class FakeApiKeyStore(ApiKeyRecord? storedKey) : IApiKeyStore
|
||||
{
|
||||
public bool MarkedUsed { get; private set; }
|
||||
|
||||
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(storedKey?.KeyId == keyId ? storedKey : null);
|
||||
}
|
||||
|
||||
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(
|
||||
storedKey?.KeyId == keyId && storedKey.RevokedUtc is null
|
||||
? storedKey
|
||||
: null);
|
||||
}
|
||||
|
||||
public Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
MarkedUsed = storedKey?.KeyId == keyId;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MxGateway.Server;
|
||||
using MxGateway.Server.Configuration;
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
|
||||
namespace MxGateway.Tests.Security.Authentication;
|
||||
|
||||
public sealed class SqliteAuthStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MigrateAsync_EmptyDatabase_InitializesCurrentSchema()
|
||||
{
|
||||
string databasePath = CreateTempDatabasePath();
|
||||
await using ServiceProvider services = BuildAuthServices(databasePath);
|
||||
|
||||
IAuthStoreMigrator migrator = services.GetRequiredService<IAuthStoreMigrator>();
|
||||
|
||||
await migrator.MigrateAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(SqliteAuthSchema.CurrentVersion, await ReadSchemaVersionAsync(databasePath));
|
||||
Assert.True(await TableExistsAsync(databasePath, SqliteAuthSchema.ApiKeysTable));
|
||||
Assert.True(await TableExistsAsync(databasePath, SqliteAuthSchema.ApiKeyAuditTable));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MigrateAsync_ExistingVersionZeroDatabase_MigratesIdempotently()
|
||||
{
|
||||
string databasePath = CreateTempDatabasePath();
|
||||
await CreateVersionZeroDatabaseAsync(databasePath);
|
||||
await using ServiceProvider services = BuildAuthServices(databasePath);
|
||||
|
||||
IAuthStoreMigrator migrator = services.GetRequiredService<IAuthStoreMigrator>();
|
||||
|
||||
await migrator.MigrateAsync(CancellationToken.None);
|
||||
await migrator.MigrateAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(SqliteAuthSchema.CurrentVersion, await ReadSchemaVersionAsync(databasePath));
|
||||
Assert.True(await TableExistsAsync(databasePath, SqliteAuthSchema.ApiKeysTable));
|
||||
Assert.True(await TableExistsAsync(databasePath, SqliteAuthSchema.ApiKeyAuditTable));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_NewerSchemaVersion_BlocksStartup()
|
||||
{
|
||||
string databasePath = CreateTempDatabasePath();
|
||||
await CreateSchemaVersionDatabaseAsync(databasePath, SqliteAuthSchema.CurrentVersion + 1);
|
||||
|
||||
await using WebApplication app = GatewayApplication.Build(
|
||||
[
|
||||
$"--MxGateway:Authentication:SqlitePath={databasePath}",
|
||||
"--urls=http://127.0.0.1:0"
|
||||
]);
|
||||
|
||||
AuthStoreMigrationException exception = await Assert.ThrowsAsync<AuthStoreMigrationException>(
|
||||
() => app.StartAsync(CancellationToken.None));
|
||||
|
||||
Assert.Contains("newer than supported version", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindActiveByKeyIdAsync_ExistingActiveKey_ReturnsKey()
|
||||
{
|
||||
string databasePath = CreateTempDatabasePath();
|
||||
await using ServiceProvider services = BuildAuthServices(databasePath);
|
||||
await services.GetRequiredService<IAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
|
||||
await InsertApiKeyAsync(databasePath, revokedUtc: null);
|
||||
|
||||
IApiKeyStore store = services.GetRequiredService<IApiKeyStore>();
|
||||
|
||||
ApiKeyRecord? key = await store.FindActiveByKeyIdAsync("test-key", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(key);
|
||||
Assert.Equal("test-key", key.KeyId);
|
||||
Assert.Equal("mxgw_test", key.KeyPrefix);
|
||||
Assert.Equal([1, 2, 3, 4], key.SecretHash);
|
||||
Assert.Contains("session:open", key.Scopes);
|
||||
Assert.Null(key.RevokedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindActiveByKeyIdAsync_RevokedKey_ReturnsNull()
|
||||
{
|
||||
string databasePath = CreateTempDatabasePath();
|
||||
await using ServiceProvider services = BuildAuthServices(databasePath);
|
||||
await services.GetRequiredService<IAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
|
||||
await InsertApiKeyAsync(databasePath, DateTimeOffset.UtcNow);
|
||||
|
||||
IApiKeyStore store = services.GetRequiredService<IApiKeyStore>();
|
||||
|
||||
ApiKeyRecord? activeKey = await store.FindActiveByKeyIdAsync(
|
||||
"test-key",
|
||||
CancellationToken.None);
|
||||
ApiKeyRecord? storedKey = await store.FindByKeyIdAsync("test-key", CancellationToken.None);
|
||||
|
||||
Assert.Null(activeKey);
|
||||
Assert.NotNull(storedKey);
|
||||
Assert.NotNull(storedKey.RevokedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApiKeyAuditStore_AppendAsync_PersistsAuditEvent()
|
||||
{
|
||||
string databasePath = CreateTempDatabasePath();
|
||||
await using ServiceProvider services = BuildAuthServices(databasePath);
|
||||
await services.GetRequiredService<IAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
|
||||
|
||||
IApiKeyAuditStore auditStore = services.GetRequiredService<IApiKeyAuditStore>();
|
||||
|
||||
await auditStore.AppendAsync(
|
||||
new ApiKeyAuditEntry(
|
||||
KeyId: "test-key",
|
||||
EventType: "lookup",
|
||||
RemoteAddress: "127.0.0.1",
|
||||
Details: "matched active key"),
|
||||
CancellationToken.None);
|
||||
|
||||
IReadOnlyList<ApiKeyAuditRecord> records = await auditStore.ListRecentAsync(
|
||||
10,
|
||||
CancellationToken.None);
|
||||
|
||||
ApiKeyAuditRecord record = Assert.Single(records);
|
||||
Assert.Equal("test-key", record.KeyId);
|
||||
Assert.Equal("lookup", record.EventType);
|
||||
Assert.Equal("127.0.0.1", record.RemoteAddress);
|
||||
Assert.Equal("matched active key", record.Details);
|
||||
}
|
||||
|
||||
private static ServiceProvider BuildAuthServices(string databasePath)
|
||||
{
|
||||
IConfigurationRoot configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["MxGateway:Authentication:SqlitePath"] = databasePath
|
||||
})
|
||||
.Build();
|
||||
|
||||
ServiceCollection services = new();
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
services.AddGatewayConfiguration();
|
||||
services.AddSqliteAuthStore();
|
||||
|
||||
return services.BuildServiceProvider(validateScopes: true);
|
||||
}
|
||||
|
||||
private static string CreateTempDatabasePath()
|
||||
{
|
||||
string directory = Path.Combine(Path.GetTempPath(), "mxgateway-auth-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
return Path.Combine(directory, "gateway-auth.db");
|
||||
}
|
||||
|
||||
private static async Task CreateVersionZeroDatabaseAsync(string databasePath)
|
||||
{
|
||||
await using SqliteConnection connection = CreateConnection(databasePath);
|
||||
await connection.OpenAsync(CancellationToken.None);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
CREATE TABLE schema_version (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
version INTEGER NOT NULL,
|
||||
applied_utc TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO schema_version (id, version, applied_utc)
|
||||
VALUES (1, 0, $applied_utc);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O"));
|
||||
|
||||
await command.ExecuteNonQueryAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task CreateSchemaVersionDatabaseAsync(string databasePath, int version)
|
||||
{
|
||||
await using SqliteConnection connection = CreateConnection(databasePath);
|
||||
await connection.OpenAsync(CancellationToken.None);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
CREATE TABLE schema_version (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
version INTEGER NOT NULL,
|
||||
applied_utc TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO schema_version (id, version, applied_utc)
|
||||
VALUES (1, $version, $applied_utc);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$version", version);
|
||||
command.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O"));
|
||||
|
||||
await command.ExecuteNonQueryAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task InsertApiKeyAsync(string databasePath, DateTimeOffset? revokedUtc)
|
||||
{
|
||||
await using SqliteConnection connection = CreateConnection(databasePath);
|
||||
await connection.OpenAsync(CancellationToken.None);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO api_keys (
|
||||
key_id,
|
||||
key_prefix,
|
||||
secret_hash,
|
||||
display_name,
|
||||
scopes,
|
||||
created_utc,
|
||||
last_used_utc,
|
||||
revoked_utc)
|
||||
VALUES (
|
||||
$key_id,
|
||||
$key_prefix,
|
||||
$secret_hash,
|
||||
$display_name,
|
||||
$scopes,
|
||||
$created_utc,
|
||||
NULL,
|
||||
$revoked_utc);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", "test-key");
|
||||
command.Parameters.AddWithValue("$key_prefix", "mxgw_test");
|
||||
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = new byte[] { 1, 2, 3, 4 };
|
||||
command.Parameters.AddWithValue("$display_name", "Test Key");
|
||||
command.Parameters.AddWithValue(
|
||||
"$scopes",
|
||||
ApiKeyScopeSerializer.Serialize(new HashSet<string>(StringComparer.Ordinal) { "session:open", "events:read" }));
|
||||
command.Parameters.AddWithValue("$created_utc", DateTimeOffset.UtcNow.ToString("O"));
|
||||
command.Parameters.AddWithValue("$revoked_utc", revokedUtc?.ToString("O") ?? (object)DBNull.Value);
|
||||
|
||||
await command.ExecuteNonQueryAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task<int> ReadSchemaVersionAsync(string databasePath)
|
||||
{
|
||||
await using SqliteConnection connection = CreateConnection(databasePath);
|
||||
await connection.OpenAsync(CancellationToken.None);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT version FROM schema_version WHERE id = 1;";
|
||||
|
||||
object? result = await command.ExecuteScalarAsync(CancellationToken.None);
|
||||
|
||||
return Convert.ToInt32(result, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static async Task<bool> TableExistsAsync(string databasePath, string tableName)
|
||||
{
|
||||
await using SqliteConnection connection = CreateConnection(databasePath);
|
||||
await connection.OpenAsync(CancellationToken.None);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT COUNT(*)
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = $table_name;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$table_name", tableName);
|
||||
|
||||
long result = (long)(await command.ExecuteScalarAsync(CancellationToken.None) ?? 0L);
|
||||
|
||||
return result == 1;
|
||||
}
|
||||
|
||||
private static SqliteConnection CreateConnection(string databasePath)
|
||||
{
|
||||
SqliteConnectionStringBuilder builder = new()
|
||||
{
|
||||
DataSource = databasePath,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate
|
||||
};
|
||||
|
||||
return new SqliteConnection(builder.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MxGateway.Contracts;
|
||||
using MxGateway.Worker.Ipc;
|
||||
|
||||
namespace MxGateway.Worker.Tests.Contracts;
|
||||
|
||||
public sealed class WorkerContractInfoTests
|
||||
{
|
||||
[Fact]
|
||||
public void SupportedProtocolVersion_UsesSharedGatewayContractVersion()
|
||||
{
|
||||
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, WorkerContractInfo.SupportedProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkerEnvelopeDescriptorName_UsesGeneratedWorkerContract()
|
||||
{
|
||||
Assert.Equal("mxaccess_worker.v1.WorkerEnvelope", WorkerContractInfo.WorkerEnvelopeDescriptorName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using MxGateway.Worker.MxAccess;
|
||||
|
||||
namespace MxGateway.Worker.Tests.MxAccess;
|
||||
|
||||
public sealed class MxAccessInteropInfoTests
|
||||
{
|
||||
[Fact]
|
||||
public void InteropInfo_IdentifiesInstalledMxAccessComTarget()
|
||||
{
|
||||
Assert.Equal("LMXProxy.LMXProxyServer.1", MxAccessInteropInfo.ProgId);
|
||||
Assert.Equal("LMXProxy.LMXProxyServer", MxAccessInteropInfo.VersionIndependentProgId);
|
||||
Assert.Equal("{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}", MxAccessInteropInfo.Clsid);
|
||||
Assert.Equal("ArchestrA.MxAccess.LMXProxyServerClass", MxAccessInteropInfo.ComClassName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InteropAssemblyName_ComesFromReferencedMxAccessAssembly()
|
||||
{
|
||||
Assert.Equal("ArchestrA.MxAccess", MxAccessInteropInfo.InteropAssemblyName);
|
||||
Assert.Equal(3, MxAccessInteropInfo.InteropAssemblyVersion.Major);
|
||||
Assert.Equal(2, MxAccessInteropInfo.InteropAssemblyVersion.Minor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
<GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.1.2" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MxGateway.Worker\MxGateway.Worker.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace MxGateway.Worker.Tests.ProjectStructure;
|
||||
|
||||
public sealed class WorkerProjectReferenceTests
|
||||
{
|
||||
[Fact]
|
||||
public void WorkerProject_TargetsNet48AndX86()
|
||||
{
|
||||
XDocument project = LoadProject("MxGateway.Worker");
|
||||
|
||||
Assert.Equal("net48", ElementValue(project, "TargetFramework"));
|
||||
Assert.Equal("x86", ElementValue(project, "PlatformTarget"));
|
||||
Assert.Equal("true", ElementValue(project, "Prefer32Bit"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkerTestProject_TargetsNet48AndX86()
|
||||
{
|
||||
XDocument project = LoadProject("MxGateway.Worker.Tests");
|
||||
|
||||
Assert.Equal("net48", ElementValue(project, "TargetFramework"));
|
||||
Assert.Equal("x86", ElementValue(project, "PlatformTarget"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MxAccessInteropReference_ExistsOnlyInWorkerProject()
|
||||
{
|
||||
DirectoryInfo repositoryRoot = FindRepositoryRoot();
|
||||
string[] projectFiles = Directory.GetFiles(repositoryRoot.FullName, "*.csproj", SearchOption.AllDirectories)
|
||||
.Where(path => path.IndexOf($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
.Where(path => path.IndexOf($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
.ToArray();
|
||||
|
||||
IReadOnlyList<string> projectsWithMxAccessReference = projectFiles
|
||||
.Where(ProjectReferencesMxAccess)
|
||||
.Select(path => Path.GetFileNameWithoutExtension(path))
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(["MxGateway.Worker"], projectsWithMxAccessReference);
|
||||
}
|
||||
|
||||
private static bool ProjectReferencesMxAccess(string projectPath)
|
||||
{
|
||||
XDocument project = XDocument.Load(projectPath);
|
||||
|
||||
return project
|
||||
.Descendants()
|
||||
.Where(element => element.Name.LocalName is "Reference" or "COMReference" or "COMFileReference" or "PackageReference")
|
||||
.Select(element => (string?)element.Attribute("Include") ?? string.Empty)
|
||||
.Concat(project.Descendants().Where(element => element.Name.LocalName == "HintPath").Select(element => element.Value))
|
||||
.Any(reference =>
|
||||
reference.IndexOf("MxAccess", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| reference.IndexOf("ArchestrA.MXAccess", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| reference.IndexOf("LMXProxy", StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
}
|
||||
|
||||
private static XDocument LoadProject(string projectName)
|
||||
{
|
||||
DirectoryInfo repositoryRoot = FindRepositoryRoot();
|
||||
string projectPath = Path.Combine(repositoryRoot.FullName, projectName, $"{projectName}.csproj");
|
||||
|
||||
return XDocument.Load(projectPath);
|
||||
}
|
||||
|
||||
private static string ElementValue(XDocument project, string elementName)
|
||||
{
|
||||
return project
|
||||
.Descendants()
|
||||
.Single(element => element.Name.LocalName == elementName)
|
||||
.Value;
|
||||
}
|
||||
|
||||
private static DirectoryInfo FindRepositoryRoot()
|
||||
{
|
||||
DirectoryInfo? current = new(AppContext.BaseDirectory);
|
||||
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "MxGateway.sln")))
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate src/MxGateway.sln from the test output directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using MxGateway.Contracts;
|
||||
using MxGateway.Contracts.Proto;
|
||||
|
||||
namespace MxGateway.Worker.Ipc;
|
||||
|
||||
public static class WorkerContractInfo
|
||||
{
|
||||
public static uint SupportedProtocolVersion => GatewayContractInfo.WorkerProtocolVersion;
|
||||
|
||||
public static string WorkerEnvelopeDescriptorName => WorkerEnvelope.Descriptor.FullName;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using ArchestrA.MxAccess;
|
||||
|
||||
namespace MxGateway.Worker.MxAccess;
|
||||
|
||||
public static class MxAccessInteropInfo
|
||||
{
|
||||
public const string ProgId = "LMXProxy.LMXProxyServer.1";
|
||||
|
||||
public const string VersionIndependentProgId = "LMXProxy.LMXProxyServer";
|
||||
|
||||
public const string Clsid = "{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}";
|
||||
|
||||
public const string InteropAssemblyPath =
|
||||
@"C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll";
|
||||
|
||||
public const string RegisteredServerPath =
|
||||
@"C:\Program Files (x86)\ArchestrA\Framework\Bin\LmxProxy.dll";
|
||||
|
||||
public const string ComClassName = "ArchestrA.MxAccess.LMXProxyServerClass";
|
||||
|
||||
public static string InteropAssemblyName =>
|
||||
typeof(LMXProxyServerClass).Assembly.GetName().Name ?? string.Empty;
|
||||
|
||||
public static Version InteropAssemblyVersion =>
|
||||
typeof(LMXProxyServerClass).Assembly.GetName().Version ?? new Version(0, 0);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
<GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MxGateway.Contracts\MxGateway.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="ArchestrA.MxAccess">
|
||||
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
<SpecificVersion>false</SpecificVersion>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,3 @@
|
||||
using MxGateway.Worker;
|
||||
|
||||
return WorkerApplication.Run(args);
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
|
||||
namespace MxGateway.Worker;
|
||||
|
||||
public static class WorkerApplication
|
||||
{
|
||||
public static int Run(string[] args)
|
||||
{
|
||||
if (args is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(args));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Tests", "MxGatewa
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.IntegrationTests", "MxGateway.IntegrationTests\MxGateway.IntegrationTests.csproj", "{6D0BDEA5-F3F5-4F7C-9152-040BF88E4F2D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Worker", "MxGateway.Worker\MxGateway.Worker.csproj", "{5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Worker.Tests", "MxGateway.Worker.Tests\MxGateway.Worker.Tests.csproj", "{91255F30-8D43-47C9-AC52-AA0DDA4E9348}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -69,6 +73,30 @@ Global
|
||||
{6D0BDEA5-F3F5-4F7C-9152-040BF88E4F2D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6D0BDEA5-F3F5-4F7C-9152-040BF88E4F2D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6D0BDEA5-F3F5-4F7C-9152-040BF88E4F2D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5F2E4C90-B101-4D5D-A9D4-F9F7B53C1A85}.Release|x86.Build.0 = Release|Any CPU
|
||||
{91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Release|x64.Build.0 = Release|Any CPU
|
||||
{91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{91255F30-8D43-47C9-AC52-AA0DDA4E9348}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
Reference in New Issue
Block a user