Add XML documentation across gateway, worker, and .NET client

This commit is contained in:
Joseph Doherty
2026-04-30 11:49:58 -04:00
parent 4731ab535c
commit eed1e88a37
269 changed files with 4555 additions and 13 deletions
@@ -2,6 +2,9 @@ using System.Text.Json;
namespace MxGateway.Server.Security.Authentication;
/// <summary>
/// Executes API key administration commands from the CLI.
/// </summary>
public sealed class ApiKeyAdminCliRunner(
IAuthStoreMigrator migrator,
IApiKeyAdminStore adminStore,
@@ -13,6 +16,12 @@ public sealed class ApiKeyAdminCliRunner(
WriteIndented = true
};
/// <summary>
/// Runs an API key administration command and writes the output.
/// </summary>
/// <param name="command">API key administration command to execute.</param>
/// <param name="output">Text writer for command output.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task<int> RunAsync(
ApiKeyAdminCommand command,
TextWriter output,
@@ -2,6 +2,9 @@ namespace MxGateway.Server.Security.Authentication;
public static class ApiKeyAdminCommandLineParser
{
/// <summary>Parses command-line arguments for the API key admin subcommand.</summary>
/// <param name="args">Command-line arguments to parse.</param>
/// <returns>Parse result containing the command kind and options, or a failure message.</returns>
public static ApiKeyAdminParseResult Parse(IReadOnlyList<string> args)
{
if (args.Count == 0 || !string.Equals(args[0], "apikey", StringComparison.OrdinalIgnoreCase))
@@ -5,16 +5,21 @@ public sealed record ApiKeyAdminParseResult(
ApiKeyAdminCommand? Command,
string? Error)
{
/// <summary>Returns a result indicating the input was not an API key command.</summary>
public static ApiKeyAdminParseResult NotApiKeyCommand()
{
return new ApiKeyAdminParseResult(false, null, null);
}
/// <summary>Returns a successful parse result with the parsed API key command.</summary>
/// <param name="command">Parsed API key administration command.</param>
public static ApiKeyAdminParseResult Success(ApiKeyAdminCommand command)
{
return new ApiKeyAdminParseResult(true, command, null);
}
/// <summary>Returns a parse result with the specified error message.</summary>
/// <param name="error">Error message describing the parse failure.</param>
public static ApiKeyAdminParseResult Fail(string error)
{
return new ApiKeyAdminParseResult(true, null, error);
@@ -5,6 +5,10 @@ public sealed class ApiKeyParser : IApiKeyParser
private const string BearerPrefix = "Bearer ";
private const string TokenPrefix = "mxgw_";
/// <summary>Attempts to parse a Bearer token from an Authorization header and extract the API key ID and secret.</summary>
/// <param name="authorizationHeader">Authorization header value to parse.</param>
/// <param name="apiKey">Parsed API key with ID and secret, or null if parsing failed.</param>
/// <returns>True if the header was successfully parsed; otherwise, false.</returns>
public bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey)
{
apiKey = null;
@@ -2,8 +2,12 @@ using Microsoft.Data.Sqlite;
namespace MxGateway.Server.Security.Authentication;
/// <summary>Reads API key records from SQLite query results.</summary>
public static class ApiKeyRecordReader
{
/// <summary>Deserializes a row from the API key table into an ApiKeyRecord.</summary>
/// <param name="reader">The data reader positioned at the API key row.</param>
/// <returns>The deserialized API key record.</returns>
public static ApiKeyRecord Read(SqliteDataReader reader)
{
return new ApiKeyRecord(
@@ -4,11 +4,17 @@ namespace MxGateway.Server.Security.Authentication;
public static class ApiKeyScopeSerializer
{
/// <summary>Serializes scopes to JSON string.</summary>
/// <param name="scopes">The scopes to serialize.</param>
/// <returns>JSON string representation.</returns>
public static string Serialize(IReadOnlySet<string> scopes)
{
return JsonSerializer.Serialize(scopes.Order(StringComparer.Ordinal));
}
/// <summary>Deserializes scopes from JSON string.</summary>
/// <param name="value">The JSON string to deserialize.</param>
/// <returns>Deserialized scopes set.</returns>
public static IReadOnlySet<string> Deserialize(string value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -2,8 +2,10 @@ using System.Security.Cryptography;
namespace MxGateway.Server.Security.Authentication;
/// <summary>Generates cryptographically secure API key secrets.</summary>
public static class ApiKeySecretGenerator
{
/// <summary>Generates a new random API key secret string.</summary>
public static string Generate()
{
Span<byte> bytes = stackalloc byte[32];
@@ -9,6 +9,9 @@ public sealed class ApiKeySecretHasher(
IConfiguration configuration,
IOptions<GatewayOptions> options) : IApiKeySecretHasher
{
/// <summary>Hashes an API key secret with pepper using HMAC-SHA256.</summary>
/// <param name="secret">The secret to hash.</param>
/// <returns>The hashed secret.</returns>
public byte[] HashSecret(string secret)
{
string pepper = GetPepper();
@@ -5,6 +5,11 @@ public sealed record ApiKeyVerificationResult(
ApiKeyIdentity? Identity,
ApiKeyVerificationFailure Failure)
{
/// <summary>
/// Creates a successful verification result.
/// </summary>
/// <param name="identity">API key identity.</param>
/// <returns>Success result.</returns>
public static ApiKeyVerificationResult Success(ApiKeyIdentity identity)
{
return new ApiKeyVerificationResult(
@@ -13,6 +18,11 @@ public sealed record ApiKeyVerificationResult(
Failure: ApiKeyVerificationFailure.None);
}
/// <summary>
/// Creates a failed verification result.
/// </summary>
/// <param name="failure">Verification failure reason.</param>
/// <returns>Failure result.</returns>
public static ApiKeyVerificationResult Fail(ApiKeyVerificationFailure failure)
{
return new ApiKeyVerificationResult(
@@ -7,6 +7,12 @@ public sealed class ApiKeyVerifier(
IApiKeySecretHasher hasher,
IApiKeyStore keyStore) : IApiKeyVerifier
{
/// <summary>
/// Verifies an API key from an authorization header asynchronously.
/// </summary>
/// <param name="authorizationHeader">Authorization header value.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result.</returns>
public async Task<ApiKeyVerificationResult> VerifyAsync(
string? authorizationHeader,
CancellationToken cancellationToken)
@@ -4,8 +4,14 @@ using MxGateway.Server.Configuration;
namespace MxGateway.Server.Security.Authentication;
/// <summary>
/// Factory for creating SQLite connections to the authentication store.
/// </summary>
public sealed class AuthSqliteConnectionFactory(IOptions<GatewayOptions> options)
{
/// <summary>
/// Creates and configures a SQLite connection to the auth database.
/// </summary>
public SqliteConnection CreateConnection()
{
string sqlitePath = options.Value.Authentication.SqlitePath;
@@ -3,10 +3,14 @@ using MxGateway.Server.Configuration;
namespace MxGateway.Server.Security.Authentication;
/// <summary>
/// Hosted service that runs authentication store migrations on startup.
/// </summary>
public sealed class AuthStoreMigrationHostedService(
IOptions<GatewayOptions> options,
IAuthStoreMigrator migrator) : IHostedService
{
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
AuthenticationOptions authentication = options.Value.Authentication;
@@ -17,6 +21,7 @@ public sealed class AuthStoreMigrationHostedService(
}
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
@@ -1,7 +1,15 @@
namespace MxGateway.Server.Security.Authentication;
/// <summary>
/// Extension methods for configuring the SQLite authentication store.
/// </summary>
public static class AuthStoreServiceCollectionExtensions
{
/// <summary>
/// Adds the SQLite authentication store and related services to the dependency container.
/// </summary>
/// <param name="services">Service collection to configure.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddSqliteAuthStore(this IServiceCollection services)
{
services.AddSingleton<IApiKeyParser, ApiKeyParser>();
@@ -2,12 +2,38 @@ namespace MxGateway.Server.Security.Authentication;
public interface IApiKeyAdminStore
{
/// <summary>
/// Creates a new API key asynchronously.
/// </summary>
/// <param name="request">API key creation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken);
/// <summary>
/// Lists all API keys asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of API key records.</returns>
Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken);
/// <summary>
/// Revokes an API key asynchronously.
/// </summary>
/// <param name="keyId">Key identifier.</param>
/// <param name="revokedUtc">Revocation timestamp.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if revoked; otherwise false.</returns>
Task<bool> RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken);
/// <summary>
/// Rotates an API key secret asynchronously.
/// </summary>
/// <param name="keyId">Key identifier.</param>
/// <param name="secretHash">New secret hash.</param>
/// <param name="rotatedUtc">Rotation timestamp.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if rotated; otherwise false.</returns>
Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
@@ -1,8 +1,23 @@
namespace MxGateway.Server.Security.Authentication;
/// <summary>
/// Stores and retrieves audit events for API key operations.
/// </summary>
public interface IApiKeyAuditStore
{
/// <summary>
/// Appends an audit entry to the audit log.
/// </summary>
/// <param name="entry">Audit entry to record.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Asynchronous task representing the append operation.</returns>
Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken);
/// <summary>
/// Lists the most recent audit entries, up to the specified count.
/// </summary>
/// <param name="count">Maximum number of entries to return.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Asynchronous task returning the list of audit records.</returns>
Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken);
}
@@ -2,5 +2,8 @@ namespace MxGateway.Server.Security.Authentication;
public interface IApiKeyParser
{
/// <summary>Attempts to parse an authorization header and extract the API key.</summary>
/// <param name="authorizationHeader">Authorization header value to parse.</param>
/// <param name="apiKey">Parsed API key if successful.</param>
bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey);
}
@@ -2,5 +2,7 @@ namespace MxGateway.Server.Security.Authentication;
public interface IApiKeySecretHasher
{
/// <summary>Hashes an API key secret and returns the hash bytes.</summary>
/// <param name="secret">API key secret to hash.</param>
byte[] HashSecret(string secret);
}
@@ -1,10 +1,21 @@
namespace MxGateway.Server.Security.Authentication;
/// <summary>Persists API keys and audit records for authentication and accounting.</summary>
public interface IApiKeyStore
{
/// <summary>Retrieves an API key by ID regardless of revocation status.</summary>
/// <param name="keyId">Identifier of the API key.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken cancellationToken);
/// <summary>Retrieves an active (non-revoked) API key by ID.</summary>
/// <param name="keyId">Identifier of the API key.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken);
/// <summary>Records that an API key was used for auditing and tracking.</summary>
/// <param name="keyId">Identifier of the API key.</param>
/// <param name="usedUtc">Timestamp when the key was used in UTC.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken);
}
@@ -1,7 +1,11 @@
namespace MxGateway.Server.Security.Authentication;
/// <summary>Verifies API key authorization headers and returns the authenticated identity.</summary>
public interface IApiKeyVerifier
{
/// <summary>Parses and verifies an authorization header, returning success with identity or a failure reason.</summary>
/// <param name="authorizationHeader">The authorization header value to verify.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task<ApiKeyVerificationResult> VerifyAsync(
string? authorizationHeader,
CancellationToken cancellationToken);
@@ -1,6 +1,10 @@
namespace MxGateway.Server.Security.Authentication;
/// <summary>Migrates authentication storage between versions.</summary>
public interface IAuthStoreMigrator
{
/// <summary>Performs authentication store migration asynchronously.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Asynchronous task representing the migration operation.</returns>
Task MigrateAsync(CancellationToken cancellationToken);
}
@@ -2,8 +2,12 @@ using Microsoft.Data.Sqlite;
namespace MxGateway.Server.Security.Authentication;
/// <summary>
/// SQLite-backed storage for API key administration (create, list, revoke, rotate).
/// </summary>
public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore
{
/// <inheritdoc />
public async Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
{
await using SqliteConnection connection = connectionFactory.CreateConnection();
@@ -35,6 +39,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
await using SqliteConnection connection = connectionFactory.CreateConnection();
@@ -60,6 +65,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
return records;
}
/// <inheritdoc />
public async Task<bool> RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken)
{
await using SqliteConnection connection = connectionFactory.CreateConnection();
@@ -79,6 +85,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
@@ -4,6 +4,7 @@ namespace MxGateway.Server.Security.Authentication;
public sealed class SqliteApiKeyAuditStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAuditStore
{
/// <inheritdoc />
public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
{
await using SqliteConnection connection = connectionFactory.CreateConnection();
@@ -23,6 +24,7 @@ public sealed class SqliteApiKeyAuditStore(AuthSqliteConnectionFactory connectio
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken)
{
if (count <= 0)
@@ -2,18 +2,22 @@ using Microsoft.Data.Sqlite;
namespace MxGateway.Server.Security.Authentication;
/// <summary>SQLite-based store for API key records.</summary>
public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStore
{
/// <inheritdoc />
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken cancellationToken)
{
return FindByKeyIdAsync(keyId, requireActive: false, cancellationToken);
}
/// <inheritdoc />
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken)
{
return FindByKeyIdAsync(keyId, requireActive: true, cancellationToken);
}
/// <inheritdoc />
public async Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken)
{
await using SqliteConnection connection = connectionFactory.CreateConnection();
@@ -4,6 +4,8 @@ namespace MxGateway.Server.Security.Authentication;
public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connectionFactory) : IAuthStoreMigrator
{
/// <summary>Applies database migrations to the authentication store.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task MigrateAsync(CancellationToken cancellationToken)
{
await using SqliteConnection connection = connectionFactory.CreateConnection();