Public Access
Sync: keep the remembered sign-in when the provider is merely down, and say why SYNC NOW found the machine offline
This commit is contained in:
1 parent
5b32c76c95
commit
509937c4b8
5 files changed
+88
-5
No files matched your search
@@ -87,6 +87,11 @@
|
|||||||
<TextBlock Classes="settingsrowtitle" Text="Synchronise" />
|
<TextBlock Classes="settingsrowtitle" Text="Synchronise" />
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption"
|
||||||
Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open." />
|
Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open." />
|
||||||
|
<!-- What SYNC NOW said. The status bar that carries it elsewhere is hidden in settings
|
||||||
|
mode, so without this the button ran, failed or found the machine offline in silence. -->
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
IsVisible="{Binding Vault.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}"
|
||||||
|
Text="{Binding Vault.Status}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||||
<Button Classes="ghost" Height="38" Content="SIGN IN" Command="{Binding SignInCommand}"
|
<Button Classes="ghost" Height="38" Content="SIGN IN" Command="{Binding SignInCommand}"
|
||||||
|
|||||||
@@ -70,6 +70,13 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<!-- What SYNC NOW said. The status bar that carries it elsewhere is hidden in settings mode, so
|
||||||
|
without this the button ran, failed or found the machine offline in perfect silence. -->
|
||||||
|
<TextBlock Margin="0,10,0,0" FontSize="11.5" TextWrapping="Wrap" HorizontalAlignment="Right"
|
||||||
|
Foreground="{StaticResource TextFaint}"
|
||||||
|
IsVisible="{Binding Vault.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}"
|
||||||
|
Text="{Binding Vault.Status}" />
|
||||||
|
|
||||||
<!-- The name-a-vault and rename-a-vault forms, in place — this window has no modal idiom for a bare
|
<!-- The name-a-vault and rename-a-vault forms, in place — this window has no modal idiom for a bare
|
||||||
text field, and the members panel below is a different kind of overlay: over one vault's people,
|
text field, and the members panel below is a different kind of overlay: over one vault's people,
|
||||||
not over a form. -->
|
not over a form. -->
|
||||||
|
|||||||
@@ -3227,6 +3227,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
if (token is null
|
if (token is null
|
||||||
|| !Uri.TryCreate(vault.Session.Profile.ServerUrl, UriKind.Absolute, out var server))
|
|| !Uri.TryCreate(vault.Session.Profile.ServerUrl, UriKind.Absolute, out var server))
|
||||||
{
|
{
|
||||||
|
vault.OfflineReason = "This machine has no remembered sign-in. Press SIGN IN in "
|
||||||
|
+ "Preferences to start syncing.";
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3234,6 +3237,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
connection = resumed;
|
connection = resumed;
|
||||||
rememberedToken = token;
|
rememberedToken = token;
|
||||||
|
vault.OfflineReason = null;
|
||||||
|
|
||||||
OnPropertyChanged(nameof(IsOnline));
|
OnPropertyChanged(nameof(IsOnline));
|
||||||
RaiseSyncState();
|
RaiseSyncState();
|
||||||
@@ -3244,15 +3248,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
return resumed;
|
return resumed;
|
||||||
}
|
}
|
||||||
catch (OidcException exception)
|
catch (OidcException exception) when (IsRefusedGrant(exception))
|
||||||
{
|
{
|
||||||
// The provider answered and said no: the session was revoked, or the token was rotated and
|
// 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
|
// 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.
|
// the same answer, so the token goes and the user is told the one thing that fixes it.
|
||||||
await ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
|
await ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
Announce($"Your sign-in has expired, so this machine is offline: {exception.Message} "
|
var message = $"Your sign-in has expired, so this machine is offline: {exception.Message} "
|
||||||
+ "Sign in again from Preferences to start syncing.");
|
+ "Sign in again from Preferences to start syncing.";
|
||||||
|
|
||||||
|
vault.OfflineReason = message;
|
||||||
|
Announce(message);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -3260,11 +3267,29 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
{
|
{
|
||||||
// Everything else is a machine with no network, a server that is down, or a vault that was
|
// Everything else is a machine with no network, a server that is down, or a vault that was
|
||||||
// locked mid-attempt — all of which are ordinary and all of which resolve themselves. The
|
// locked mid-attempt — all of which are ordinary and all of which resolve themselves. The
|
||||||
// titlebar already says OFFLINE; a socket error once a minute would say nothing more.
|
// titlebar already says OFFLINE; a socket error once a minute would say nothing more. That
|
||||||
|
// includes an OidcException with no refusal in it: discovery answering 502, or a token endpoint
|
||||||
|
// whose body is a proxy's error page, is the identity provider restarting. Forgetting the token
|
||||||
|
// for that used to strand a machine offline for good once the provider came back.
|
||||||
|
vault.OfflineReason = $"The server could not be reached: {exception.Message} Changes are "
|
||||||
|
+ "queued and will be sent once it can.";
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a failed resume was the provider refusing the token, rather than failing to answer.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>invalid_grant</c> is what RFC 6749 §5.2 has a token endpoint say about a refresh token that is
|
||||||
|
/// expired, revoked or already rotated — the one answer that a retry cannot change. Every other
|
||||||
|
/// <see cref="OidcException"/> on this path is discovery or the token endpoint failing, which is a
|
||||||
|
/// provider that is down and will be back.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool IsRefusedGrant(OidcException exception) =>
|
||||||
|
string.Equals(exception.ErrorCode, "invalid_grant", StringComparison.Ordinal);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Writes the connection's current refresh token into the vault, if it has changed.
|
/// Writes the connection's current refresh token into the vault, if it has changed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -6199,7 +6199,9 @@ internal sealed partial class VaultViewModel(
|
|||||||
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
||||||
{
|
{
|
||||||
LastSyncFailed = true;
|
LastSyncFailed = true;
|
||||||
Status = "Offline. Changes are queued and will be sent as soon as this machine "
|
Status = OfflineReason is { } reason
|
||||||
|
? $"Offline. {reason}"
|
||||||
|
: "Offline. Changes are queued and will be sent as soon as this machine "
|
||||||
+ "can reach the server again.";
|
+ "can reach the server again.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -6214,6 +6216,14 @@ internal sealed partial class VaultViewModel(
|
|||||||
}).ConfigureAwait(true);
|
}).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Why the last attempt to get online failed, or null when nothing has said.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Set by the shell, which is the thing that knows: <see cref="ServerReconnectHandler" /> answers null
|
||||||
|
/// for every reason on purpose, so that a background pass stays quiet. A press of SYNC NOW is somebody
|
||||||
|
/// asking, though, and "offline" with no why is what left a machine stuck with no way to find out.
|
||||||
|
/// </remarks>
|
||||||
|
internal string? OfflineReason { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finds a server to sync against, getting this machine online if it is not.
|
/// Finds a server to sync against, getting this machine online if it is not.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -9702,6 +9702,42 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
resumeAttempts.ShouldBe(attempted);
|
resumeAttempts.ShouldBe(attempted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The machine this was found on: the identity provider restarted, discovery answered 502 for a
|
||||||
|
/// moment, and that was read as a refusal — so the remembered sign-in was deleted and the machine
|
||||||
|
/// stayed OFFLINE long after the server was back, with SYNC NOW saying nothing about why.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AProviderThatIsDown_KeepsTheSignInAndComesBackOnline()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
await shell.Vault!.SyncOnOpenAsync(Token);
|
||||||
|
await shell.LockCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
resumeFailure = new OidcException(
|
||||||
|
"Discovery at 'https://id.example/.well-known/openid-configuration' returned 502.");
|
||||||
|
|
||||||
|
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!.SyncCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
relaunch.IsOnline.ShouldBeFalse();
|
||||||
|
relaunch.Vault.Status.ShouldContain("returned 502", Case.Insensitive,
|
||||||
|
"SYNC NOW must say why it is offline, not just that it is");
|
||||||
|
|
||||||
|
// The provider comes back, and the next pass resumes with the token it still has.
|
||||||
|
resumeFailure = null;
|
||||||
|
await relaunch.Vault.SyncOnOpenAsync(Token);
|
||||||
|
|
||||||
|
relaunch.IsOnline.ShouldBeTrue();
|
||||||
|
resumedWith.ShouldBe("refresh-token-1");
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The pass that runs when the vault opens used to be skipped in the application and nowhere else: the
|
/// The pass that runs when the vault opens used to be skipped in the application and nowhere else: the
|
||||||
/// loop is started from inside the unlock command, so the busy flag it yields to was raised by the
|
/// loop is started from inside the unlock command, so the busy flag it yields to was raised by the
|
||||||
|
|||||||
Reference in new issue
Block a user