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() {