Public Access
Spelunk sign-in: end it with a refused DodoSSH resume, keep the token the check rotated, and send the token by the sign-in's own address
A DodoSSH resume the provider refuses now signs the device out of every Spelunk as well, as the check before a Spelunk connection already did, so the README's "an account the provider no longer signs in is signed out of DodoSSH and of every Spelunk" holds whichever finds it first. The forced refresh that check makes is written down at once rather than at the next sync pass, so a crash in between cannot cost a browser sign-in on the next launch. The tunnel is opened at the sign-in's own address rather than the route's: the same string, but the token going where it came from is now said where it is sent. Relaunches in the shell tests use the scripted Spelunk sign-in too, so none can open a browser or send a sign-out anywhere real. Fixes a doc comment the account-refused handler had split, a README line still pointing at the key picker, and the Android composition's comment on which redirect Spelunk allows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
57d3d3c801
commit
abd7e56bb5
7 files changed
+84
-15
No files matched your search
@@ -487,7 +487,7 @@ ticking one unticks the other.
|
||||
allowed on that server, a server Spelunk does not know, an agent that is not connected, an agent that could
|
||||
not reach the SSH port or is too old to open terminals.
|
||||
|
||||
**Sign in through Spelunk (no SSH key needed)**, the box under the key in the same card, lets Spelunk log
|
||||
**Sign in through Spelunk (no SSH key needed)**, the last box in the same card, lets Spelunk log
|
||||
you in instead of an SSH key or password of the host's own. For each connection DodoSSH makes a throwaway
|
||||
Ed25519 key pair in memory and sends the public half with the ticket request, which your sign-in authorises;
|
||||
the agent runs a one-connection `sshd -i` on the server whose only authorised key is that one, and says
|
||||
|
||||
@@ -274,7 +274,8 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
||||
updates: updates,
|
||||
|
||||
// Signing in to Spelunk comes back the way signing in to DodoSSH does — by intent, to the same
|
||||
// registered redirect — and names this phone by its model, for difference 5's reason.
|
||||
// registered scheme, at the dev.dodotech.dodossh:/callback Spelunk allows — and names this phone as
|
||||
// the account's device list does, for difference 5's reason.
|
||||
spelunkAccounts: new SpelunkAccounts(
|
||||
browser,
|
||||
path => new AndroidRedirectCallback(path),
|
||||
|
||||
@@ -3844,16 +3844,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
catch (OidcException exception) when (IsRefusedGrant(exception))
|
||||
{
|
||||
// The provider answered and said no: the session was revoked, or the token was rotated and
|
||||
// this machine kept the old one. Retrying costs a round trip a minute and can only ever get
|
||||
// the same answer, so the token goes and the user is told the one thing that fixes it.
|
||||
await ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
var message = $"Your sign-in has expired, so this machine is offline: {exception.Message} "
|
||||
+ "Sign in again from Preferences to start syncing.";
|
||||
|
||||
vault.OfflineReason = message;
|
||||
Announce(message);
|
||||
await OnResumeRefusedAsync(vault, exception, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -3872,6 +3863,45 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The provider answered a resume and said no.</summary>
|
||||
/// <remarks>
|
||||
/// The session was revoked, or the token was rotated and this machine kept the old one. Retrying costs a
|
||||
/// round trip a minute and can only ever get the same answer, so the token goes and the user is told the one
|
||||
/// thing that fixes it.
|
||||
/// </remarks>
|
||||
private async Task OnResumeRefusedAsync(VaultViewModel vault, OidcException exception, CancellationToken cancellationToken)
|
||||
{
|
||||
await ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// And with it every Spelunk this device is signed in to, as when the vault finds the account refused
|
||||
// before a Spelunk connection: a sign-in there is only ever used on behalf of a DodoSSH account that
|
||||
// still stands, and this one, for now, does not.
|
||||
var spelunks = await SignOutOfEverySpelunkQuietlyAsync(vault).ConfigureAwait(true);
|
||||
|
||||
var message = $"Your sign-in has expired, so this machine is offline: {exception.Message} "
|
||||
+ (spelunks ? "This device has been signed out of Spelunk too. " : string.Empty)
|
||||
+ "Sign in again from Preferences to start syncing.";
|
||||
|
||||
vault.OfflineReason = message;
|
||||
Announce(message);
|
||||
}
|
||||
|
||||
/// <summary>Signs out of every Spelunk, for a DodoSSH sign-in the provider refused; whether there were any.</summary>
|
||||
/// <remarks>Never throws: it runs where the refusal is already being reported, and a cache that will not take it is wiped on sign-out anyway.</remarks>
|
||||
private static async Task<bool> SignOutOfEverySpelunkQuietlyAsync(VaultViewModel vault)
|
||||
{
|
||||
try
|
||||
{
|
||||
var any = vault.HasSpelunkSignIns;
|
||||
await vault.SignOutOfEverySpelunkAsync(CancellationToken.None).ConfigureAwait(true);
|
||||
return any;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a failed resume was the provider refusing the token, rather than failing to answer.
|
||||
/// </summary>
|
||||
@@ -3913,7 +3943,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drops the remembered sign-in, so nothing tries to resume it again.</summary>
|
||||
/// <summary>
|
||||
/// The identity provider refused this account when the vault checked it — before a Spelunk connection.
|
||||
/// </summary>
|
||||
@@ -3941,6 +3970,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
RaiseSyncState();
|
||||
}
|
||||
|
||||
/// <summary>Drops the remembered sign-in, so nothing tries to resume it again.</summary>
|
||||
private async Task ForgetSignInAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
rememberedToken = null;
|
||||
|
||||
@@ -185,9 +185,11 @@ internal sealed partial class VaultViewModel
|
||||
|
||||
try
|
||||
{
|
||||
// The sign-in's own address rather than the route's, though they are one string: the token goes to
|
||||
// the Spelunk that issued it, and that is said here rather than left to the lookup that found it.
|
||||
return await Spelunk
|
||||
.OpenAsync(
|
||||
route.ApiBase,
|
||||
signIn.ApiBase,
|
||||
route.ServerId,
|
||||
signIn.Token,
|
||||
row.Resolved.Port.Value,
|
||||
|
||||
@@ -219,6 +219,11 @@ internal sealed partial class VaultViewModel
|
||||
try
|
||||
{
|
||||
await server.VerifySignInAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// Asked again only for what asking does to a connection already held: the shell writes down the
|
||||
// refresh token the check may just have rotated, which is the only one the next launch can use.
|
||||
// Left for the next sync pass, a crash in between would cost a browser sign-in.
|
||||
await ResolveServerAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch (OidcException exception) when (string.Equals(exception.ErrorCode, "invalid_grant", StringComparison.Ordinal))
|
||||
{
|
||||
|
||||
@@ -680,6 +680,33 @@ public sealed partial class ShellFlowTests
|
||||
shell.IsOnline.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ADodoSshSignInRefusedOnResume_SignsThisDeviceOutOfEverySpelunkToo()
|
||||
{
|
||||
await ReadyToConnectThroughSpelunkAsync();
|
||||
await shell.Vault!.SyncOnOpenAsync(Token);
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
spelunkAccounts.SignedOut.ShouldBeEmpty();
|
||||
|
||||
resumeFailure = new OidcException("The token endpoint returned 400: Account disabled.", "invalid_grant");
|
||||
|
||||
var relaunch = Relaunch(resume: ResumeAsync);
|
||||
await using var _ = relaunch.ConfigureAwait(false);
|
||||
|
||||
await relaunch.StartAsync(Token);
|
||||
relaunch.Passphrase = Passphrase;
|
||||
await relaunch.UnlockCommand.ExecuteAsync(null);
|
||||
await relaunch.Vault.ShouldNotBeNull().SyncOnOpenAsync(Token);
|
||||
|
||||
// The token Spelunk is told to end is the one the first launch kept: the sign-in outlived the lock and
|
||||
// the relaunch, and went only when DodoSSH's own did.
|
||||
relaunch.IsOnline.ShouldBeFalse();
|
||||
spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]);
|
||||
relaunch.Vault.SpelunkSignIns.ShouldBeEmpty();
|
||||
(await relaunch.Vault.Session.SpelunkSignIns.ListAsync(Token)).ShouldBeEmpty();
|
||||
relaunch.Vault.Status.ShouldNotBeNull().ShouldContain("signed out of Spelunk");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SigningOutOfSpelunk_OnTheSecurityPage_EndsItThereAndHere()
|
||||
{
|
||||
|
||||
@@ -9666,7 +9666,11 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
TimeProvider.System,
|
||||
ssh,
|
||||
CheapProfile,
|
||||
resume);
|
||||
resume,
|
||||
|
||||
// The same stand-in as the first launch's, so a relaunch neither opens a browser at Spelunk nor
|
||||
// sends a sign-out anywhere real.
|
||||
spelunkAccounts: spelunkAccounts);
|
||||
|
||||
private async Task SignedInAsync()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user