using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.Utils; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Enums.Players; using RadicadeServer.RecNet.Enums.Rooms; using System.Collections.Concurrent; using System.Net; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace RadicadeServer.Auth { [Route("auth")] public class StudioAuthAPI : Controller { private const int DeviceCodeLifetimeSeconds = 900; private const int PollIntervalSeconds = 2; private const int TokenLifetimeSeconds = 604800; private static readonly ConcurrentDictionary Pending = new(StringComparer.Ordinal); private const string UserCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; private sealed class PendingDeviceAuthorization { public string UserCode = string.Empty; public string Scope = "studio"; public DateTime ExpiresUtc = DateTime.UtcNow; public long AccountId; public bool Approved; } RecDBContext db; private readonly IConfiguration config; public StudioAuthAPI(RecDBContext context,IConfiguration configuration) { this.db = context; this.config = configuration; } [HttpGet("connect/deviceauthorization")] [HttpPost("connect/deviceauthorization")] public async Task DeviceAuthorization() { var fields = await ReadRequestFields(); if (!IsDeviceFlowAllowed()) { return BadRequest(new { error = "access_denied", error_description = "Rec Room Studio sign-in is only available from the machine running the server." }); } var accountId = await ResolveStudioAccountId(fields); if (accountId <= 0) { return BadRequest(new { error = "access_denied", error_description = "No account is configured for Rec Room Studio on this server." }); } PruneExpired(); var deviceCode = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); var pending = new PendingDeviceAuthorization { UserCode = GenerateUserCode(), Scope = fields.TryGetValue("scope", out var scope) && !string.IsNullOrWhiteSpace(scope) ? scope : "studio", ExpiresUtc = DateTime.UtcNow.AddSeconds(DeviceCodeLifetimeSeconds), AccountId = accountId, Approved = true }; Pending[deviceCode] = pending; return Ok(new { device_code = deviceCode, user_code = pending.UserCode, verification_uri = $"{DBConfig.ServerUrl}/device", verification_uri_complete = $"{DBConfig.ServerUrl}/device?user_code={pending.UserCode}", expires_in = DeviceCodeLifetimeSeconds, interval = PollIntervalSeconds, scope = pending.Scope }); } [HttpGet("connect/studiotoken")] [HttpPost("connect/studiotoken")] public async Task StudioToken() { var fields = await ReadRequestFields(); var grantType = fields.GetValueOrDefault("grant_type") ?? string.Empty; if (IsDeviceCodeGrant(grantType)) return await CompleteDeviceAuthorization(fields); if (IsRefreshGrant(grantType)) return await RefreshStudioToken(fields); if (grantType.Length == 0) { return BadRequest(new { error = "invalid_request", error_description = "grant_type is required." }); } return BadRequest(new { error = "unsupported_grant_type", error_description = $"grant_type '{grantType}' is not supported." }); } private static bool IsDeviceCodeGrant(string grantType) => grantType.Replace("-", "_").Contains("device_code", StringComparison.OrdinalIgnoreCase); private static bool IsRefreshGrant(string grantType) => grantType.Replace("-", "_").Contains("refresh_token", StringComparison.OrdinalIgnoreCase); internal static (bool Found, bool Approved, long AccountId, DateTime ExpiresUtc) DescribeUserCode(string userCode) { if (string.IsNullOrWhiteSpace(userCode)) return (false, false, 0, default); var wanted = NormalizeUserCode(userCode); var now = DateTime.UtcNow; foreach (var entry in Pending) { if (entry.Value.ExpiresUtc <= now) continue; if (NormalizeUserCode(entry.Value.UserCode) != wanted) continue; return (true, entry.Value.Approved, entry.Value.AccountId, entry.Value.ExpiresUtc); } return (false, false, 0, default); } private static string NormalizeUserCode(string userCode) => new string(userCode.Where(char.IsLetterOrDigit).ToArray()).ToUpperInvariant(); private async Task CompleteDeviceAuthorization(Dictionary fields) { var deviceCode = fields.GetValueOrDefault("device_code") ?? string.Empty; if (!Pending.TryGetValue(deviceCode, out var pending)) return BadRequest(new { error = "invalid_grant", error_description = "Unknown device code." }); if (pending.ExpiresUtc <= DateTime.UtcNow) { Pending.TryRemove(deviceCode, out _); return BadRequest(new { error = "expired_token", error_description = "The device code has expired." }); } if (!pending.Approved) return BadRequest(new { error = "authorization_pending", error_description = "The user has not approved this sign-in yet." }); Pending.TryRemove(deviceCode, out _); return await IssueStudioTokens(pending.AccountId); } private async Task RefreshStudioToken(Dictionary fields) { var refreshToken = fields.GetValueOrDefault("refresh_token") ?? string.Empty; var accountId = AccountIdFromRefreshToken(refreshToken); if (accountId > 0 && !await db.Players.AnyAsync(x => x.AccountId == accountId)) accountId = 0; if (accountId <= 0) { return BadRequest(new { error = "invalid_grant", error_description = "Unknown refresh token." }); } return await IssueStudioTokens(accountId); } private async Task IssueStudioTokens(long accountId) { await EnsurePresence(accountId); var token = await AuthAPI.GenerateToken(db, accountId, DBConfig.GameVersion, DeviceClass.Screen, Platforms.Steam, $"studio:{accountId}"); if (!token.success || token.value is not string accessToken) { return BadRequest(new { error = "invalid_grant", error_description = token.error ?? "Could not create a studio token." }); } return Ok(new { access_token = accessToken, refresh_token = RefreshTokenFor(accountId), token_type = "Bearer", expires_in = TokenLifetimeSeconds, scope = "studio" }); } private static string RefreshTokenFor(long accountId) { var bytes = new byte[16]; Array.Copy(BitConverter.GetBytes(accountId), 0, bytes, 0, 8); return new Guid(bytes).ToString(); } private static long AccountIdFromRefreshToken(string refreshToken) { if (!Guid.TryParse(refreshToken, out var guid)) return 0; var embedded = BitConverter.ToInt64(guid.ToByteArray(), 0); return embedded > 0 ? embedded : 0; } private async Task EnsurePresence(long accountId) { if (await db.Presences.AnyAsync(x => x.playerId == accountId)) return; db.Presences.Add(new RealPresence { playerId = accountId, deviceClass = DeviceClass.Screen, statusVisibility = StatusVisibility.Public, vRMovementMode = VRMovementModes.TELEPORT }); await db.SaveChangesAsync(); } private async Task ResolveStudioAccountId(Dictionary fields) { var configured = config["Radicade:Studio:AutoApproveAccountId"]; if (long.TryParse(configured, out var configuredId) && configuredId > 0 && await db.Players.AnyAsync(x => x.AccountId == configuredId)) return configuredId; if (long.TryParse(fields.GetValueOrDefault("account_id"), out var requestedId) && requestedId > 0 && await db.Players.AnyAsync(x => x.AccountId == requestedId)) return requestedId; var developer = await db.Profiles.Where(p => p.role != null && p.role.Contains("developer")) .OrderBy(p => p.accountId) .FirstOrDefaultAsync(); if (developer != null) return developer.accountId; return 0; } private bool IsDeviceFlowAllowed() { if (bool.TryParse(config["Radicade:Studio:AllowRemoteDeviceAuth"], out var allowRemote) && allowRemote) return true; var ip = Request.GetClientIp(); if (string.IsNullOrWhiteSpace(ip)) return false; if (IPAddress.TryParse(ip, out var parsed)) return IPAddress.IsLoopback(parsed); return ip.Equals("localhost", StringComparison.OrdinalIgnoreCase); } private static string GenerateUserCode() { var buffer = new char[9]; var bytes = RandomNumberGenerator.GetBytes(8); for (var i = 0; i < 8; i++) { buffer[i < 4 ? i : i + 1] = UserCodeAlphabet[bytes[i] % UserCodeAlphabet.Length]; } buffer[4] = '-'; return new string(buffer); } private static void PruneExpired() { var now = DateTime.UtcNow; foreach (var entry in Pending) { if (entry.Value.ExpiresUtc <= now) Pending.TryRemove(entry.Key, out _); } } private async Task> ReadRequestFields() { var fields = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var query in Request.Query) fields[query.Key] = query.Value.ToString(); if (Request.HasFormContentType) { foreach (var form in Request.Form) fields[form.Key] = form.Value.ToString(); } else { Request.EnableBuffering(); Request.Body.Position = 0; using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true); var raw = await reader.ReadToEndAsync(); Request.Body.Position = 0; if (!string.IsNullOrWhiteSpace(raw)) { try { using var document = JsonDocument.Parse(raw); if (document.RootElement.ValueKind == JsonValueKind.Object) { foreach (var property in document.RootElement.EnumerateObject()) { fields[property.Name] = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() ?? string.Empty : property.Value.ToString(); } } } catch (JsonException) { fields["raw"] = raw; } } } return fields; } private static string Describe(Dictionary fields) { var parts = fields.Select(field => field.Key.Contains("secret", StringComparison.OrdinalIgnoreCase) || field.Key.Contains("password", StringComparison.OrdinalIgnoreCase) ? $"{field.Key}=" : $"{field.Key}={field.Value}"); return string.Join(", ", parts); } } }