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.");
+ }
+}