using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using Microsoft.EntityFrameworkCore; namespace DorkNet.Server.Services; /// /// OAuth device-authorization grant as the 2023 client drives it. /// /// 1. The device that wants to sign in calls connect/deviceauthorization /// and shows the player the user_code (reader /// RecNet.Runtime/AMPFGJPKHOD.txt:399-598: device_code, user_code, /// verification_uri(_complete), expires_in, interval). /// 2. It polls connect/token with /// grant_type=urn:ietf:params:oauth:grant-type:device_code and /// device_code (LBNJFPOLCDL_NestedType_DDNJDALGIME.txt:444-451). /// A 400 whose error is authorization_pending or /// slow_down means "keep polling" /// (LBNJFPOLCDL_NestedType_JAEKFNKDNCP.txt:97-116); anything else /// ends the attempt. /// 3. A signed-in client approves the code with /// account/me/remoteauth (form field code). /// /// State lives in one PlayerSettings row per authorization, owned by the system /// account: key deviceauthorization:{device_code}, value /// {user_code}|{expiresAtUtc:O}, plus |{approverAccountId} once /// approved. Redeeming deletes the row, so a device code is single-use. /// public static class DeviceAuthorizations { public const string KeyPrefix = "deviceauthorization:"; public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(15); public enum RedeemStatus { Invalid, Expired, Pending, Approved } public sealed record Created(string DeviceCode, string UserCode, int ExpiresInSeconds); public static async Task CreateAsync(DorkNetDbContext db, DateTime nowUtc) { var deviceCode = Guid.NewGuid().ToString("N"); var userCode = deviceCode[..8].ToUpperInvariant(); db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = RelationshipQueries.SystemAccountId, Key = KeyPrefix + deviceCode, Value = $"{userCode}|{(nowUtc + Lifetime):O}", }); await db.SaveChangesAsync(); return new Created(deviceCode, userCode, (int)Lifetime.TotalSeconds); } /// Bind the pending authorization whose user code is /// to . False when /// no live, unapproved authorization carries that code. public static async Task ApproveAsync( DorkNetDbContext db, string userCode, long approverId, DateTime nowUtc) { var code = NormaliseUserCode(userCode); if (code.Length == 0) return false; var prefix = code + "|"; var rows = await db.PlayerSettings .Where(s => s.Key.StartsWith(KeyPrefix) && s.Value.StartsWith(prefix)) .ToListAsync(); foreach (var row in rows) { var parts = row.Value.Split('|'); if (parts.Length < 2 || !TryParseExpiry(parts[1], out var expires) || expires < nowUtc) continue; if (parts.Length >= 3 && parts[2].Length > 0) continue; // already approved row.Value = $"{parts[0]}|{parts[1]}|{approverId}"; await db.SaveChangesAsync(); return true; } return false; } /// One poll from the device. Approved authorizations are consumed /// and yield the approving account. public static async Task<(RedeemStatus Status, long AccountId)> RedeemAsync( DorkNetDbContext db, string? deviceCode, DateTime nowUtc) { if (string.IsNullOrWhiteSpace(deviceCode)) return (RedeemStatus.Invalid, 0); var row = await db.PlayerSettings .FirstOrDefaultAsync(s => s.Key == KeyPrefix + deviceCode.Trim()); if (row is null) return (RedeemStatus.Invalid, 0); var parts = row.Value.Split('|'); if (parts.Length < 2 || !TryParseExpiry(parts[1], out var expires) || expires < nowUtc) { db.PlayerSettings.Remove(row); await db.SaveChangesAsync(); return (RedeemStatus.Expired, 0); } if (parts.Length < 3 || !long.TryParse(parts[2], out var approver)) return (RedeemStatus.Pending, 0); db.PlayerSettings.Remove(row); await db.SaveChangesAsync(); return (RedeemStatus.Approved, approver); } private static string NormaliseUserCode(string? raw) => new string((raw ?? string.Empty).Where(char.IsLetterOrDigit).ToArray()).ToUpperInvariant(); private static bool TryParseExpiry(string raw, out DateTime expiresUtc) => DateTime.TryParse(raw, null, System.Globalization.DateTimeStyles.RoundtripKind, out expiresUtc); }