diff --git a/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs b/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs index a48e217..3379886 100644 --- a/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs +++ b/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs @@ -123,17 +123,30 @@ public class ShadowverseTranslationMiddleware : IMiddleware throw; } + // Peek the decrypted msgpack as a raw dict to extract the auth tuple BEFORE the typed + // DTO deserialize drops anything the action's DTO doesn't model. Stash the result in + // HttpContext.Items so SteamSessionAuthenticationHandler can read it without depending + // on the DTO shape — that's the whole point of the decoupling, see + // docs/superpowers/specs/2026-06-02-baseRequest-auth-footgun-improvement.md. Failures + // here are non-fatal: the auth handler will surface a 401 with a more specific reason + // (missing ticket vs corrupt body) than we could from middleware. + if (!skipEncryption) + { + TryStashAuthFields(context, decryptedBytes); + } + var firstParam = endpointDescriptor.Parameters.FirstOrDefault(); if (firstParam is null) { // Action method has no parameters — middleware can't bind the (encrypted+msgpacked) - // body to anything. The codebase convention is to take a BaseRequest even for body- - // less endpoints (see e.g. PuzzleController.Info(BaseRequest _)). Fail loud with a - // specific message rather than NREing below on .ParameterType. + // body to anything. Fail loud with a specific message rather than NREing below on + // .ParameterType. Authed actions can declare any DTO shape (auth fields are already + // stashed via TryStashAuthFields above); they just need ONE parameter so the binder + // has somewhere to put the rewritten JSON body. throw new InvalidOperationException( $"Action {endpointDescriptor.DisplayName} has no parameters; the SV translation " + - "middleware needs at least one to bind the decrypted body. Add a BaseRequest parameter " + - "(or a derived DTO) — see other *Info/*Top actions for the convention."); + "middleware needs at least one to bind the decrypted body. Add a request DTO " + + "parameter — even an empty one (see ProfileIndexRequest for the minimal shape)."); } Type requestType = firstParam.ParameterType; object? data; @@ -271,6 +284,54 @@ public class ShadowverseTranslationMiddleware : IMiddleware context.Response.Body = originalResponsebody; } + /// + /// Pulls viewer_id / steam_id / steam_session_ticket out of the + /// decrypted msgpack body and stashes them in HttpContext.Items[AuthFields.ContextKey]. + /// Lets the Steam handler read the auth tuple from a separate channel so action DTOs no + /// longer need to inherit BaseRequest just so the handler can find the ticket. + /// Failures (corrupt body, non-map root, missing keys) are silent on purpose: the auth + /// handler will surface a more specific 401 reason than we can here. + /// + private static void TryStashAuthFields(HttpContext context, byte[] decryptedBytes) + { + try + { + var raw = MessagePackSerializer.Deserialize>( + decryptedBytes, + MessagePackSerializerOptions.Standard.WithResolver(ContractlessStandardResolver.Instance)); + if (raw is null) return; + + context.Items[Security.SteamSessionAuthentication.AuthFields.ContextKey] = + new Security.SteamSessionAuthentication.AuthFields + { + ViewerId = TryGetString(raw, "viewer_id"), + SteamId = TryGetUlong(raw, "steam_id"), + SteamSessionTicket = TryGetString(raw, "steam_session_ticket"), + }; + } + catch + { + // Malformed body — auth handler will fail with its own diagnostic. + } + } + + private static string? TryGetString(Dictionary raw, string key) => + raw.TryGetValue(key, out var v) ? v as string : null; + + private static ulong TryGetUlong(Dictionary raw, string key) + { + if (!raw.TryGetValue(key, out var v) || v is null) return 0; + return v switch + { + ulong u => u, + long l => unchecked((ulong)l), + int i => unchecked((ulong)(long)i), + uint ui => ui, + string s => ulong.TryParse(s, out var parsed) ? parsed : 0, + _ => 0, + }; + } + /// /// Walks a parsed JSON tree into the plain CLR shape MessagePack-CSharp's contractless /// resolver understands: objects → Dictionary<string, object?>, arrays → diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/ItemAcquireHistory/ItemAcquireHistoryInfoRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/ItemAcquireHistory/ItemAcquireHistoryInfoRequest.cs index 20dfad1..0abd2d8 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/ItemAcquireHistory/ItemAcquireHistoryInfoRequest.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/ItemAcquireHistory/ItemAcquireHistoryInfoRequest.cs @@ -3,8 +3,10 @@ using MessagePack; namespace SVSim.EmulatedEntrypoint.Models.Dtos.ItemAcquireHistory; /// -/// Empty request body. The endpoint takes no parameters; this DTO exists so model binding -/// resolves the envelope correctly. +/// Empty request body — the endpoint takes no parameters. Does not inherit BaseRequest: the +/// translation middleware stashes the auth tuple into HttpContext.Items before the typed DTO +/// deserialize, so the Steam handler reads them from there. See ProfileIndexRequest + +/// AuthDecouplingTests for the pattern. /// [MessagePackObject(true)] public sealed class ItemAcquireHistoryInfoRequest diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Profile/ProfileIndexRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Profile/ProfileIndexRequest.cs index 0ee1ce8..1a0315e 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Profile/ProfileIndexRequest.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Profile/ProfileIndexRequest.cs @@ -3,8 +3,12 @@ using MessagePack; namespace SVSim.EmulatedEntrypoint.Models.Dtos.Profile; /// -/// Empty request body. The endpoint takes no parameters (client task uses BaseParam directly); -/// this DTO exists so model binding resolves the envelope correctly. +/// Empty request body — the endpoint takes no parameters and deliberately does NOT inherit +/// BaseRequest. The translation middleware pulls the auth tuple +/// (viewer_id / steam_id / steam_session_ticket) straight out of the decrypted msgpack dict +/// into HttpContext.Items[AuthFields.ContextKey] before deserializing into this DTO, +/// so the Steam handler reads them from there rather than re-parsing the rewritten body. +/// See AuthDecouplingTests for the integration test that pins this contract down. /// [MessagePackObject(true)] public sealed class ProfileIndexRequest diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/UserMyPage/UserMyPageUpdateRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/UserMyPage/UserMyPageUpdateRequest.cs index 6fbeec7..8d364bd 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/UserMyPage/UserMyPageUpdateRequest.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/UserMyPage/UserMyPageUpdateRequest.cs @@ -6,7 +6,9 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos.UserMyPage; /// /// Body of POST /user_mypage/update. Client task: MyPageSettingUpdateTask /// (Shadowverse_Code_2026-05-23/Wizard/MyPageSettingUpdateTask.cs). Note that -/// select_type is the only int on the wire — id fields are strings. +/// select_type is the only int on the wire — id fields are strings. Does not inherit +/// BaseRequest: the translation middleware stashes the auth tuple into HttpContext.Items +/// before the typed DTO deserialize, so the Steam handler reads them from there. /// [MessagePackObject] public sealed class UserMyPageUpdateRequest diff --git a/SVSim.EmulatedEntrypoint/Security/SteamSessionAuthentication/AuthFields.cs b/SVSim.EmulatedEntrypoint/Security/SteamSessionAuthentication/AuthFields.cs new file mode 100644 index 0000000..fa5d86a --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Security/SteamSessionAuthentication/AuthFields.cs @@ -0,0 +1,22 @@ +namespace SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication; + +/// +/// Auth tuple extracted from the decrypted msgpack request body BEFORE it gets pivoted into +/// the action's typed DTO. Stashed into HttpContext.Items under +/// by ShadowverseTranslationMiddleware so SteamSessionAuthenticationHandler can +/// read the ticket without depending on the DTO modelling these fields. +/// +/// History: see docs/superpowers/specs/2026-06-02-baseRequest-auth-footgun-improvement.md. +/// The pre-existing route required every authed DTO to inherit BaseRequest (otherwise +/// the msgpack→DTO→JSON pivot dropped the auth fields and the handler silently 401'd live). +/// Surfacing the fields via a separate channel decouples auth from DTO shape entirely. +/// +public sealed class AuthFields +{ + /// Items key under which the middleware stashes / the handler reads the auth tuple. + public const string ContextKey = "SVSim.AuthFields"; + + public string? ViewerId { get; init; } + public ulong SteamId { get; init; } + public string? SteamSessionTicket { get; init; } +} diff --git a/SVSim.EmulatedEntrypoint/Security/SteamSessionAuthentication/SteamSessionAuthenticationHandler.cs b/SVSim.EmulatedEntrypoint/Security/SteamSessionAuthentication/SteamSessionAuthenticationHandler.cs index 431f6c4..a4bf0c7 100644 --- a/SVSim.EmulatedEntrypoint/Security/SteamSessionAuthentication/SteamSessionAuthenticationHandler.cs +++ b/SVSim.EmulatedEntrypoint/Security/SteamSessionAuthentication/SteamSessionAuthenticationHandler.cs @@ -1,7 +1,5 @@ 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; @@ -9,21 +7,12 @@ 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) @@ -48,65 +37,43 @@ public class SteamSessionAuthenticationHandler : AuthenticationHandler(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); + Logger.LogWarning( + "Auth: no AuthFields in HttpContext.Items on {Path}. The translation middleware " + + "either didn't run (non-Unity UA?) or the body wasn't a msgpack map.", + path); return AuthenticateResult.Fail("Invalid request body."); } - if (requestJson is null || string.IsNullOrEmpty(requestJson.SteamSessionTicket)) + if (string.IsNullOrEmpty(auth.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); + "Auth: request body missing steam_session_ticket on {Path} (hasViewerId={HasViewerId}, steamId={SteamId}).", + path, !string.IsNullOrEmpty(auth.ViewerId), auth.SteamId); return AuthenticateResult.Fail("Invalid request body."); } // Check steam session validity - bool sessionIsValid = _sessionService.IsTicketValidForUser(requestJson.SteamSessionTicket, requestJson.SteamId); + bool sessionIsValid = _sessionService.IsTicketValidForUser(auth.SteamSessionTicket, auth.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); + path, auth.SteamId, auth.SteamSessionTicket.Length); return AuthenticateResult.Fail("Invalid ticket."); } Viewer? viewer = - await _viewerRepository.GetViewerBySocialConnection(SocialAccountType.Steam, requestJson.SteamId); + await _viewerRepository.GetViewerBySocialConnection(SocialAccountType.Steam, auth.SteamId); if (viewer is null) { @@ -123,12 +90,12 @@ public class SteamSessionAuthenticationHandler : AuthenticationHandler +/// Builds a request that mirrors what the Unity client posts: msgpack-serialized body, AES- +/// encrypted with the viewer's UDID, plus the UDID/SID headers and Unity user-agent that the +/// translation middleware uses to recognize the wire format. +/// +internal static class EncryptedMsgpackHelper +{ + private static readonly MessagePackSerializerOptions ContractlessOpts = + MessagePackSerializerOptions.Standard.WithResolver(ContractlessStandardResolver.Instance); + + /// + /// Pairs a fresh UDID with a unique SID and registers the mapping on the running test host's + /// via the SessionidMappingMiddleware path (a GET to + /// any endpoint with both headers seeds the dict). Returns the pair for reuse on subsequent + /// POSTs in the same test. + /// + public static (Guid Udid, string Sid) NewSessionIds() + { + var udid = Guid.NewGuid(); + var sid = Guid.NewGuid().ToString("N"); + return (udid, sid); + } + + /// + /// Builds a POST request to shaped like a real Unity client call: + /// msgpack body (contractless dictionary), AES-encrypted with , with + /// the Unity user-agent and UDID/SID headers wired up. Caller sends it via + /// . + /// + public static HttpRequestMessage BuildPost( + string path, + IReadOnlyDictionary body, + Guid udid, + string sid) + { + byte[] msgpackBody = MessagePackSerializer.Serialize(body, ContractlessOpts); + byte[] encryptedBody = Encryption.Encrypt(msgpackBody, udid.ToString()); + + var request = new HttpRequestMessage(HttpMethod.Post, path) + { + Content = new ByteArrayContent(encryptedBody), + }; + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + request.Headers.UserAgent.ParseAdd("UnityPlayer/2022.3.0 (test)"); + request.Headers.Add(NetworkConstants.UdidHeaderName, Encryption.Encode(udid.ToString())); + request.Headers.Add(NetworkConstants.SessionIdHeaderName, sid); + return request; + } +} diff --git a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs index c4a62c1..ee76887 100644 --- a/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs +++ b/SVSim.UnitTests/Infrastructure/SVSimTestFactory.cs @@ -24,15 +24,17 @@ namespace SVSim.UnitTests.Infrastructure; /// header-driven test versions, and exposes a helper for tests /// to create realistic viewer rows. /// -internal sealed class SVSimTestFactory : WebApplicationFactory +internal class SVSimTestFactory : WebApplicationFactory { private readonly SqliteConnection _connection; private long _nextSeededShortUdid = 400_000_001; private readonly bool _freeplayEnabled; + private readonly bool _useRealAuthHandler; - public SVSimTestFactory(bool freeplayEnabled = false) + public SVSimTestFactory(bool freeplayEnabled = false, bool useRealAuthHandler = false) { _freeplayEnabled = freeplayEnabled; + _useRealAuthHandler = useRealAuthHandler; // SQLite :memory: lives only as long as a connection is open — keep ours open for the // factory's lifetime so the DbContext can reattach to the same DB across scopes. _connection = new SqliteConnection("DataSource=:memory:"); @@ -48,7 +50,19 @@ internal sealed class SVSimTestFactory : WebApplicationFactory builder.ConfigureTestServices(services => { ReplaceDbContext(services); - ReplaceAuthHandler(services); + if (!_useRealAuthHandler) + { + ReplaceAuthHandler(services); + } + else + { + // Real auth handler stays in place; bypass the live Steam SDK so synthetic + // tickets validate without touching Steam. + var steamServer = services.FirstOrDefault(d => d.ServiceType == typeof(SVSim.EmulatedEntrypoint.Services.ISteamServer)); + if (steamServer is not null) services.Remove(steamServer); + services.AddSingleton(); + } }); } diff --git a/SVSim.UnitTests/Security/AuthDecouplingTests.cs b/SVSim.UnitTests/Security/AuthDecouplingTests.cs new file mode 100644 index 0000000..6968cc4 --- /dev/null +++ b/SVSim.UnitTests/Security/AuthDecouplingTests.cs @@ -0,0 +1,51 @@ +using System.Net; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Security; + +/// +/// Pins down the wire-level contract that authed endpoints work even when their +/// [FromBody] DTO doesn't inherit BaseRequest. The translation middleware +/// extracts the auth tuple (viewer_id / steam_id / steam_session_ticket) +/// from the raw decrypted msgpack dict and stashes it in HttpContext.Items before the +/// typed DTO deserialize runs, so the Steam handler can read the ticket without depending on +/// the action's DTO shape. +/// +/// History: this was a recurring footgun (2026-05-25 basic-puzzle, 2026-05-28 deck-code, +/// 2026-06-02 Phase 3 Bot, 2026-06-10 profile/index + item_acquire_history/info) where +/// every per-DTO workaround eventually got forgotten somewhere else. See +/// docs/superpowers/specs/2026-06-02-baseRequest-auth-footgun-improvement.md for the +/// design. +/// +[TestFixture] +public class AuthDecouplingTests +{ + [Test] + public async Task ProfileIndex_succeeds_when_DTO_does_not_inherit_BaseRequest() + { + const ulong steamId = 76_561_198_000_000_999UL; + await using var factory = new SVSimTestFactory(useRealAuthHandler: true); + await factory.SeedViewerAsync(steamId: steamId); + + var (udid, sid) = EncryptedMsgpackHelper.NewSessionIds(); + var body = new Dictionary + { + ["viewer_id"] = "test-viewer-id-blob", + ["steam_id"] = steamId, + ["steam_session_ticket"] = "deadbeef", // hex-decoded by SteamSessionService; DevAlwaysValidSteamServer accepts any bytes + }; + var request = EncryptedMsgpackHelper.BuildPost("/profile/index", body, udid, sid); + + using var client = factory.CreateClient(); + var response = await client.SendAsync(request); + + // The DTO (ProfileIndexRequest) has no [Key]'d fields — without the auth-field stash, + // the msgpack-to-DTO-to-JSON pivot strips viewer_id/steam_id/steam_session_ticket and + // the handler 401s on "missing steam_session_ticket". Option A keeps them alive in + // HttpContext.Items so the handler still authenticates. + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), + "/profile/index should authenticate against a DTO that does not inherit BaseRequest. " + + "If this fails with 401, the translation middleware probably stopped stashing AuthFields " + + "into HttpContext.Items before DTO deserialization."); + } +}