Skip to content

Commit 1089677

Browse files
committed
Rework CredentialManager
1 parent 90e85fb commit 1089677

File tree

14 files changed

+638
-109
lines changed

14 files changed

+638
-109
lines changed

App/App.xaml.cs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System;
2+
using System.Threading;
23
using System.Threading.Tasks;
4+
using Coder.Desktop.App.Models;
35
using Coder.Desktop.App.Services;
46
using Coder.Desktop.App.ViewModels;
57
using Coder.Desktop.App.Views;
@@ -46,17 +48,29 @@ public async Task ExitApplication()
4648
{
4749
_handleWindowClosed = false;
4850
Exit();
49-
var rpcManager = _services.GetRequiredService<IRpcController>();
51+
var rpcController = _services.GetRequiredService<IRpcController>();
5052
// TODO: send a StopRequest if we're connected???
51-
await rpcManager.DisposeAsync();
53+
await rpcController.DisposeAsync();
5254
Environment.Exit(0);
5355
}
5456

5557
protected override void OnLaunched(LaunchActivatedEventArgs args)
5658
{
57-
var trayWindow = _services.GetRequiredService<TrayWindow>();
59+
// Start connecting to the manager in the background.
60+
var rpcController = _services.GetRequiredService<IRpcController>();
61+
if (rpcController.GetState().RpcLifecycle == RpcLifecycle.Disconnected)
62+
// Passing in a CT with no cancellation is desired here, because
63+
// the named pipe open will block until the pipe comes up.
64+
_ = rpcController.Reconnect(CancellationToken.None);
65+
66+
// Load the credentials in the background. Even though we pass a CT
67+
// with no cancellation, the method itself will impose a timeout on the
68+
// HTTP portion.
69+
var credentialManager = _services.GetRequiredService<ICredentialManager>();
70+
_ = credentialManager.LoadCredentials(CancellationToken.None);
5871

5972
// Prevent the TrayWindow from closing, just hide it.
73+
var trayWindow = _services.GetRequiredService<TrayWindow>();
6074
trayWindow.Closed += (sender, args) =>
6175
{
6276
if (!_handleWindowClosed) return;

App/Services/CredentialManager.cs

Lines changed: 169 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
using System.Threading;
77
using System.Threading.Tasks;
88
using Coder.Desktop.App.Models;
9+
using Coder.Desktop.CoderSdk;
910
using Coder.Desktop.Vpn.Utilities;
10-
using CoderSdk;
1111

1212
namespace Coder.Desktop.App.Services;
1313

@@ -33,7 +33,7 @@ public interface ICredentialManager
3333
/// <summary>
3434
/// Get any sign-in URL. The returned value is not parsed to check if it's a valid URI.
3535
/// </summary>
36-
public string? GetSignInUri();
36+
public Task<string?> GetSignInUri();
3737

3838
/// <summary>
3939
/// Returns cached credentials or loads/verifies them from storage if not cached.
@@ -42,35 +42,76 @@ public interface ICredentialManager
4242

4343
public Task SetCredentials(string coderUrl, string apiToken, CancellationToken ct = default);
4444

45-
public void ClearCredentials();
45+
public Task ClearCredentials(CancellationToken ct = default);
4646
}
4747

48+
public interface ICredentialBackend
49+
{
50+
public Task<RawCredentials?> ReadCredentials(CancellationToken ct = default);
51+
public Task WriteCredentials(RawCredentials credentials, CancellationToken ct = default);
52+
public Task DeleteCredentials(CancellationToken ct = default);
53+
}
54+
55+
/// <summary>
56+
/// Implements ICredentialManager using the Windows Credential Manager to
57+
/// store credentials.
58+
/// </summary>
4859
public class CredentialManager : ICredentialManager
4960
{
5061
private const string CredentialsTargetName = "Coder.Desktop.App.Credentials";
5162

52-
private readonly RaiiSemaphoreSlim _loadLock = new(1, 1);
53-
private readonly RaiiSemaphoreSlim _stateLock = new(1, 1);
54-
private CredentialModel? _latestCredentials;
63+
// _opLock is held for the full duration of SetCredentials, and partially
64+
// during LoadCredentials. _lock protects _inFlightLoad, _loadCts, and
65+
// writes to _latestCredentials.
66+
private readonly RaiiSemaphoreSlim _opLock = new(1, 1);
67+
68+
// _inFlightLoad and _loadCts are set at the beginning of a LoadCredentials
69+
// call.
70+
private Task<CredentialModel>? _inFlightLoad;
71+
private CancellationTokenSource? _loadCts;
72+
73+
// Reading and writing a reference in C# is always atomic, so this doesn't
74+
// need to be protected on reads with a lock in GetCachedCredentials.
75+
//
76+
// The volatile keyword disables optimizations on reads/writes which helps
77+
// other threads see the new value quickly (no guarantee that it's
78+
// immediate).
79+
private volatile CredentialModel? _latestCredentials;
80+
81+
private ICredentialBackend Backend { get; } = new WindowsCredentialBackend(CredentialsTargetName);
82+
83+
private ICoderApiClientFactory CoderApiClientFactory { get; } = new CoderApiClientFactory();
84+
85+
public CredentialManager()
86+
{
87+
}
88+
89+
public CredentialManager(ICredentialBackend backend, ICoderApiClientFactory coderApiClientFactory)
90+
{
91+
Backend = backend;
92+
CoderApiClientFactory = coderApiClientFactory;
93+
}
5594

5695
public event EventHandler<CredentialModel>? CredentialsChanged;
5796

5897
public CredentialModel GetCachedCredentials()
5998
{
60-
using var _ = _stateLock.Lock();
61-
if (_latestCredentials != null) return _latestCredentials.Clone();
99+
// No lock required to read the reference.
100+
var latestCreds = _latestCredentials;
101+
// No clone needed as the model is immutable.
102+
if (latestCreds != null) return latestCreds;
62103

63104
return new CredentialModel
64105
{
65106
State = CredentialState.Unknown,
66107
};
67108
}
68109

69-
public string? GetSignInUri()
110+
public async Task<string?> GetSignInUri()
70111
{
71112
try
72113
{
73-
var raw = ReadCredentials();
114+
var raw = await Backend.ReadCredentials();
74115
if (raw is not null && !string.IsNullOrWhiteSpace(raw.CoderUrl)) return raw.CoderUrl;
75116
}
76117
catch
@@ -81,42 +122,50 @@ public CredentialModel GetCachedCredentials()
81122
return null;
82123
}
83124

84-
public async Task<CredentialModel> LoadCredentials(CancellationToken ct = default)
125+
// LoadCredentials may be preempted by SetCredentials.
126+
public Task<CredentialModel> LoadCredentials(CancellationToken ct = default)
85127
{
86-
using var _ = await _loadLock.LockAsync(ct);
87-
using (await _stateLock.LockAsync(ct))
88-
{
89-
if (_latestCredentials != null) return _latestCredentials.Clone();
90-
}
128+
// This function is not `async` because we may return an existing task.
129+
// However, we still want to acquire the lock with the
130+
// CancellationToken so it can be canceled if needed.
131+
using var _ = _opLock.LockAsync(ct).Result;
132+
133+
// If we already have a cached value, return it.
134+
var latestCreds = _latestCredentials;
135+
if (latestCreds != null) return Task.FromResult(latestCreds);
136+
137+
// If we are already loading, return the existing task.
138+
if (_inFlightLoad != null) return _inFlightLoad;
139+
140+
// Otherwise, kick off a new load.
141+
// Note: subsequent loads returned from above will ignore the passed in
142+
// CancellationToken. We set a maximum timeout of 15 seconds anyway.
143+
_loadCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
144+
_loadCts.CancelAfter(TimeSpan.FromSeconds(15));
145+
_inFlightLoad = LoadCredentialsInner(_loadCts.Token);
146+
return _inFlightLoad;
147+
}
91148

92-
CredentialModel model;
93-
try
94-
{
95-
var raw = ReadCredentials();
96-
model = await PopulateModel(raw, ct);
97-
}
98-
catch
149+
public async Task SetCredentials(string coderUrl, string apiToken, CancellationToken ct)
150+
{
151+
using var _ = await _opLock.LockAsync(ct);
152+
153+
// If there's an ongoing load, cancel it.
154+
if (_loadCts != null)
99155
{
100-
// We don't need to clear the credentials here, the app will think
101-
// they're unset and any subsequent SetCredentials call after the
102-
// user signs in again will overwrite the old invalid ones.
103-
model = new CredentialModel
104-
{
105-
State = CredentialState.Invalid,
106-
};
156+
await _loadCts.CancelAsync();
157+
_loadCts.Dispose();
158+
_loadCts = null;
159+
_inFlightLoad = null;
107160
}
108161

109-
UpdateState(model.Clone());
110-
return model.Clone();
111-
}
112-
113-
public async Task SetCredentials(string coderUrl, string apiToken, CancellationToken ct = default)
114-
{
115162
if (string.IsNullOrWhiteSpace(coderUrl)) throw new ArgumentException("Coder URL is required", nameof(coderUrl));
116163
coderUrl = coderUrl.Trim();
117-
if (coderUrl.Length > 128) throw new ArgumentOutOfRangeException(nameof(coderUrl), "Coder URL is too long");
164+
if (coderUrl.Length > 128) throw new ArgumentException("Coder URL is too long", nameof(coderUrl));
118165
if (!Uri.TryCreate(coderUrl, UriKind.Absolute, out var uri))
119166
throw new ArgumentException($"Coder URL '{coderUrl}' is not a valid URL", nameof(coderUrl));
167+
if (uri.Scheme != "http" && uri.Scheme != "https")
168+
throw new ArgumentException("Coder URL must be HTTP or HTTPS", nameof(coderUrl));
120169
if (uri.PathAndQuery != "/") throw new ArgumentException("Coder URL must be the root URL", nameof(coderUrl));
121170
if (string.IsNullOrWhiteSpace(apiToken)) throw new ArgumentException("API token is required", nameof(apiToken));
122171
apiToken = apiToken.Trim();
@@ -126,21 +175,66 @@ public async Task SetCredentials(string coderUrl, string apiToken, CancellationT
126175
CoderUrl = coderUrl,
127176
ApiToken = apiToken,
128177
};
129-
var model = await PopulateModel(raw, ct);
130-
WriteCredentials(raw);
178+
var populateCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
179+
populateCts.CancelAfter(TimeSpan.FromSeconds(15));
180+
var model = await PopulateModel(raw, populateCts.Token);
181+
await Backend.WriteCredentials(raw, ct);
131182
UpdateState(model);
132183
}
133184

134-
public void ClearCredentials()
185+
public async Task ClearCredentials(CancellationToken ct = default)
135186
{
136-
NativeApi.DeleteCredentials(CredentialsTargetName);
187+
using var _ = await _opLock.LockAsync(ct);
188+
await Backend.DeleteCredentials(ct);
137189
UpdateState(new CredentialModel
138190
{
139191
State = CredentialState.Invalid,
140192
});
141193
}
142194

143-
private async Task<CredentialModel> PopulateModel(RawCredentials? credentials, CancellationToken ct = default)
195+
private async Task<CredentialModel> LoadCredentialsInner(CancellationToken ct)
196+
{
197+
CredentialModel model;
198+
try
199+
{
200+
var raw = await Backend.ReadCredentials(ct);
201+
model = await PopulateModel(raw, ct);
202+
}
203+
catch
204+
{
205+
// This catch will be hit if a SetCredentials operation started, or
206+
// if the read/populate failed for some other reason (e.g. HTTP
207+
// timeout).
208+
//
209+
// We don't need to clear the credentials here, the app will think
210+
// they're unset and any subsequent SetCredentials call after the
211+
// user signs in again will overwrite the old invalid ones.
212+
model = new CredentialModel
213+
{
214+
State = CredentialState.Invalid,
215+
};
216+
}
217+
218+
// Grab the lock again so we can update the state. If we got cancelled
219+
// due to a SetCredentials call, _latestCredentials will be populated so
220+
// we just return that instead.
221+
using (await _opLock.LockAsync(ct))
222+
{
223+
var latestCreds = _latestCredentials;
224+
if (latestCreds != null) return latestCreds;
225+
if (_loadCts != null)
226+
{
227+
_loadCts.Dispose();
228+
_loadCts = null;
229+
_inFlightLoad = null;
230+
}
231+
232+
UpdateState(model);
233+
return model;
234+
}
235+
}
236+
237+
private async Task<CredentialModel> PopulateModel(RawCredentials? credentials, CancellationToken ct)
144238
{
145239
if (credentials is null || string.IsNullOrWhiteSpace(credentials.CoderUrl) ||
146240
string.IsNullOrWhiteSpace(credentials.ApiToken))
@@ -153,19 +247,21 @@ private async Task<CredentialModel> PopulateModel(RawCredentials? credentials, C
153247
User me;
154248
try
155249
{
156-
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
157-
cts.CancelAfter(TimeSpan.FromSeconds(15));
158-
var sdkClient = new CoderApiClient(credentials.CoderUrl);
250+
var sdkClient = CoderApiClientFactory.Create(credentials.CoderUrl);
251+
// BuildInfo does not require authentication.
252+
buildInfo = await sdkClient.GetBuildInfo(ct);
159253
sdkClient.SetSessionToken(credentials.ApiToken);
160-
buildInfo = await sdkClient.GetBuildInfo(cts.Token);
161-
me = await sdkClient.GetUser(User.Me, cts.Token);
254+
me = await sdkClient.GetUser(User.Me, ct);
162255
}
163256
catch (Exception e)
164257
{
165258
throw new InvalidOperationException("Could not connect to or verify Coder server", e);
166259
}
167260

168261
ServerVersionUtilities.ParseAndValidateServerVersion(buildInfo.Version);
262+
if (string.IsNullOrWhiteSpace(me.Username))
263+
throw new InvalidOperationException("Could not retrieve user information, username is empty");
264+
169265
return new CredentialModel
170266
{
171267
State = CredentialState.Valid,
@@ -175,20 +271,27 @@ private async Task<CredentialModel> PopulateModel(RawCredentials? credentials, C
175271
};
176272
}
177273

274+
// Lock must be held when calling this function.
178275
private void UpdateState(CredentialModel newModel)
179276
{
180-
using (_stateLock.Lock())
181-
{
182-
_latestCredentials = newModel.Clone();
183-
}
184-
277+
_latestCredentials = newModel;
185278
CredentialsChanged?.Invoke(this, newModel.Clone());
186279
}
280+
}
281+
282+
public class WindowsCredentialBackend : ICredentialBackend
283+
{
284+
private readonly string _credentialsTargetName;
285+
286+
public WindowsCredentialBackend(string credentialsTargetName)
287+
{
288+
_credentialsTargetName = credentialsTargetName;
289+
}
187290

188-
private static RawCredentials? ReadCredentials()
291+
public Task<RawCredentials?> ReadCredentials(CancellationToken ct = default)
189292
{
190-
var raw = NativeApi.ReadCredentials(CredentialsTargetName);
191-
if (raw == null) return null;
293+
var raw = NativeApi.ReadCredentials(_credentialsTargetName);
294+
if (raw == null) return Task.FromResult<RawCredentials?>(null);
192295

193296
RawCredentials? credentials;
194297
try
@@ -197,19 +300,23 @@ private void UpdateState(CredentialModel newModel)
197300
}
198301
catch (JsonException)
199302
{
200-
return null;
303+
credentials = null;
201304
}
202305

203-
if (credentials is null || string.IsNullOrWhiteSpace(credentials.CoderUrl) ||
204-
string.IsNullOrWhiteSpace(credentials.ApiToken)) return null;
205-
206-
return credentials;
306+
return Task.FromResult(credentials);
207307
}
208308

209-
private static void WriteCredentials(RawCredentials credentials)
309+
public Task WriteCredentials(RawCredentials credentials, CancellationToken ct = default)
210310
{
211311
var raw = JsonSerializer.Serialize(credentials, RawCredentialsJsonContext.Default.RawCredentials);
212-
NativeApi.WriteCredentials(CredentialsTargetName, raw);
312+
NativeApi.WriteCredentials(_credentialsTargetName, raw);
313+
return Task.CompletedTask;
314+
}
315+
316+
public Task DeleteCredentials(CancellationToken ct = default)
317+
{
318+
NativeApi.DeleteCredentials(_credentialsTargetName);
319+
return Task.CompletedTask;
213320
}
214321

215322
private static class NativeApi

0 commit comments

Comments
 (0)