using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Calls.Api;
public sealed record CrmUser(string Id, string? Email, string? Name, long Exp);
///
/// Проверка токена CRM (Supabase access_token) и доступа к workspace.
/// Токены не логируются и не хранятся: в кэше ключ — SHA-256 токена.
///
public sealed partial class SupabaseAuth(IHttpClientFactory http, IConfiguration cfg)
{
private readonly string _url = (cfg["Supabase:Url"] ?? "").TrimEnd('/');
private readonly string _key = cfg["Supabase:PublishableKey"] ?? "";
private readonly ConcurrentDictionary _users = new();
private readonly ConcurrentDictionary _ws = new();
private static readonly TimeSpan CacheFor = TimeSpan.FromMinutes(5);
public const string Shared = "shared";
// id пользовательского workspace CRM — текст: 'ws' + base36 (createWorkspace в CRM, напр. wsmv5l7d425re160);
// принимаем и любые похожие текстовые id (в т. ч. uuid). Только безопасные символы — значение идёт в фильтр PostgREST.
[GeneratedRegex("^[A-Za-z0-9_-]{1,64}$")]
private static partial Regex CustomWsRx();
public enum Fail { None, BadToken, Expired, Rejected, Unavailable }
public async Task<(CrmUser? User, Fail Fail)> VerifyAsync(string token, CancellationToken ct)
{
var now = DateTimeOffset.UtcNow;
var key = Hash(token);
if (_users.TryGetValue(key, out var hit) && hit.Until > now && hit.User.Exp > now.ToUnixTimeSeconds())
return (hit.User, Fail.None);
// 1. Разбор JWT: exp и aud.
JsonElement claims;
try
{
var parts = token.Split('.');
if (parts.Length != 3) return (null, Fail.BadToken);
claims = JsonDocument.Parse(Base64Url(parts[1])).RootElement;
}
catch { return (null, Fail.BadToken); }
if (!claims.TryGetProperty("exp", out var expEl) || !expEl.TryGetInt64(out var exp)) return (null, Fail.BadToken);
if (exp <= now.ToUnixTimeSeconds()) return (null, Fail.Expired);
if (!AudIsAuthenticated(claims)) return (null, Fail.Rejected);
// 2. Supabase: GET /auth/v1/user.
HttpResponseMessage r;
try
{
using var req = new HttpRequestMessage(HttpMethod.Get, $"{_url}/auth/v1/user");
req.Headers.Add("apikey", _key);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
r = await http.CreateClient("supabase").SendAsync(req, ct);
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException) { return (null, Fail.Unavailable); }
using (r)
{
if (!r.IsSuccessStatusCode) return (null, (int)r.StatusCode >= 500 ? Fail.Unavailable : Fail.Rejected);
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
var u = doc.RootElement;
if (Str(u, "aud") != "authenticated" || Str(u, "id") is not { } id) return (null, Fail.Rejected);
string? name = null;
if (u.TryGetProperty("user_metadata", out var md) && md.ValueKind == JsonValueKind.Object)
name = Str(md, "name") ?? Str(md, "full_name") ?? Str(md, "display_name");
var user = new CrmUser(id, Str(u, "email"), string.IsNullOrWhiteSpace(name) ? null : name, exp);
_users[key] = (user, now + CacheFor);
if (_users.Count > 5000) Sweep(now);
return (user, Fail.None);
}
}
///
/// Нормализует и проверяет workspace. id из клиента не доверяем:
/// shared — для всех, personal:<uid> — только владельцу, остальные (custom, напр. wsmv5l7d425re160) —
/// запросом к таблице workspaces Supabase с токеном пользователя: RLS отдаёт строку, только если он владелец или участник.
///
public async Task CanAccessAsync(CrmUser user, string token, string workspaceId, CancellationToken ct)
{
if (workspaceId == Shared) return true;
if (workspaceId.StartsWith("personal:", StringComparison.Ordinal))
return workspaceId == $"personal:{user.Id}";
if (!CustomWsRx().IsMatch(workspaceId)) return false;
var now = DateTimeOffset.UtcNow;
var key = user.Id + "|" + workspaceId;
if (_ws.TryGetValue(key, out var hit) && hit.Until > now) return hit.Ok;
bool ok;
try
{
using var req = new HttpRequestMessage(HttpMethod.Get,
$"{_url}/rest/v1/workspaces?id=eq.{Uri.EscapeDataString(workspaceId)}&select=id");
req.Headers.Add("apikey", _key);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var r = await http.CreateClient("supabase").SendAsync(req, ct);
if (!r.IsSuccessStatusCode) return false; // ошибку не кэшируем
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
ok = doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0;
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return false; }
_ws[key] = (ok, now + CacheFor);
return ok;
}
public sealed record WorkspaceInfo(string Id, string Kind, string Title);
///
/// Workspace пользователя для выбора, когда CRM не прислала workspace в crm:auth.
/// Как в CRM (model.js workspaces()): «Мій» (personal:<uid>), «Загальний» (shared) и custom-строки
/// таблицы workspaces, где он владелец или участник (RLS отдаёт только их, фильтр — подстраховка).
/// null — Supabase недоступен.
///
public async Task?> ListWorkspacesAsync(CrmUser user, string token, CancellationToken ct)
{
var list = new List
{
new($"personal:{user.Id}", "personal", "Мій"),
new(Shared, "shared", "Загальний"),
};
try
{
using var req = new HttpRequestMessage(HttpMethod.Get,
$"{_url}/rest/v1/workspaces?select=id,kind,title,owner_uid,members&kind=eq.custom");
req.Headers.Add("apikey", _key);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var r = await http.CreateClient("supabase").SendAsync(req, ct);
// Таблицы может не быть (схему CRM не обновили) — тогда только «Мій» и «Загальний».
if ((int)r.StatusCode == 404) return list;
if (!r.IsSuccessStatusCode) return null;
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
if (doc.RootElement.ValueKind != JsonValueKind.Array) return null;
var custom = new List();
foreach (var w in doc.RootElement.EnumerateArray())
{
if (Str(w, "id") is not { } id || !CustomWsRx().IsMatch(id)) continue;
var mine = Str(w, "owner_uid") == user.Id;
if (!mine && w.TryGetProperty("members", out var m) && m.ValueKind == JsonValueKind.Array)
mine = m.EnumerateArray().Any(x => x.ValueKind == JsonValueKind.String && x.GetString() == user.Id);
if (!mine) continue;
var title = Str(w, "title");
custom.Add(new(id, "custom", string.IsNullOrWhiteSpace(title) ? "Без назви" : title.Trim()));
_ws[user.Id + "|" + id] = (true, DateTimeOffset.UtcNow + CacheFor);
}
list.AddRange(custom.OrderBy(w => w.Title, StringComparer.Create(new System.Globalization.CultureInfo("uk-UA"), true)));
return list;
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return null; }
}
private void Sweep(DateTimeOffset now)
{
foreach (var (k, v) in _users) if (v.Until <= now) _users.TryRemove(k, out _);
foreach (var (k, v) in _ws) if (v.Until <= now) _ws.TryRemove(k, out _);
}
private static bool AudIsAuthenticated(JsonElement claims)
{
if (!claims.TryGetProperty("aud", out var aud)) return false;
if (aud.ValueKind == JsonValueKind.String) return aud.GetString() == "authenticated";
if (aud.ValueKind == JsonValueKind.Array)
foreach (var a in aud.EnumerateArray()) if (a.ValueKind == JsonValueKind.String && a.GetString() == "authenticated") return true;
return false;
}
private static string? Str(JsonElement e, string p) =>
e.TryGetProperty(p, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
private static string Hash(string s) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(s)));
private static byte[] Base64Url(string s)
{
s = s.Replace('-', '+').Replace('_', '/');
return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '='));
}
}