diff --git a/README.md b/README.md
index 6046052..eb4c601 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/src/DodoSSH.Client.Android/App.axaml.cs b/src/DodoSSH.Client.Android/App.axaml.cs
index 9d70f23..38a93f1 100644
--- a/src/DodoSSH.Client.Android/App.axaml.cs
+++ b/src/DodoSSH.Client.Android/App.axaml.cs
@@ -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),
diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
index 3b39856..a5d9aa2 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
@@ -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
}
}
+ /// The provider answered a resume 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.
+ ///
+ 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);
+ }
+
+ /// Signs out of every Spelunk, for a DodoSSH sign-in the provider refused; whether there were any.
+ /// 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.
+ private static async Task 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;
+ }
+ }
+
///
/// Whether a failed resume was the provider refusing the token, rather than failing to answer.
///
@@ -3913,7 +3943,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
}
- /// Drops the remembered sign-in, so nothing tries to resume it again.
///
/// The identity provider refused this account when the vault checked it — before a Spelunk connection.
///
@@ -3941,6 +3970,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
RaiseSyncState();
}
+ /// Drops the remembered sign-in, so nothing tries to resume it again.
private async Task ForgetSignInAsync(CancellationToken cancellationToken)
{
rememberedToken = null;
diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.Spelunk.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.Spelunk.cs
index e3ce1f4..8bd956f 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.Spelunk.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.Spelunk.cs
@@ -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,
diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.SpelunkSignIns.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.SpelunkSignIns.cs
index a128cdf..6598716 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.SpelunkSignIns.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.SpelunkSignIns.cs
@@ -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))
{
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Spelunk.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Spelunk.cs
index 74910b2..1e5905b 100644
--- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Spelunk.cs
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Spelunk.cs
@@ -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()
{
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
index ba9a299..1de2636 100644
--- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
@@ -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()
{