using System.Security.Claims; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Options; using SVSim.Database.Enums; using SVSim.Database.Models; using SVSim.Database.Repositories.Viewer; using SVSim.EmulatedEntrypoint.Constants; using SVSim.EmulatedEntrypoint.Extensions; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; using SVSim.EmulatedEntrypoint.Services; namespace SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication; public class SteamSessionAuthenticationHandler : AuthenticationHandler { // Must mirror the controller-side JSON options — the translation middleware rewrites the // request body in snake_case, and we have to read it back the same way or every property // binds to null and we NRE downstream against the Steam ticket. private static readonly JsonSerializerOptions RequestJsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; private readonly SteamSessionService _sessionService; private readonly IViewerRepository _viewerRepository; public SteamSessionAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, SteamSessionService sessionService, IViewerRepository viewerRepository) : base(options, logger, encoder) { _sessionService = sessionService; _viewerRepository = viewerRepository; } protected async override Task HandleAuthenticateAsync() { string path = Request.Path; byte[] requestBytes; try { using (var requestBytesStream = new MemoryStream()) { // Reset request stream Request.Body.Seek(0, SeekOrigin.Begin); await Request.Body.CopyToAsync(requestBytesStream); requestBytes = requestBytesStream.ToArray(); // Reset request stream Request.Body.Seek(0, SeekOrigin.Begin); } } catch (Exception e) { Logger.LogWarning(e, "Auth: failed to read request body on {Path}.", path); return AuthenticateResult.Fail("Failed to read request body."); } // Convert bytes to json string requestString = Encoding.UTF8.GetString(requestBytes); BaseRequest? requestJson; try { requestJson = JsonSerializer.Deserialize(requestString, RequestJsonOptions); } catch (JsonException ex) { Logger.LogWarning(ex, "Auth: failed to JSON-parse request body on {Path} (bodyLen={BodyLen}). " + "Translation middleware should have rewritten this to JSON — if it didn't, the request bypassed translation (non-Unity UA?).", path, requestBytes.Length); return AuthenticateResult.Fail("Invalid request body."); } if (requestJson is null || string.IsNullOrEmpty(requestJson.SteamSessionTicket)) { Logger.LogWarning( "Auth: request body missing steam_session_ticket on {Path} (bodyLen={BodyLen}, hasViewerId={HasViewerId}, steamId={SteamId}).", path, requestBytes.Length, !string.IsNullOrEmpty(requestJson?.ViewerId), requestJson?.SteamId ?? 0); return AuthenticateResult.Fail("Invalid request body."); } // Check steam session validity bool sessionIsValid = _sessionService.IsTicketValidForUser(requestJson.SteamSessionTicket, requestJson.SteamId); if (!sessionIsValid) { Logger.LogWarning( "Auth: Steam ticket rejected on {Path} for steamId={SteamId} (ticketLen={TicketLen}). " + "See SteamSessionService logs above for the underlying Steam reason (BeginAuthSession failure, duplicate, etc.).", path, requestJson.SteamId, requestJson.SteamSessionTicket.Length); return AuthenticateResult.Fail("Invalid ticket."); } Viewer? viewer = await _viewerRepository.GetViewerBySocialConnection(SocialAccountType.Steam, requestJson.SteamId); if (viewer is null) { // Find-or-link: first authenticated request after /tool/signup. The client signed up // anonymously and has no Steam social row yet; if the UDID resolves to a viewer, attach // Steam to it now so subsequent requests hit the fast SteamId path. The SteamId unique // index on SocialAccountConnection is the dedup backstop for concurrent first-touch. Guid? udid = Context.GetUdid(); if (udid is Guid u && u != Guid.Empty) { viewer = await _viewerRepository.GetViewerByUdid(u); if (viewer is not null) { await _viewerRepository.LinkSteamToViewer(viewer.Id, requestJson.SteamId); // Re-read with socials so transition_account_data downstream sees the new link. viewer = await _viewerRepository.GetViewerWithSocials(viewer.Id) ?? viewer; Logger.LogInformation( "Auth: linked steamId={SteamId} to UDID-keyed viewer_id={ViewerId} on {Path} (first-Steam-touch).", requestJson.SteamId, viewer.Id, path); } } if (viewer is null) { Logger.LogWarning( "Auth: no viewer linked to steamId={SteamId} on {Path}, and no UDID-keyed viewer to link to. " + "Client must call /tool/signup before authenticated endpoints.", requestJson.SteamId, path); return AuthenticateResult.Fail("User not found."); } } // Add viewer to context Context.SetViewer(viewer); // Build identity ClaimsIdentity identity = new ClaimsIdentity(SteamAuthenticationConstants.SchemeName); identity.AddClaim(new Claim(ClaimTypes.Name, viewer.DisplayName)); identity.AddClaim(new Claim(ShadowverseClaimTypes.ShortUdidClaim, viewer.ShortUdid.ToString())); identity.AddClaim(new Claim(ShadowverseClaimTypes.ViewerIdClaim, viewer.Id.ToString())); identity.AddClaim(new Claim(SteamAuthenticationConstants.SteamIdClaim, requestJson.SteamId.ToString())); // Build and return final ticket AuthenticationTicket ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SteamAuthenticationConstants.SchemeName); return AuthenticateResult.Success(ticket); } }