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 }