6
6
using System . Threading ;
7
7
using System . Threading . Tasks ;
8
8
using Coder . Desktop . App . Models ;
9
+ using Coder . Desktop . CoderSdk ;
9
10
using Coder . Desktop . Vpn . Utilities ;
10
- using CoderSdk ;
11
11
12
12
namespace Coder . Desktop . App . Services ;
13
13
@@ -33,7 +33,7 @@ public interface ICredentialManager
33
33
/// <summary>
34
34
/// Get any sign-in URL. The returned value is not parsed to check if it's a valid URI.
35
35
/// </summary>
36
- public string ? GetSignInUri ( ) ;
36
+ public Task < string ? > GetSignInUri ( ) ;
37
37
38
38
/// <summary>
39
39
/// Returns cached credentials or loads/verifies them from storage if not cached.
@@ -42,35 +42,76 @@ public interface ICredentialManager
42
42
43
43
public Task SetCredentials ( string coderUrl , string apiToken , CancellationToken ct = default ) ;
44
44
45
- public void ClearCredentials ( ) ;
45
+ public Task ClearCredentials ( CancellationToken ct = default ) ;
46
46
}
47
47
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>
48
59
public class CredentialManager : ICredentialManager
49
60
{
50
61
private const string CredentialsTargetName = "Coder.Desktop.App.Credentials" ;
51
62
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
+ }
55
94
56
95
public event EventHandler < CredentialModel > ? CredentialsChanged ;
57
96
58
97
public CredentialModel GetCachedCredentials ( )
59
98
{
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 ;
62
103
63
104
return new CredentialModel
64
105
{
65
106
State = CredentialState . Unknown ,
66
107
} ;
67
108
}
68
109
69
- public string ? GetSignInUri ( )
110
+ public async Task < string ? > GetSignInUri ( )
70
111
{
71
112
try
72
113
{
73
- var raw = ReadCredentials ( ) ;
114
+ var raw = await Backend . ReadCredentials ( ) ;
74
115
if ( raw is not null && ! string . IsNullOrWhiteSpace ( raw . CoderUrl ) ) return raw . CoderUrl ;
75
116
}
76
117
catch
@@ -81,42 +122,50 @@ public CredentialModel GetCachedCredentials()
81
122
return null ;
82
123
}
83
124
84
- public async Task < CredentialModel > LoadCredentials ( CancellationToken ct = default )
125
+ // LoadCredentials may be preempted by SetCredentials.
126
+ public Task < CredentialModel > LoadCredentials ( CancellationToken ct = default )
85
127
{
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
+ }
91
148
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 )
99
155
{
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 ;
107
160
}
108
161
109
- UpdateState ( model . Clone ( ) ) ;
110
- return model . Clone ( ) ;
111
- }
112
-
113
- public async Task SetCredentials ( string coderUrl , string apiToken , CancellationToken ct = default )
114
- {
115
162
if ( string . IsNullOrWhiteSpace ( coderUrl ) ) throw new ArgumentException ( "Coder URL is required" , nameof ( coderUrl ) ) ;
116
163
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 ) ) ;
118
165
if ( ! Uri . TryCreate ( coderUrl , UriKind . Absolute , out var uri ) )
119
166
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 ) ) ;
120
169
if ( uri . PathAndQuery != "/" ) throw new ArgumentException ( "Coder URL must be the root URL" , nameof ( coderUrl ) ) ;
121
170
if ( string . IsNullOrWhiteSpace ( apiToken ) ) throw new ArgumentException ( "API token is required" , nameof ( apiToken ) ) ;
122
171
apiToken = apiToken . Trim ( ) ;
@@ -126,21 +175,66 @@ public async Task SetCredentials(string coderUrl, string apiToken, CancellationT
126
175
CoderUrl = coderUrl ,
127
176
ApiToken = apiToken ,
128
177
} ;
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 ) ;
131
182
UpdateState ( model ) ;
132
183
}
133
184
134
- public void ClearCredentials ( )
185
+ public async Task ClearCredentials ( CancellationToken ct = default )
135
186
{
136
- NativeApi . DeleteCredentials ( CredentialsTargetName ) ;
187
+ using var _ = await _opLock . LockAsync ( ct ) ;
188
+ await Backend . DeleteCredentials ( ct ) ;
137
189
UpdateState ( new CredentialModel
138
190
{
139
191
State = CredentialState . Invalid ,
140
192
} ) ;
141
193
}
142
194
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 )
144
238
{
145
239
if ( credentials is null || string . IsNullOrWhiteSpace ( credentials . CoderUrl ) ||
146
240
string . IsNullOrWhiteSpace ( credentials . ApiToken ) )
@@ -153,19 +247,21 @@ private async Task<CredentialModel> PopulateModel(RawCredentials? credentials, C
153
247
User me ;
154
248
try
155
249
{
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 ) ;
159
253
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 ) ;
162
255
}
163
256
catch ( Exception e )
164
257
{
165
258
throw new InvalidOperationException ( "Could not connect to or verify Coder server" , e ) ;
166
259
}
167
260
168
261
ServerVersionUtilities . ParseAndValidateServerVersion ( buildInfo . Version ) ;
262
+ if ( string . IsNullOrWhiteSpace ( me . Username ) )
263
+ throw new InvalidOperationException ( "Could not retrieve user information, username is empty" ) ;
264
+
169
265
return new CredentialModel
170
266
{
171
267
State = CredentialState . Valid ,
@@ -175,20 +271,27 @@ private async Task<CredentialModel> PopulateModel(RawCredentials? credentials, C
175
271
} ;
176
272
}
177
273
274
+ // Lock must be held when calling this function.
178
275
private void UpdateState ( CredentialModel newModel )
179
276
{
180
- using ( _stateLock . Lock ( ) )
181
- {
182
- _latestCredentials = newModel . Clone ( ) ;
183
- }
184
-
277
+ _latestCredentials = newModel ;
185
278
CredentialsChanged ? . Invoke ( this , newModel . Clone ( ) ) ;
186
279
}
280
+ }
281
+
282
+ public class WindowsCredentialBackend : ICredentialBackend
283
+ {
284
+ private readonly string _credentialsTargetName ;
285
+
286
+ public WindowsCredentialBackend ( string credentialsTargetName )
287
+ {
288
+ _credentialsTargetName = credentialsTargetName ;
289
+ }
187
290
188
- private static RawCredentials ? ReadCredentials ( )
291
+ public Task < RawCredentials ? > ReadCredentials ( CancellationToken ct = default )
189
292
{
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 ) ;
192
295
193
296
RawCredentials ? credentials ;
194
297
try
@@ -197,19 +300,23 @@ private void UpdateState(CredentialModel newModel)
197
300
}
198
301
catch ( JsonException )
199
302
{
200
- return null ;
303
+ credentials = null ;
201
304
}
202
305
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 ) ;
207
307
}
208
308
209
- private static void WriteCredentials ( RawCredentials credentials )
309
+ public Task WriteCredentials ( RawCredentials credentials , CancellationToken ct = default )
210
310
{
211
311
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 ;
213
320
}
214
321
215
322
private static class NativeApi
0 commit comments