using System.Security.Cryptography;
using System.Text.Json;
using DodoSSH.Crypto;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Client.Storage;
/// This device's sign-in to one Spelunk, as keeps it.
/// The Spelunk API's base address, normalised as a host's route stores it.
/// What Spelunk issued this device: sent as the bearer token on every ticket request.
/// Spelunk's id for this sign-in, which its person sees under Your account.
/// Who it is signed in as, for saying so.
/// The same, as Spelunk names them.
/// When this device signed in.
public sealed record SpelunkSignInRecord(
string ApiBase,
string Token,
Guid SessionId,
string Email,
string DisplayName,
DateTimeOffset SignedInAtUtc)
{
/// Keeps the token out of logs and debugger views.
public override string ToString() => $"{Email} at {ApiBase}";
}
///
/// The Spelunks this device is signed in to, one sign-in each, sealed under the LocalCacheKey — see
/// for why per device and why sealed whole.
///
public sealed class SpelunkSignInStore(
IDbContextFactory contexts,
LocalCacheProtector protector,
TimeProvider clock)
{
/// Every sign-in this key can open. One that will not open is skipped, as a stale cache record is.
public async Task> ListAsync(CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var rows = await context.Set()
.AsNoTracking()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows
.Select(Open)
.OfType()
.OrderBy(record => record.ApiBase, StringComparer.Ordinal)
.ToList();
}
/// The sign-in for a Spelunk, or null when this device is not signed in to it.
public async Task FindAsync(string apiBase, CancellationToken cancellationToken) =>
(await ListAsync(cancellationToken).ConfigureAwait(false))
.FirstOrDefault(record => string.Equals(record.ApiBase, apiBase, StringComparison.Ordinal));
/// Keeps a sign-in, replacing any this device had for the same Spelunk.
public async Task SaveAsync(SpelunkSignInRecord record, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(record);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var rows = await context.Set().ToListAsync(cancellationToken).ConfigureAwait(false);
context.RemoveRange(rows.Where(row => Open(row) is not { } open || string.Equals(open.ApiBase, record.ApiBase, StringComparison.Ordinal)));
var id = Guid.CreateVersion7();
var bytes = Serialize(record);
try
{
context.Add(new SpelunkSignInRow
{
Id = id,
Sealed = protector.Protect(CryptoSpec.AadResourceType.SpelunkSignIn, id, bytes),
UpdatedAtUtc = clock.GetUtcNow(),
});
}
finally
{
CryptographicOperations.ZeroMemory(bytes);
}
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
/// Forgets the sign-in for a Spelunk, if this device had one.
public async Task ForgetAsync(string apiBase, CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var rows = await context.Set().ToListAsync(cancellationToken).ConfigureAwait(false);
context.RemoveRange(rows.Where(row => Open(row) is not { } open || string.Equals(open.ApiBase, apiBase, StringComparison.Ordinal)));
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
/// Forgets every Spelunk sign-in on this device.
public async Task ForgetAllAsync(CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
await context.Set().ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
}
private SpelunkSignInRecord? Open(SpelunkSignInRow row)
{
var opened = protector.TryUnprotect(CryptoSpec.AadResourceType.SpelunkSignIn, row.Id, row.Sealed);
if (opened is null)
{
return null;
}
try
{
return Deserialize(opened);
}
finally
{
CryptographicOperations.ZeroMemory(opened);
}
}
// Written by hand rather than through a serializer context: six fields, and nothing here should depend
// on reflection the Android build trims away.
private static byte[] Serialize(SpelunkSignInRecord record)
{
using var buffer = new MemoryStream();
using (var writer = new Utf8JsonWriter(buffer))
{
writer.WriteStartObject();
writer.WriteString("api", record.ApiBase);
writer.WriteString("token", record.Token);
writer.WriteString("session", record.SessionId);
writer.WriteString("email", record.Email);
writer.WriteString("name", record.DisplayName);
writer.WriteString("at", record.SignedInAtUtc);
writer.WriteEndObject();
}
return buffer.ToArray();
}
private static SpelunkSignInRecord? Deserialize(byte[] bytes)
{
try
{
using var document = JsonDocument.Parse(bytes);
var root = document.RootElement;
return new SpelunkSignInRecord(
root.GetProperty("api").GetString()!,
root.GetProperty("token").GetString()!,
root.GetProperty("session").GetGuid(),
root.GetProperty("email").GetString()!,
root.GetProperty("name").GetString()!,
root.GetProperty("at").GetDateTimeOffset());
}
catch (Exception e) when (e is JsonException or KeyNotFoundException or InvalidOperationException or FormatException)
{
return null;
}
}
}