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); } } }