/ GUNRPG.ConsoleClient / Auth / SessionManager.cs
SessionManager.cs
  1  using System.Net.Http.Json;
  2  using System.Text;
  3  using System.Text.Json;
  4  using GUNRPG.Application.Identity.Dtos;
  5  using GUNRPG.ConsoleClient.Identity;
  6  
  7  namespace GUNRPG.ConsoleClient.Auth;
  8  
  9  /// <summary>
 10  /// Represents the current authentication state of the console client.
 11  /// </summary>
 12  public enum AuthState
 13  {
 14      /// <summary>No valid session — the user must log in.</summary>
 15      NotAuthenticated,
 16      /// <summary>The device code flow is in progress; waiting for the user to authenticate in the browser.</summary>
 17      Authenticating,
 18      /// <summary>A valid access token is held in memory.</summary>
 19      Authenticated,
 20  }
 21  
 22  /// <summary>
 23  /// Orchestrates all authentication for the console client.
 24  ///
 25  /// Responsibilities:
 26  /// <list type="bullet">
 27  ///   <item>Loading <c>session.json</c> from disk and attempting a silent token refresh on startup.</item>
 28  ///   <item>Starting the interactive device-code login flow and updating <see cref="State"/> as it progresses.</item>
 29  ///   <item>Persisting the new session to <c>session.json</c> on successful login.</item>
 30  ///   <item>Deleting <c>session.json</c> and clearing the in-memory token on logout.</item>
 31  /// </list>
 32  ///
 33  /// The TUI reads <see cref="State"/>, <see cref="VerificationUrl"/>, and <see cref="UserCode"/>
 34  /// to render the appropriate screen without coupling to any HTTP details.
 35  ///
 36  /// Refresh tokens are never logged. Access tokens are never written to disk.
 37  /// </summary>
 38  public sealed class SessionManager
 39  {
 40      private static readonly JsonSerializerOptions s_jsonOptions =
 41          new(JsonSerializerDefaults.Web);
 42  
 43      private readonly SessionStore _store;
 44      private readonly AuthDelegatingHandler _authHandler;
 45      private readonly string _baseUrl;
 46  
 47      // Cached bypass client — bypasses AuthDelegatingHandler to avoid recursive 401 handling.
 48      private HttpClient? _bypassClient;
 49  
 50      // Volatile so reads from the UI render thread always see the latest value
 51      // written by the background polling task.
 52      private volatile AuthState _state = AuthState.NotAuthenticated;
 53  
 54      // Backing fields for properties read by the UI thread and written by the background
 55      // polling task. Declared volatile to ensure cross-thread visibility without locks.
 56      // Reference-type reads and writes are atomic on all supported CLR platforms.
 57      private volatile string? _verificationUrl;
 58      private volatile string? _userCode;
 59      private volatile string? _loginError;
 60  
 61      /// <summary>Current authentication state. Thread-safe for read access from the UI thread.</summary>
 62      public AuthState State => _state;
 63  
 64      /// <summary>
 65      /// The verification URI to display to the user during the device-code flow.
 66      /// <see langword="null"/> when not in the <see cref="AuthState.Authenticating"/> state.
 67      /// </summary>
 68      public string? VerificationUrl => _verificationUrl;
 69  
 70      /// <summary>
 71      /// The short user code to display during the device-code flow.
 72      /// <see langword="null"/> when not in the <see cref="AuthState.Authenticating"/> state.
 73      /// </summary>
 74      public string? UserCode => _userCode;
 75  
 76      /// <summary>
 77      /// Error message from the last failed login attempt, or <see langword="null"/> if none.
 78      /// </summary>
 79      public string? LoginError => _loginError;
 80  
 81      public SessionManager(SessionStore store, AuthDelegatingHandler authHandler, string baseUrl)
 82      {
 83          _store = store;
 84          _authHandler = authHandler;
 85          _baseUrl = baseUrl;
 86      }
 87  
 88      /// <summary>
 89      /// Attempts to silently log in using a stored refresh token.
 90      /// Returns <see langword="true"/> and transitions to <see cref="AuthState.Authenticated"/>
 91      /// on success; returns <see langword="false"/> if there is no stored session or if the
 92      /// refresh token is expired/invalid.
 93      /// </summary>
 94      public async Task<bool> TryAutoLoginAsync(CancellationToken ct = default)
 95      {
 96          var session = await _store.LoadAsync();
 97          if (session is null)
 98              return false;
 99  
100          return await TryRefreshAsync(session.RefreshToken, ct);
101      }
102  
103      /// <summary>
104      /// Starts the interactive device-code login flow in a background task.
105      /// Immediately sets <see cref="State"/> to <see cref="AuthState.Authenticating"/>.
106      /// <see cref="VerificationUrl"/> and <see cref="UserCode"/> are updated once the server
107      /// responds with device-code data.
108      /// On success, transitions to <see cref="AuthState.Authenticated"/> and persists the session.
109      /// On failure, transitions back to <see cref="AuthState.NotAuthenticated"/> and sets
110      /// <see cref="LoginError"/>.
111      /// </summary>
112      public void StartLogin(CancellationToken ct)
113      {
114          _state = AuthState.Authenticating;
115          _loginError = null;
116          _verificationUrl = null;
117          _userCode = null;
118  
119          // Fire-and-forget: the background task owns all state transitions.
120          // StartLogin returns immediately so the TUI can render the Authenticating screen
121          // while device-code polling runs in the background.
122          // CancellationToken propagation ensures the task stops if the app exits.
123          // Task.Run does not trigger CS4014 (which only applies to direct async calls).
124          Task.Run(() => RunDeviceFlowAsync(ct), ct);
125      }
126  
127      /// <summary>
128      /// Logs out the current user: deletes <c>session.json</c>, clears the in-memory
129      /// access token, and transitions to <see cref="AuthState.NotAuthenticated"/>.
130      /// </summary>
131      public void Logout()
132      {
133          _store.Delete();
134          _authHandler.SetAccessToken(null);
135          _verificationUrl = null;
136          _userCode = null;
137          _loginError = null;
138          _state = AuthState.NotAuthenticated;
139      }
140  
141      // -------------------------------------------------------------------------
142      // Private helpers
143      // -------------------------------------------------------------------------
144  
145      /// <summary>
146      /// Exchanges a refresh token for a new token pair via the bypass client.
147      /// On success sets the in-memory access token, persists the new session, and returns true.
148      /// Does not log token values.
149      /// </summary>
150      private async Task<bool> TryRefreshAsync(string refreshToken, CancellationToken ct)
151      {
152          try
153          {
154              using var response = await BypassClient.PostAsJsonAsync(
155                  $"{_baseUrl}/auth/token/refresh",
156                  new RefreshTokenRequest(refreshToken),
157                  s_jsonOptions,
158                  ct);
159  
160              if (!response.IsSuccessStatusCode)
161                  return false;
162  
163              var tokens = await response.Content
164                  .ReadFromJsonAsync<TokenResponse>(s_jsonOptions, ct);
165  
166              if (tokens is null)
167                  return false;
168  
169              _authHandler.SetAccessToken(tokens.AccessToken);
170              var userId = ExtractSubFromJwt(tokens.AccessToken);
171              await _store.SaveAsync(new SessionData(tokens.RefreshToken, userId, DateTimeOffset.UtcNow));
172              _state = AuthState.Authenticated;
173              return true;
174          }
175          catch
176          {
177              return false;
178          }
179      }
180  
181      /// <summary>
182      /// Runs the full RFC 8628 device authorization flow.
183      /// Updates <see cref="VerificationUrl"/> and <see cref="UserCode"/> for the TUI to display,
184      /// then polls until authorized, expired, or cancelled.
185      /// </summary>
186      private async Task RunDeviceFlowAsync(CancellationToken ct)
187      {
188          try
189          {
190              var deviceClient = new DeviceAuthClient(BypassClient, _baseUrl);
191  
192              var deviceFlow = await deviceClient.StartDeviceFlowAsync(ct);
193              _verificationUrl = deviceFlow.VerificationUri;
194              _userCode = deviceFlow.UserCode;
195  
196              // Poll respects the server-provided interval (DeviceAuthClient handles slow_down back-off).
197              var tokens = await deviceClient.PollForTokenAsync(deviceFlow, ct);
198  
199              _authHandler.SetAccessToken(tokens.AccessToken);
200              var userId = ExtractSubFromJwt(tokens.AccessToken);
201              await _store.SaveAsync(new SessionData(tokens.RefreshToken, userId, DateTimeOffset.UtcNow));
202              _state = AuthState.Authenticated;
203          }
204          catch (OperationCanceledException)
205          {
206              _state = AuthState.NotAuthenticated;
207          }
208          catch (Exception ex)
209          {
210              _loginError = ex.Message;
211              _state = AuthState.NotAuthenticated;
212          }
213      }
214  
215      /// <summary>
216      /// Decodes the <c>sub</c> claim from a JWT access token without verifying the signature.
217      /// Returns an empty string if decoding fails.
218      /// </summary>
219      private static string ExtractSubFromJwt(string token)
220      {
221          try
222          {
223              var parts = token.Split('.');
224              if (parts.Length < 2)
225                  return string.Empty;
226  
227              // Base64url → standard base64, then pad to a multiple of 4.
228              var base64 = parts[1]
229                  .Replace('-', '+')
230                  .Replace('_', '/');
231              base64 = base64.PadRight(base64.Length + (4 - base64.Length % 4) % 4, '=');
232  
233              var payloadJson = Encoding.UTF8.GetString(Convert.FromBase64String(base64));
234  
235              using var doc = JsonDocument.Parse(payloadJson);
236              return doc.RootElement.TryGetProperty("sub", out var sub)
237                  ? sub.GetString() ?? string.Empty
238                  : string.Empty;
239          }
240          catch
241          {
242              return string.Empty;
243          }
244      }
245  
246      /// <summary>
247      /// An <see cref="HttpClient"/> that bypasses <see cref="AuthDelegatingHandler"/>,
248      /// going straight to <see cref="DelegatingHandler.InnerHandler"/>. Used for auth-endpoint
249      /// calls (token refresh, device flow) to avoid recursive 401 handling.
250      /// </summary>
251      /// <remarks>
252      /// <see cref="DelegatingHandler.InnerHandler"/> is guaranteed non-null because
253      /// <see cref="AuthDelegatingHandler.InnerHandler"/> is always set to a
254      /// <see cref="HttpClientHandler"/> before <see cref="SessionManager"/> is constructed
255      /// (see Program.cs startup).
256      /// </remarks>
257      private HttpClient BypassClient =>
258          _bypassClient ??= new HttpClient(
259              _authHandler.InnerHandler
260                  ?? throw new InvalidOperationException(
261                      "AuthDelegatingHandler.InnerHandler must be set before SessionManager is used."),
262              disposeHandler: false);
263  }