/ GUNRPG.Infrastructure / Identity / DeviceCodeService.cs
DeviceCodeService.cs
  1  using System.Security.Cryptography;
  2  using System.Text;
  3  using GUNRPG.Application.Identity;
  4  using GUNRPG.Application.Identity.Dtos;
  5  using GUNRPG.Application.Results;
  6  using GUNRPG.Core.Identity;
  7  using LiteDB;
  8  using Microsoft.AspNetCore.Identity;
  9  
 10  namespace GUNRPG.Infrastructure.Identity;
 11  
 12  /// <summary>
 13  /// Device Code Flow service for console clients.
 14  /// Issues short-lived user codes, validates browser-side WebAuthn completion,
 15  /// and lets the console poll for token issuance.
 16  /// </summary>
 17  public sealed class DeviceCodeService : IDeviceCodeService
 18  {
 19      private static readonly TimeSpan DeviceCodeExpiry = TimeSpan.FromMinutes(15);
 20  
 21      private const int UserCodeLength = 8; // e.g. "WXYZ1234"
 22  
 23      private readonly ILiteCollection<DeviceCode> _codes;
 24      private readonly ITokenService _tokenService;
 25      private readonly UserManager<ApplicationUser> _userManager;
 26      private readonly string _verificationUri;
 27  
 28      public DeviceCodeService(
 29          ILiteDatabase db,
 30          ITokenService tokenService,
 31          UserManager<ApplicationUser> userManager,
 32          string verificationUri)
 33      {
 34          _codes = db.GetCollection<DeviceCode>("identity_device_codes");
 35          _codes.EnsureIndex(c => c.Code, unique: true);
 36          _codes.EnsureIndex(c => c.UserCode, unique: true);
 37          _tokenService = tokenService;
 38          _userManager = userManager;
 39          _verificationUri = verificationUri;
 40      }
 41  
 42      public Task<DeviceCodeResponse> StartAsync(CancellationToken ct = default)
 43      {
 44          PurgeExpired();
 45  
 46          var deviceCode = GenerateDeviceCode();
 47          var userCode = GenerateUserCode();
 48  
 49          var code = new DeviceCode
 50          {
 51              Code = deviceCode,
 52              UserCode = userCode,
 53              VerificationUri = _verificationUri,
 54              IssuedAt = DateTimeOffset.UtcNow,
 55              ExpiresAt = DateTimeOffset.UtcNow.Add(DeviceCodeExpiry),
 56          };
 57  
 58          _codes.Insert(code);
 59  
 60          return Task.FromResult(new DeviceCodeResponse(
 61              DeviceCode: deviceCode,
 62              UserCode: userCode,
 63              VerificationUri: _verificationUri,
 64              ExpiresInSeconds: (int)DeviceCodeExpiry.TotalSeconds,
 65              PollIntervalSeconds: code.PollIntervalSeconds));
 66      }
 67  
 68      public Task<ServiceResult> AuthorizeAsync(string userCode, string userId, CancellationToken ct = default)
 69      {
 70          var normalised = userCode.ToUpperInvariant().Replace("-", "").Replace(" ", "");
 71          var code = _codes.FindOne(c => c.UserCode == normalised || c.UserCode == userCode);
 72  
 73          if (code is null)
 74              return Task.FromResult(ServiceResult.NotFound("User code not found."));
 75  
 76          if (code.IsExpired)
 77          {
 78              _codes.Delete(code.Id);
 79              return Task.FromResult(ServiceResult.InvalidState("Device code has expired."));
 80          }
 81  
 82          if (code.IsAuthorized)
 83              return Task.FromResult(ServiceResult.InvalidState("Device code is already authorized."));
 84  
 85          code.AuthorizedUserId = userId;
 86          _codes.Update(code);
 87  
 88          return Task.FromResult(ServiceResult.Success());
 89      }
 90  
 91      public async Task<ServiceResult<DevicePollResponse>> PollAsync(string deviceCode, CancellationToken ct = default)
 92      {
 93          var code = _codes.FindOne(c => c.Code == deviceCode);
 94  
 95          if (code is null)
 96              return ServiceResult<DevicePollResponse>.NotFound("Device code not found.");
 97  
 98          if (code.IsExpired)
 99          {
100              _codes.Delete(code.Id);
101              return ServiceResult<DevicePollResponse>.Success(new DevicePollResponse("expired_token", null));
102          }
103  
104          // Rate limiting: enforce minimum poll interval (RFC 8628 §3.5 "slow_down")
105          if (code.LastPolledAt.HasValue)
106          {
107              var elapsed = DateTimeOffset.UtcNow - code.LastPolledAt.Value;
108              if (elapsed.TotalSeconds < code.PollIntervalSeconds)
109                  return ServiceResult<DevicePollResponse>.Success(new DevicePollResponse("slow_down", null));
110          }
111  
112          code.LastPolledAt = DateTimeOffset.UtcNow;
113          _codes.Update(code);
114  
115          if (!code.IsAuthorized)
116              return ServiceResult<DevicePollResponse>.Success(new DevicePollResponse("authorization_pending", null));
117  
118          // Authorization granted — issue tokens and consume the code
119          var user = await _userManager.FindByIdAsync(code.AuthorizedUserId!);
120          if (user is null)
121              return ServiceResult<DevicePollResponse>.InvalidState("Authorized user no longer exists.");
122  
123          var accountProvisioning = await AccountIdProvisioning.EnsureAssignedAsync(_userManager, user, ct);
124          if (!accountProvisioning.Succeeded)
125              return ServiceResult<DevicePollResponse>.InvalidState(
126                  string.Join("; ", accountProvisioning.Errors.Select(e => e.Description)));
127  
128          _codes.Delete(code.Id);
129  
130          var tokens = await _tokenService.IssueTokensAsync(user.Id, user.UserName, user.AccountId, ct);
131          return ServiceResult<DevicePollResponse>.Success(new DevicePollResponse("authorized", tokens));
132      }
133  
134      // ── Helpers ──────────────────────────────────────────────────────────────
135  
136      private static string GenerateDeviceCode()
137      {
138          var bytes = RandomNumberGenerator.GetBytes(32);
139          return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
140      }
141  
142      private static string GenerateUserCode()
143      {
144          // Generate an 8-character alphanumeric code (uppercase only, no ambiguous chars)
145          const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
146          var bytes = RandomNumberGenerator.GetBytes(UserCodeLength);
147          var sb = new StringBuilder(UserCodeLength);
148          foreach (var b in bytes)
149              sb.Append(chars[b % chars.Length]);
150          return sb.ToString();
151      }
152  
153      private void PurgeExpired()
154      {
155          var now = DateTimeOffset.UtcNow;
156          _codes.DeleteMany(c => c.ExpiresAt <= now);
157      }
158  }