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 }