Merge branch 'main' into claude/m3-implementation-57f9d7
ci / build and test (push) Failing after 2s

Three files conflicted, and two of the resolutions are more than a choice of
side.

QuickConnectTests had both branches fixing the same build break — main's M2
merge left the shell's constructor with an ISftpSessionFactory nobody passed.
Main's version wins because it carries a comment saying why the palette never
needs a session.

VaultSession's conflict is adjacent edits: main added the remembered sign-in
members and this branch changed SyncAsync's summary from "the active vault" to
"one vault". Both kept.

VaultViewModel is the one that matters. Main taught the background pass to
report a sync that had to start over, on the grounds that a machine which
silently re-read a whole vault has had something happen to it; this branch
turned a pass into one report per readable vault. Taking either side alone
would have lost the other, so ResyncedFromStart is now one of the conditions
IsWorthReporting checks, per vault.

Merging also broke something neither branch could have caught alone, and the
build would not have said a word. SyncOnceAsync cleared LastSyncFailed
unconditionally, which was right while a pass was one vault and a failure was
an exception that never reached that line. A failure is now a report — one
unreachable team vault must not stop the others syncing — so the flag was being
cleared over a vault that had just failed, lighting the titlebar SYNCED. It is
computed from the report instead, in the one place both callers go through, so
the manual command gets it as well as the loop. The background pass still
swallows the message and keeps the fact, which is what
AnAutomaticPassThatFails_LeavesTheStatusAlone is there to hold it to.

Two comments the auto-merge left describing a world with one vault in it: the
SCOPES rail's, which said team vaults are refused by the access service, and
the host sidebar's "One heading, for one vault".
This commit is contained in:
2026-07-31 12:26:59 +02:00
42 changed files with 3708 additions and 155 deletions
+31
View File
@@ -78,6 +78,37 @@ internal sealed class UnlockMaterialRow
public DateTimeOffset UpdatedAtUtc { get; set; }
}
/// <summary>
/// The sign-in this machine may resume without opening a browser.
/// </summary>
/// <remarks>
/// <para>
/// A single row, like <see cref="UnlockMaterialRow"/> and for the same reason: one cache holds one
/// account.
/// </para>
/// <para>
/// <b>The token is sealed under the LocalCacheKey, which is the whole point of storing it here.</b> A
/// refresh token is a long-lived credential for the account — not for the vault, which nothing but the
/// passphrase opens — so a copy of this file lifted off a stolen laptop must not be one. Sealing it under
/// a key that exists only while the vault is unlocked means the sign-in can only be resumed by somebody
/// who has already opened the vault, which is exactly the moment the application wants it: unlock, then
/// come back online by itself. It also means a locked machine cannot reach the server at all, which is a
/// consequence worth stating rather than a limitation to work around.
/// </para>
/// </remarks>
internal sealed class RememberedSignInRow
{
/// <summary>The only legal primary key.</summary>
internal const int SingletonId = 1;
public int Id { get; set; } = SingletonId;
/// <summary>The refresh token, sealed under the LocalCacheKey. Ciphertext.</summary>
public byte[] SealedRefreshToken { get; set; } = [];
public DateTimeOffset UpdatedAtUtc { get; set; }
}
/// <summary>A vault the user can reach, with the grant that opens it.</summary>
/// <remarks>
/// Cached so the vault list and the key needed to decrypt it are both available offline. The name is
@@ -73,6 +73,7 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
ArgumentNullException.ThrowIfNull(modelBuilder);
ConfigureUnlockMaterial(modelBuilder);
ConfigureRememberedSignIn(modelBuilder);
ConfigureVaults(modelBuilder);
ConfigureItems(modelBuilder);
ConfigureOutbox(modelBuilder);
@@ -102,6 +103,26 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
entity.Property(row => row.KdfSalt).IsRequired();
});
/// <remarks>
/// A table of its own rather than two more columns on <c>unlock_material</c>, because the two rows have
/// opposite lifetimes: the unlock material is what makes this machine work offline and must survive
/// everything short of a reset, while a remembered sign-in is dropped the moment the server stops
/// accepting it. Deleting one must never be able to take the other with it.
/// </remarks>
private static void ConfigureRememberedSignIn(ModelBuilder modelBuilder) =>
modelBuilder.Entity<RememberedSignInRow>(entity =>
{
entity.ToTable(
"remembered_sign_in",
table => table.HasCheckConstraint(
"ck_remembered_sign_in_singleton",
$"id = {RememberedSignInRow.SingletonId}"));
entity.HasKey(row => row.Id);
entity.Property(row => row.Id).ValueGeneratedNever();
entity.Property(row => row.SealedRefreshToken).IsRequired();
});
private static void ConfigureVaults(ModelBuilder modelBuilder) =>
modelBuilder.Entity<CachedVaultRow>(entity =>
{
@@ -123,6 +123,59 @@ public sealed class ClientCacheFactory : IDbContextFactory<ClientCacheContext>,
await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Empties the cache: every row of every table, and the pages they were written on.
/// </summary>
/// <remarks>
/// <para>
/// What signing out means on disk. The profile, the wrapped bundle, the item mirror, the outbox and
/// the conflict log all go; the schema stays, so the application is usable again immediately and does
/// not have to be restarted to be set up afresh.
/// </para>
/// <para>
/// <b>Emptied rather than deleted, and then vacuumed.</b> Deleting the file is the obvious move and is
/// worse here: the database is in WAL mode, so it is three files rather than one — a routine that
/// removes <c>cache.db</c> and leaves <c>-wal</c> behind loses to a checkpoint that puts some of it
/// back — and on Windows the pooled connections hold the file open, so the delete fails outright while
/// the application is running. The <c>VACUUM</c> is the half that makes this a wipe rather than a
/// hide: SQLite marks deleted pages free without overwriting them, so ciphertext and sealed records
/// would otherwise stay legible in the file until something happened to reuse the page.
/// </para>
/// <para>
/// The migrations history is deliberately left alone. It describes the shape of the tables, not the
/// user, and clearing it would make the next launch try to apply every migration to a schema that
/// already has them.
/// </para>
/// </remarks>
public async Task ResetAsync(CancellationToken cancellationToken)
{
var context = CreateDbContext();
await using var scope = context.ConfigureAwait(false);
// Taken from the model rather than listed here, so a table added later is emptied by a sign-out
// without anyone having to remember this method exists.
var tables = context.Model.GetEntityTypes()
.Select(entityType => entityType.GetTableName())
.OfType<string>()
.Distinct(StringComparer.Ordinal);
foreach (var table in tables)
{
// A table name cannot be a parameter, so it is quoted rather than bound. The value comes from
// this assembly's own model metadata and never from input; the doubling is what keeps that
// true of a name somebody eventually writes with a quote in it.
var sql = string.Concat("DELETE FROM \"", table.Replace("\"", "\"\"", StringComparison.Ordinal), "\"");
// EF1002 and CA2100 both describe interpolating a value into SQL, which is what the two lines
// above are; the value is the one thing here that cannot come from a user.
#pragma warning disable EF1002, CA2100
await context.Database.ExecuteSqlRawAsync(sql, cancellationToken).ConfigureAwait(false);
#pragma warning restore EF1002, CA2100
}
await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public void Dispose()
{
@@ -0,0 +1,440 @@
// <auto-generated />
using System;
using DodoSSH.Client.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DodoSSH.Client.Storage.Migrations
{
[DbContext(typeof(ClientCacheContext))]
[Migration("20260731082424_AddRememberedSignIn")]
partial class AddRememberedSignIn
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<byte>("AadVersion")
.HasColumnType("INTEGER")
.HasColumnName("aad_version");
b.Property<long>("ChangeSequence")
.HasColumnType("INTEGER")
.HasColumnName("change_sequence");
b.Property<Guid?>("DataKeyId")
.HasColumnType("TEXT")
.HasColumnName("data_key_id");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER")
.HasColumnName("is_deleted");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<byte[]>("Payload")
.HasColumnType("BLOB")
.HasColumnName("payload");
b.Property<byte[]>("ProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("protected_fields");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<int>("Version")
.HasColumnType("INTEGER")
.HasColumnName("version");
b.Property<byte[]>("WrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_data_key");
b.HasKey("VaultId", "EntityType", "EntityId")
.HasName("pk_item");
b.HasIndex("VaultId", "ChangeSequence")
.HasDatabaseName("ix_item_vault_id_change_sequence");
b.HasIndex("VaultId", "EntityType")
.HasDatabaseName("ix_item_vault_id_entity_type");
b.ToTable("item", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<bool>("IsPersonal")
.HasColumnType("INTEGER")
.HasColumnName("is_personal");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("Permissions")
.HasColumnType("INTEGER")
.HasColumnName("permissions");
b.Property<bool>("RekeyRequired")
.HasColumnType("INTEGER")
.HasColumnName("rekey_required");
b.Property<Guid?>("TeamId")
.HasColumnType("TEXT")
.HasColumnName("team_id");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<byte[]>("WrappedVaultKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_vault_key");
b.HasKey("VaultId")
.HasName("pk_vault");
b.ToTable("vault", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Acknowledged")
.HasColumnType("INTEGER")
.HasColumnName("acknowledged");
b.Property<byte[]>("Detail")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("detail");
b.Property<long>("DetectedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("detected_at_utc");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<int>("Kind")
.HasColumnType("INTEGER")
.HasColumnName("kind");
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.HasKey("Id")
.HasName("pk_conflict");
b.HasIndex("VaultId", "Acknowledged")
.HasDatabaseName("ix_conflict_vault_id_acknowledged");
b.HasIndex("VaultId", "EntityType", "EntityId")
.HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
b.ToTable("conflict", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
{
b.Property<long>("Sequence")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("sequence");
b.Property<byte>("AadVersion")
.HasColumnType("INTEGER")
.HasColumnName("aad_version");
b.Property<byte?>("AncestorAadVersion")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_aad_version");
b.Property<Guid?>("AncestorDataKeyId")
.HasColumnType("TEXT")
.HasColumnName("ancestor_data_key_id");
b.Property<uint?>("AncestorKeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_key_generation");
b.Property<byte[]>("AncestorPayload")
.HasColumnType("BLOB")
.HasColumnName("ancestor_payload");
b.Property<byte[]>("AncestorProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("ancestor_protected_fields");
b.Property<int?>("AncestorVersion")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_version");
b.Property<byte[]>("AncestorWrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("ancestor_wrapped_data_key");
b.Property<int>("Attempts")
.HasColumnType("INTEGER")
.HasColumnName("attempts");
b.Property<Guid?>("DataKeyId")
.HasColumnType("TEXT")
.HasColumnName("data_key_id");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<int?>("ExpectedVersion")
.HasColumnType("INTEGER")
.HasColumnName("expected_version");
b.Property<bool>("IsParked")
.HasColumnType("INTEGER")
.HasColumnName("is_parked");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("LastError")
.HasColumnType("TEXT")
.HasColumnName("last_error");
b.Property<int>("Operation")
.HasColumnType("INTEGER")
.HasColumnName("operation");
b.Property<Guid>("OperationId")
.HasColumnType("TEXT")
.HasColumnName("operation_id");
b.Property<byte[]>("Payload")
.HasColumnType("BLOB")
.HasColumnName("payload");
b.Property<byte[]>("ProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("protected_fields");
b.Property<long>("QueuedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("queued_at_utc");
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<byte[]>("WrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_data_key");
b.HasKey("Sequence")
.HasName("pk_outbox");
b.HasIndex("OperationId")
.IsUnique()
.HasDatabaseName("ix_outbox_operation_id");
b.HasIndex("VaultId", "EntityType", "EntityId")
.IsUnique()
.HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
b.HasIndex("VaultId", "IsParked", "Sequence")
.HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
b.ToTable("outbox", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<byte[]>("SealedRefreshToken")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("sealed_refresh_token");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.HasKey("Id")
.HasName("pk_remembered_sign_in");
b.ToTable("remembered_sign_in", null, t =>
{
t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
});
});
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<string>("Cursor")
.HasColumnType("TEXT")
.HasColumnName("cursor");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<long?>("LastPulledAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("last_pulled_at_utc");
b.Property<long?>("LastPushedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("last_pushed_at_utc");
b.Property<long>("ServerTimeSkewMs")
.HasColumnType("INTEGER")
.HasColumnName("server_time_skew_ms");
b.HasKey("VaultId")
.HasName("pk_sync_state");
b.ToTable("sync_state", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<Guid?>("DeviceId")
.HasColumnType("TEXT")
.HasColumnName("device_id");
b.Property<byte[]>("DeviceWrappedPrivateKey")
.HasColumnType("BLOB")
.HasColumnName("device_wrapped_private_key");
b.Property<string>("DisplayName")
.HasColumnType("TEXT")
.HasColumnName("display_name");
b.Property<string>("Email")
.HasColumnType("TEXT")
.HasColumnName("email");
b.Property<string>("Issuer")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("issuer");
b.Property<string>("KdfAlgorithm")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("kdf_algorithm");
b.Property<int>("KdfMemoryKibibytes")
.HasColumnType("INTEGER")
.HasColumnName("kdf_memory_kibibytes");
b.Property<int>("KdfParallelism")
.HasColumnType("INTEGER")
.HasColumnName("kdf_parallelism");
b.Property<int>("KdfPasses")
.HasColumnType("INTEGER")
.HasColumnName("kdf_passes");
b.Property<byte[]>("KdfSalt")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("kdf_salt");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("ServerUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("server_url");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subject");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<Guid>("UserId")
.HasColumnType("TEXT")
.HasColumnName("user_id");
b.Property<byte[]>("WrappedPrivateKey")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("wrapped_private_key");
b.HasKey("Id")
.HasName("pk_unlock_material");
b.ToTable("unlock_material", null, t =>
{
t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
});
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DodoSSH.Client.Storage.Migrations
{
/// <inheritdoc />
public partial class AddRememberedSignIn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "remembered_sign_in",
columns: table => new
{
id = table.Column<int>(type: "INTEGER", nullable: false),
sealed_refresh_token = table.Column<byte[]>(type: "BLOB", nullable: false),
updated_at_utc = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_remembered_sign_in", x => x.id);
table.CheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "remembered_sign_in");
}
}
}
@@ -291,6 +291,30 @@ namespace DodoSSH.Client.Storage.Migrations
b.ToTable("outbox", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<byte[]>("SealedRefreshToken")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("sealed_refresh_token");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.HasKey("Id")
.HasName("pk_remembered_sign_in");
b.ToTable("remembered_sign_in", null, t =>
{
t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
});
});
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
{
b.Property<Guid>("VaultId")
@@ -0,0 +1,122 @@
using System.Security.Cryptography;
using System.Text;
using DodoSSH.Crypto;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Client.Storage;
/// <summary>
/// The sign-in this machine may resume without opening a browser.
/// </summary>
/// <remarks>
/// <para>
/// One refresh token, sealed under the LocalCacheKey and bound to the user it belongs to. Everything about
/// why it is sealed rather than stored — and what a locked machine therefore cannot do — is on
/// <see cref="RememberedSignInRow"/>.
/// </para>
/// <para>
/// The server it belongs to is deliberately not recorded here: <c>unlock_material</c> already holds it, and
/// two copies of one fact is two facts that can disagree. A cache holds one account and one server.
/// </para>
/// </remarks>
public sealed class RememberedSignInStore(
IDbContextFactory<ClientCacheContext> contexts,
LocalCacheProtector protector,
Guid userId,
TimeProvider clock)
{
/// <summary>Remembers a refresh token, replacing whatever was there.</summary>
/// <remarks>
/// Called again whenever the provider rotates the token. Keeping the one this client first received
/// would leave a rotating provider refusing the next launch, which is the failure that reads as "the
/// application randomly signs me out".
/// </remarks>
public async Task SaveAsync(string refreshToken, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(refreshToken);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<RememberedSignInRow>()
.SingleOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
row = new RememberedSignInRow();
context.Add(row);
}
var bytes = Encoding.UTF8.GetBytes(refreshToken);
try
{
row.SealedRefreshToken = protector.Protect(
CryptoSpec.AadResourceType.User, userId, bytes);
}
finally
{
// The managed copy this method made, not the string it was handed — see the remark on
// ReadAsync for what a .NET string does and does not allow here.
CryptographicOperations.ZeroMemory(bytes);
}
row.UpdatedAtUtc = clock.GetUtcNow();
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Reads the remembered token, or null when there is none this key can open.
/// </summary>
/// <remarks>
/// <para>
/// Null rather than an exception for a record that will not open, on the same reasoning as
/// <see cref="LocalCacheProtector.TryUnprotect"/>: a cache written under a different identity is an
/// ordinary situation and the answer is to sign in again, not to fail.
/// </para>
/// <para>
/// It comes back as a <see cref="string"/>, which cannot be wiped. That is the same bargain the private
/// key and password editors already make — every HTTP client on the way to the token endpoint wants a
/// string — and pretending otherwise with a <c>SecureString</c> would buy nothing this process's memory
/// does not already give away.
/// </para>
/// </remarks>
public async Task<string?> ReadAsync(CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<RememberedSignInRow>()
.AsNoTracking()
.SingleOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
return null;
}
var opened = protector.TryUnprotect(
CryptoSpec.AadResourceType.User, userId, row.SealedRefreshToken);
return opened is null ? null : Encoding.UTF8.GetString(opened);
}
/// <summary>Forgets the remembered sign-in, so the next launch has to use a browser.</summary>
/// <remarks>
/// Used when the provider refuses the token — a revoked session, a rotation this machine missed — as
/// well as when the user signs out. Keeping a token that has already been refused would mean retrying
/// it once a minute for the life of the profile.
/// </remarks>
public async Task ForgetAsync(CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
await context.Set<RememberedSignInRow>()
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
}
}