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 }