using System.Text.Json; using RadicadeServer.DB; using RadicadeServer.RecNet.Enums.Players; namespace RadicadeServer.Auth; public static class PlatformIdentity { public static readonly string[] AllowedSteamAppIds = ["480", "471710"]; public static HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(10) }; public sealed record Proof(bool Verified, string? SteamId, string Detail) { public static Proof Ok(string? steamId) => new(true, steamId, "proved"); public static Proof No(string detail) => new(false, null, detail); } public static async Task ProveAsync( Platforms platform, string? platformId, string? steamTicketHeader, string? platformAuth, CancellationToken cancellationToken = default) { var id = platformId?.Trim() ?? string.Empty; if (id.Length == 0) return Proof.No("A platform id is required."); return platform switch { Platforms.Steam => await ProveSteamAsync(id, steamTicketHeader, platformAuth, cancellationToken), Platforms.Oculus => ProveOculus(id, platformAuth), _ => Proof.No("Account creation is only available from Steam or Oculus on this server.") }; } private static async Task ProveSteamAsync( string platformId, string? steamTicketHeader, string? platformAuth, CancellationToken cancellationToken) { var ticket = string.IsNullOrWhiteSpace(steamTicketHeader) ? ReadString(platformAuth, "Ticket") : steamTicketHeader.Trim(); if (string.IsNullOrWhiteSpace(ticket)) return Proof.No("No Steam ticket was sent (x-steam-ticket header or platform_auth.Ticket)."); if (string.IsNullOrWhiteSpace(DBConfig.SteamApiKey)) { if (!DBConfig.TrustUnverifiedSteamIdentity) return Proof.No("Steam identity cannot be proved: no SteamApiKey is configured."); Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Identity] accepted Steam identity {platformId} " + "UNPROVED - no SteamApiKey is configured and TrustUnverifiedSteamIdentity is on."); return Proof.Ok(null); } var attempted = new List(); foreach (var appId in CandidateAppIds(ReadString(platformAuth, "AppId"))) { attempted.Add(appId); var result = await AuthenticateTicketAsync(ticket, appId, cancellationToken); if (result.OtherApp) continue; if (!result.Ok) return Proof.No(result.Detail); if (!string.Equals(result.SteamId, platformId, StringComparison.Ordinal)) return Proof.No($"The Steam ticket is for {result.SteamId}, not for the identity it was sent with."); return Proof.Ok(result.SteamId); } return Proof.No("The Steam ticket is not for an app this server accepts (tried " + string.Join(", ", attempted) + ")."); } private static Proof ProveOculus(string platformId, string? platformAuth) { var nonce = ReadString(platformAuth, "Nonce"); if (string.IsNullOrWhiteSpace(nonce)) return Proof.No("No Oculus nonce was sent (platform_auth.Nonce)."); return Proof.No("Oculus identity cannot be proved yet: no Oculus app secret is configured, " + "so the nonce cannot be validated."); } public static async Task CheckSteamStandingAsync(string steamId, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(DBConfig.SteamApiKey)) return null; if (string.IsNullOrWhiteSpace(steamId)) return null; var key = Uri.EscapeDataString(DBConfig.SteamApiKey); var id = Uri.EscapeDataString(steamId); try { using var summaryDoc = await GetJsonAsync( $"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key={key}&steamids={id}", cancellationToken); if (summaryDoc == null) return null; if (!summaryDoc.RootElement.TryGetProperty("response", out var summary) || !summary.TryGetProperty("players", out var players) || players.ValueKind != JsonValueKind.Array || players.GetArrayLength() == 0) return "Your Steam profile was not found, so an account cannot be created for it."; var player = players[0]; if (player.TryGetProperty("profilestate", out var profileState) && profileState.ValueKind == JsonValueKind.Number && profileState.TryGetInt32(out var state) && state != 1) return "Your Steam profile has to be set up before you can create an account."; using var bansDoc = await GetJsonAsync( $"https://api.steampowered.com/ISteamUser/GetPlayerBans/v1/?key={key}&steamids={id}", cancellationToken); if (bansDoc == null) return null; if (!bansDoc.RootElement.TryGetProperty("players", out var bans) || bans.ValueKind != JsonValueKind.Array || bans.GetArrayLength() == 0) return "Steam would not report this account's ban status, so an account cannot be created for it."; var entry = bans[0]; if (entry.TryGetProperty("CommunityBanned", out var banned) && banned.ValueKind == JsonValueKind.True) return "Steam accounts with an active community ban are not allowed to create an account."; } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException) { Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Identity] Steam standing check could not be " + $"completed for {steamId}: {ex.GetType().Name}: {ex.Message}"); return null; } return null; } public sealed record TicketResult(bool Ok, bool OtherApp, string? SteamId, string Detail); public static async Task AuthenticateTicketAsync(string ticket, string appId, CancellationToken cancellationToken) { try { using var doc = await GetJsonAsync( "https://api.steampowered.com/ISteamUserAuth/AuthenticateUserTicket/v1/?key=" + $"{Uri.EscapeDataString(DBConfig.SteamApiKey)}&appid={Uri.EscapeDataString(appId)}" + $"&ticket={Uri.EscapeDataString(ticket)}", cancellationToken); if (doc == null) return new TicketResult(false, false, null, "Steam did not answer the ticket check."); if (!doc.RootElement.TryGetProperty("response", out var response)) return new TicketResult(false, false, null, "Steam did not answer the ticket check."); if (response.TryGetProperty("error", out var error)) { var code = error.TryGetProperty("errorcode", out var errorCode) && errorCode.TryGetInt32(out var value) ? value : 0; var description = error.TryGetProperty("errordesc", out var desc) ? desc.GetString() : null; if (code == 102) return new TicketResult(false, true, null, $"ticket for app {appId}"); return new TicketResult(false, false, null, $"Steam refused the ticket ({(description ?? "no description")})."); } if (!response.TryGetProperty("params", out var parameters) || parameters.ValueKind != JsonValueKind.Object || !parameters.TryGetProperty("result", out var result) || result.GetString() != "OK" || !parameters.TryGetProperty("steamid", out var steamId) || steamId.ValueKind != JsonValueKind.String) return new TicketResult(false, false, null, "Steam did not accept the ticket."); return new TicketResult(true, false, steamId.GetString(), "ok"); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException) { return new TicketResult(false, false, null, $"Steam could not be reached to check the ticket ({ex.GetType().Name})."); } } private static async Task GetJsonAsync(string url, CancellationToken cancellationToken) { using var response = await Client.GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) { Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Identity] Steam answered {(int)response.StatusCode} for " + $"{new Uri(url).AbsolutePath}."); return null; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); } private static IEnumerable CandidateAppIds(string? claimed) { var seen = new HashSet(StringComparer.Ordinal); var ordered = new List(); void Add(string? candidate) { var value = candidate?.Trim(); if (string.IsNullOrEmpty(value) || !seen.Add(value)) return; ordered.Add(value); } Add(claimed); foreach (var id in AllowedSteamAppIds) Add(id); return ordered; } private static string? ReadString(string? json, string property) { if (string.IsNullOrWhiteSpace(json)) return null; try { using var doc = JsonDocument.Parse(json); if (doc.RootElement.ValueKind != JsonValueKind.Object) return null; if (!doc.RootElement.TryGetProperty(property, out var value)) return null; return value.ValueKind switch { JsonValueKind.String => value.GetString(), JsonValueKind.Number => value.ToString(), _ => null }; } catch (JsonException) { return null; } } }