Public Access
A Spelunk API key reaches one organisation, so a server elsewhere - oc-linux in Orange Climate - was refused before it reached the agent. Each device now signs in to a Spelunk once, under Settings -> Security -> Spelunk or from a Spelunk host's editor: the OAuth authorization code flow with PKCE for a native app (RFC 8252), with Spelunk as the authorization server. The browser opens Spelunk's /api/app-sign-in/authorize with a loopback redirect (the app's own scheme on Android), the person signs in there however they normally do and allows "DodoSSH on <device>", and the code is redeemed at /api/app-sign-in/token for an app session token. That token opens terminals in every organisation its person is in, with the role they have on each server at the time; tunnel tickets send no organisation. The sign-in stays on the device: sealed in the local cache under the LocalCacheKey (AAD resource type 18, bound to its row), never synced, wiped with the cache, and only ever sent to the Spelunk it came from. A 401 from Spelunk forgets it; SIGN OUT ends it there and here, and so does signing out of DodoSSH. Before every Spelunk connection DodoSSH asks its own identity provider whether its sign-in still stands (a forced refresh, at most once a minute), and an account it refuses is signed out of DodoSSH and of every Spelunk on the device. A route names no key any more: Spelunk hosts are written at host schema 11, which an older build shows read-only and asks to be updated for, and a payload that still names a key credential decodes with the id skipped. The SpelunkKey item type is gone: SyncEntityType 14, ChangeEntityType 14 and AAD type 17 are retired, the server drops spelunk_key and its change-log rows, and the client cache drops its cached items, outbox entries and conflicts of that type. A link still never starts a sign-in: from a Spelunk this device is not signed in to it opens the editor, which offers to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
167 lines
6.6 KiB
C#
167 lines
6.6 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text.Json;
|
|
using DodoSSH.Crypto;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace DodoSSH.Client.Storage;
|
|
|
|
/// <summary>This device's sign-in to one Spelunk, as <see cref="SpelunkSignInStore"/> keeps it.</summary>
|
|
/// <param name="ApiBase">The Spelunk API's base address, normalised as a host's route stores it.</param>
|
|
/// <param name="Token">What Spelunk issued this device: sent as the bearer token on every ticket request.</param>
|
|
/// <param name="SessionId">Spelunk's id for this sign-in, which its person sees under Your account.</param>
|
|
/// <param name="Email">Who it is signed in as, for saying so.</param>
|
|
/// <param name="DisplayName">The same, as Spelunk names them.</param>
|
|
/// <param name="SignedInAtUtc">When this device signed in.</param>
|
|
public sealed record SpelunkSignInRecord(
|
|
string ApiBase,
|
|
string Token,
|
|
Guid SessionId,
|
|
string Email,
|
|
string DisplayName,
|
|
DateTimeOffset SignedInAtUtc)
|
|
{
|
|
/// <summary>Keeps the token out of logs and debugger views.</summary>
|
|
public override string ToString() => $"{Email} at {ApiBase}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// The Spelunks this device is signed in to, one sign-in each, sealed under the LocalCacheKey — see
|
|
/// <see cref="SpelunkSignInRow"/> for why per device and why sealed whole.
|
|
/// </summary>
|
|
public sealed class SpelunkSignInStore(
|
|
IDbContextFactory<ClientCacheContext> contexts,
|
|
LocalCacheProtector protector,
|
|
TimeProvider clock)
|
|
{
|
|
/// <summary>Every sign-in this key can open. One that will not open is skipped, as a stale cache record is.</summary>
|
|
public async Task<IReadOnlyList<SpelunkSignInRecord>> ListAsync(CancellationToken cancellationToken)
|
|
{
|
|
var context = contexts.CreateDbContext();
|
|
await using var scope = context.ConfigureAwait(false);
|
|
|
|
var rows = await context.Set<SpelunkSignInRow>()
|
|
.AsNoTracking()
|
|
.ToListAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return rows
|
|
.Select(Open)
|
|
.OfType<SpelunkSignInRecord>()
|
|
.OrderBy(record => record.ApiBase, StringComparer.Ordinal)
|
|
.ToList();
|
|
}
|
|
|
|
/// <summary>The sign-in for a Spelunk, or null when this device is not signed in to it.</summary>
|
|
public async Task<SpelunkSignInRecord?> FindAsync(string apiBase, CancellationToken cancellationToken) =>
|
|
(await ListAsync(cancellationToken).ConfigureAwait(false))
|
|
.FirstOrDefault(record => string.Equals(record.ApiBase, apiBase, StringComparison.Ordinal));
|
|
|
|
/// <summary>Keeps a sign-in, replacing any this device had for the same Spelunk.</summary>
|
|
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<SpelunkSignInRow>().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);
|
|
}
|
|
|
|
/// <summary>Forgets the sign-in for a Spelunk, if this device had one.</summary>
|
|
public async Task ForgetAsync(string apiBase, CancellationToken cancellationToken)
|
|
{
|
|
var context = contexts.CreateDbContext();
|
|
await using var scope = context.ConfigureAwait(false);
|
|
|
|
var rows = await context.Set<SpelunkSignInRow>().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);
|
|
}
|
|
|
|
/// <summary>Forgets every Spelunk sign-in on this device.</summary>
|
|
public async Task ForgetAllAsync(CancellationToken cancellationToken)
|
|
{
|
|
var context = contexts.CreateDbContext();
|
|
await using var scope = context.ConfigureAwait(false);
|
|
|
|
await context.Set<SpelunkSignInRow>().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;
|
|
}
|
|
}
|
|
}
|