/ GUNRPG.ConsoleClient / Identity / DeviceAuthClient.cs
DeviceAuthClient.cs
  1  using System.Net.Http.Json;
  2  using System.Text.Json;
  3  using GUNRPG.Application.Identity.Dtos;
  4  
  5  namespace GUNRPG.ConsoleClient.Identity;
  6  
  7  /// <summary>
  8  /// Implements the RFC 8628 Device Authorization Grant for console clients.
  9  /// The console cannot perform WebAuthn directly; instead the user authenticates
 10  /// via a browser-based verification URI, and the console polls for the result.
 11  /// </summary>
 12  public sealed class DeviceAuthClient
 13  {
 14      private static readonly JsonSerializerOptions s_jsonOptions =
 15          new(JsonSerializerDefaults.Web);
 16  
 17      private readonly HttpClient _http;
 18      private readonly string _baseUrl;
 19  
 20      public DeviceAuthClient(HttpClient http, string baseUrl)
 21      {
 22          _http = http;
 23          _baseUrl = baseUrl;
 24      }
 25  
 26      /// <summary>
 27      /// Starts the device authorization flow.
 28      /// Returns the device code, user code, and verification URI to display to the user.
 29      /// </summary>
 30      public async Task<DeviceCodeResponse> StartDeviceFlowAsync(CancellationToken ct = default)
 31      {
 32          using var response = await _http.PostAsync($"{_baseUrl}/auth/device/start", content: null, ct);
 33          response.EnsureSuccessStatusCode();
 34  
 35          return await response.Content.ReadFromJsonAsync<DeviceCodeResponse>(s_jsonOptions, ct)
 36              ?? throw new InvalidOperationException("Empty response from /auth/device/start.");
 37      }
 38  
 39      /// <summary>
 40      /// Polls the server at the server-provided interval until the device code is
 41      /// authorized, expired, or denied.
 42      /// Backs off by 5 seconds on <c>slow_down</c> per RFC 8628 §3.5.
 43      /// Returns tokens only when the server responds with <c>authorized</c>.
 44      /// </summary>
 45      public async Task<TokenResponse> PollForTokenAsync(
 46          DeviceCodeResponse deviceFlow, CancellationToken ct = default)
 47      {
 48          var intervalSeconds = deviceFlow.PollIntervalSeconds;
 49  
 50          while (true)
 51          {
 52              // Respect server-provided interval strictly; Task.Delay avoids CPU spin.
 53              await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), ct);
 54  
 55              DevicePollResponse result;
 56              using (var pollResponse = await _http.PostAsJsonAsync(
 57                  $"{_baseUrl}/auth/device/poll",
 58                  new DevicePollRequest(deviceFlow.DeviceCode),
 59                  s_jsonOptions,
 60                  ct))
 61              {
 62                  if (!pollResponse.IsSuccessStatusCode)
 63                  {
 64                      var errorBody = await pollResponse.Content.ReadAsStringAsync(ct);
 65                      throw new InvalidOperationException(
 66                          $"Device poll request failed with HTTP {(int)pollResponse.StatusCode}: {errorBody}");
 67                  }
 68  
 69                  result = await pollResponse.Content
 70                      .ReadFromJsonAsync<DevicePollResponse>(s_jsonOptions, ct)
 71                      ?? throw new InvalidOperationException("Empty response from /auth/device/poll.");
 72              }
 73  
 74              switch (result.Status)
 75              {
 76                  case "authorized":
 77                      return result.Tokens
 78                          ?? throw new InvalidOperationException(
 79                              "Server responded authorized but returned no tokens.");
 80  
 81                  case "authorization_pending":
 82                      break; // keep polling at current interval
 83  
 84                  case "slow_down":
 85                      intervalSeconds += 5; // RFC 8628 §3.5: add 5 s per slow_down
 86                      break;
 87  
 88                  case "expired_token":
 89                      throw new InvalidOperationException(
 90                          "Device code expired. Please restart the login flow.");
 91  
 92                  case "access_denied":
 93                      throw new InvalidOperationException(
 94                          "Access was denied. Please restart the login flow.");
 95  
 96                  default:
 97                      throw new InvalidOperationException(
 98                          $"Unknown device poll status: '{result.Status}'.");
 99              }
100          }
101      }
102  }