using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Security.Cryptography;
using System.Text;
namespace GB5Shared.DirectAction
{
///
/// Signs and verifies direct-action tokens using HMAC-SHA256.
///
/// Token format (URL-safe Base64):
/// payload = "{actionCode}:{contextId}:{tenantId}:{assigneeUserId}:{expiresAtTicks}:{databaseName}"
/// token = Base64Url(payload) + "." + Base64Url(HMAC-SHA256(payload, secret))
///
/// Secret key: appsettings["DirectAction:TokenSecret"] (provisioned from Vault in production).
///
public class DirectActionTokenService : IDirectActionTokenService
{
private readonly byte[] _secretBytes;
private readonly ILogger _logger;
public DirectActionTokenService(
IConfiguration config,
ILogger logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
var secret = config["DirectAction:TokenSecret"];
if (string.IsNullOrWhiteSpace(secret))
throw new InvalidOperationException("DirectAction:TokenSecret is not configured.");
_secretBytes = Encoding.UTF8.GetBytes(secret);
}
public string Generate(string actionCode, int contextId, int tenantId, int assigneeUserId,
string databaseName, TimeSpan expiry)
{
var expiresAt = DateTime.UtcNow.Add(expiry).Ticks;
var payload = $"{actionCode}:{contextId}:{tenantId}:{assigneeUserId}:{expiresAt}:{databaseName}";
var payloadB64 = ToBase64Url(Encoding.UTF8.GetBytes(payload));
var sigB64 = ToBase64Url(Sign(payload));
return $"{payloadB64}.{sigB64}";
}
public bool TryValidate(string token,
out string actionCode, out int contextId, out int tenantId,
out int assigneeUserId, out string databaseName,
out DateTime expiresAt)
{
actionCode = string.Empty; contextId = 0; tenantId = 0; assigneeUserId = 0;
databaseName = string.Empty; expiresAt = DateTime.MinValue;
if (string.IsNullOrWhiteSpace(token))
return false;
var dot = token.LastIndexOf('.');
if (dot < 1 || dot == token.Length - 1)
{
_logger.LogWarning("DirectActionToken: malformed token — no separator dot.");
return false;
}
var payloadB64 = token[..dot];
var sigB64 = token[(dot + 1)..];
string payload;
try
{
payload = Encoding.UTF8.GetString(FromBase64Url(payloadB64));
}
catch
{
_logger.LogWarning("DirectActionToken: Base64 decode failed.");
return false;
}
var expectedSig = ToBase64Url(Sign(payload));
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expectedSig),
Encoding.UTF8.GetBytes(sigB64)))
{
_logger.LogWarning("DirectActionToken: signature mismatch.");
return false;
}
// Format: actionCode:contextId:tenantId:assigneeUserId:expiresAtTicks:databaseName
var parts = payload.Split(':');
if (parts.Length < 6 ||
!int.TryParse(parts[1], out contextId) ||
!int.TryParse(parts[2], out tenantId) ||
!int.TryParse(parts[3], out assigneeUserId) ||
!long.TryParse(parts[4], out var ticks))
{
_logger.LogWarning("DirectActionToken: payload parse failed.");
return false;
}
actionCode = parts[0];
databaseName = string.Join(":", parts[5..]); // join tail in case connection name ever contains ':'
expiresAt = new DateTime(ticks, DateTimeKind.Utc);
if (DateTime.UtcNow > expiresAt)
{
_logger.LogInformation(
"DirectActionToken: expired | ActionCode={Code} ContextId={CtxId} ExpiredAt={Exp}",
actionCode, contextId, expiresAt);
return false;
}
return true;
}
private byte[] Sign(string payload)
{
using var hmac = new HMACSHA256(_secretBytes);
return hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
}
private static string ToBase64Url(byte[] data)
=> Convert.ToBase64String(data)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
private static byte[] FromBase64Url(string base64Url)
{
var padded = base64Url.Replace('-', '+').Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
return Convert.FromBase64String(padded);
}
}
}