// Client per le API SignOTP (.NET 6 o superiore, nessuna dipendenza esterna).
// Documentazione: https://signotp.com/sviluppatori/
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SignOtp;
public sealed class SignOtpClient : IDisposable
{
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private readonly HttpClient _api;
private readonly HttpClient _upload = new(); // l'upload va direttamente su S3, senza x-api-key
public SignOtpClient(string apiKey, string baseUrl = "https://api.signotp.com")
{
_api = new HttpClient { BaseAddress = new Uri(baseUrl) };
_api.DefaultRequestHeaders.Add("x-api-key", apiKey);
}
/// 1) Crea una pratica (o un nuovo documento su una pratica esistente con ExistingUid).
public Task CreateRequestAsync(CreateRequest request, CancellationToken ct = default)
=> SendAsync(HttpMethod.Post, "/v1/requests", request, ct);
/// 2) Carica il PDF sulla URL temporanea restituita da CreateRequestAsync (valida 15 minuti).
public async Task UploadPdfAsync(UploadInfo upload, byte[] pdf, CancellationToken ct = default)
{
using var content = new ByteArrayContent(pdf);
content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
using var res = await _upload.PutAsync(upload.Url, content, ct);
res.EnsureSuccessStatusCode();
}
/// 3) Chiude l'inizializzazione: restituisce SignUrl da inviare all'utente.
public Task FinalizeAsync(string guid, CancellationToken ct = default)
=> SendAsync(HttpMethod.Post, $"/v1/requests/{guid}/finalize", null, ct);
/// Crea la pratica, carica il PDF e la finalizza in un'unica chiamata.
public async Task StartSigningAsync(CreateRequest request, byte[] pdf, CancellationToken ct = default)
{
var created = await CreateRequestAsync(request, ct);
await UploadPdfAsync(created.Upload, pdf, ct);
return await FinalizeAsync(created.Guid, ct);
}
/// Stato della pratica e di tutti i suoi documenti.
public Task GetRequestAsync(string guid, CancellationToken ct = default)
=> SendAsync(HttpMethod.Get, $"/v1/requests/{guid}", null, ct);
/// Scarica il PDF firmato (o l'originale) del documento docNumber.
public async Task DownloadAsync(string guid, int docNumber, bool signed = true, CancellationToken ct = default)
{
// l'API risponde 302 verso una URL S3 temporanea: HttpClient segue il redirect
using var res = await _api.GetAsync($"/v1/requests/{guid}/documents/{docNumber:00}/{(signed ? "signed" : "original")}", ct);
if (!res.IsSuccessStatusCode) throw await SignOtpException.FromAsync(res, ct);
return await res.Content.ReadAsByteArrayAsync(ct);
}
private async Task SendAsync(HttpMethod method, string path, object? body, CancellationToken ct)
{
using var req = new HttpRequestMessage(method, path);
if (body is not null) req.Content = JsonContent.Create(body, options: Json);
using var res = await _api.SendAsync(req, ct);
if (!res.IsSuccessStatusCode) throw await SignOtpException.FromAsync(res, ct);
return (await res.Content.ReadFromJsonAsync(Json, ct))!;
}
public void Dispose()
{
_api.Dispose();
_upload.Dispose();
}
}
/// Verifica delle callback ricevute da SignOTP.
public static class SignOtpWebhook
{
/// corpo della richiesta così come ricevuto (non riserializzato)
/// header X-SignOTP-Timestamp
/// header X-SignOTP-Signature
/// webhook secret della propria applicazione
public static bool Verify(string rawBody, string? timestamp, string? signature, string secret, int toleranceSeconds = 300)
{
if (string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(signature)) return false;
if (!long.TryParse(timestamp, out var ts)) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > toleranceSeconds) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}"));
var expected = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature));
}
}
public sealed class SignOtpException : Exception
{
public int StatusCode { get; }
public string? Code { get; }
private SignOtpException(int status, string? code, string message) : base(message)
{
StatusCode = status;
Code = code;
}
internal static async Task FromAsync(HttpResponseMessage res, CancellationToken ct)
{
string? code = null, message = null;
try
{
var body = await res.Content.ReadFromJsonAsync(cancellationToken: ct);
var err = body.GetProperty("error");
code = err.GetProperty("code").GetString();
message = err.GetProperty("message").GetString();
}
catch { /* corpo non JSON */ }
return new SignOtpException((int)res.StatusCode, code, message ?? $"HTTP {(int)res.StatusCode}");
}
}
// ---- Modelli ----
public record CreateRequest(string? Text = null, string? Email = null, string? CallbackUrl = null, string? ExistingUid = null);
public record UploadInfo(string Method, string Url, Dictionary Headers, DateTimeOffset ExpiresAt, long MaxBytes);
public record CreateResponse(string Guid, int DocNumber, string FileName, string Status, UploadInfo Upload, string FinalizeUrl);
public record FinalizeResponse(string Guid, int DocNumber, string FileName, string Status, int Pages, string Sha256, string SignUrl);
public record SignedHashes(string Original, string Signed);
public record SignedCallback(
string Event, string Guid, int DocNumber, string FileName, DateTimeOffset SignedAt,
string Phone, string Ip, SignedHashes Sha256,
string SignedPdfUrl, DateTimeOffset SignedPdfUrlExpiresAt, string DownloadUrl);