66using System . Threading ;
77using System . Threading . Tasks ;
88using Coder . Desktop . App . Models ;
9+ using Coder . Desktop . CoderSdk ;
910using Coder . Desktop . Vpn . Utilities ;
10- using CoderSdk ;
1111
1212namespace 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>
4859public 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