refactor(auth): decouple Steam handler from request DTO shape
Translation middleware now extracts viewer_id/steam_id/steam_session_ticket from the decrypted msgpack dict into HttpContext.Items before the typed DTO deserialize. The Steam handler reads from there instead of re-parsing Request.Body — so authed action DTOs no longer need to inherit BaseRequest to keep the auth fields alive through the msgpack→DTO→JSON pivot. Retires the recurring footgun documented in docs/superpowers/specs/2026-06-02-baseRequest-auth-footgun-improvement.md (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 + user_mypage/update). Pinned by AuthDecouplingTests — posts an encrypted msgpack body to /profile/index (DTO does not inherit BaseRequest) through the real translation middleware + auth handler and asserts 200. Adds an EncryptedMsgpackHelper + useRealAuthHandler factory flag, reusable for future wire-shape tests. ProfileIndexRequest, ItemAcquireHistoryInfoRequest, and UserMyPageUpdateRequest revert to the naked shape — the per-DTO workarounds become vestigial under the new architecture. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pulls <c>viewer_id</c> / <c>steam_id</c> / <c>steam_session_ticket</c> out of the
|
||||
/// decrypted msgpack body and stashes them in <c>HttpContext.Items[AuthFields.ContextKey]</c>.
|
||||
/// Lets the Steam handler read the auth tuple from a separate channel so action DTOs no
|
||||
/// longer need to inherit <c>BaseRequest</c> 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.
|
||||
/// </summary>
|
||||
private static void TryStashAuthFields(HttpContext context, byte[] decryptedBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = MessagePackSerializer.Deserialize<Dictionary<object, object?>>(
|
||||
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<object, object?> raw, string key) =>
|
||||
raw.TryGetValue(key, out var v) ? v as string : null;
|
||||
|
||||
private static ulong TryGetUlong(Dictionary<object, object?> 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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks a parsed JSON tree into the plain CLR shape MessagePack-CSharp's contractless
|
||||
/// resolver understands: objects → <c>Dictionary<string, object?></c>, arrays →
|
||||
|
||||
@@ -3,8 +3,10 @@ using MessagePack;
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.ItemAcquireHistory;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[MessagePackObject(true)]
|
||||
public sealed class ItemAcquireHistoryInfoRequest
|
||||
|
||||
@@ -3,8 +3,12 @@ using MessagePack;
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Profile;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>HttpContext.Items[AuthFields.ContextKey]</c> before deserializing into this DTO,
|
||||
/// so the Steam handler reads them from there rather than re-parsing the rewritten body.
|
||||
/// See <c>AuthDecouplingTests</c> for the integration test that pins this contract down.
|
||||
/// </summary>
|
||||
[MessagePackObject(true)]
|
||||
public sealed class ProfileIndexRequest
|
||||
|
||||
@@ -6,7 +6,9 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos.UserMyPage;
|
||||
/// <summary>
|
||||
/// Body of <c>POST /user_mypage/update</c>. Client task: <c>MyPageSettingUpdateTask</c>
|
||||
/// (Shadowverse_Code_2026-05-23/Wizard/MyPageSettingUpdateTask.cs). Note that
|
||||
/// <c>select_type</c> is the only int on the wire — id fields are strings.
|
||||
/// <c>select_type</c> 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.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class UserMyPageUpdateRequest
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
|
||||
|
||||
/// <summary>
|
||||
/// Auth tuple extracted from the decrypted msgpack request body BEFORE it gets pivoted into
|
||||
/// the action's typed DTO. Stashed into <c>HttpContext.Items</c> under <see cref="ContextKey"/>
|
||||
/// by <c>ShadowverseTranslationMiddleware</c> so <c>SteamSessionAuthenticationHandler</c> can
|
||||
/// read the ticket without depending on the DTO modelling these fields.
|
||||
///
|
||||
/// History: see <c>docs/superpowers/specs/2026-06-02-baseRequest-auth-footgun-improvement.md</c>.
|
||||
/// The pre-existing route required every authed DTO to inherit <c>BaseRequest</c> (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.
|
||||
/// </summary>
|
||||
public sealed class AuthFields
|
||||
{
|
||||
/// <summary>Items key under which the middleware stashes / the handler reads the auth tuple.</summary>
|
||||
public const string ContextKey = "SVSim.AuthFields";
|
||||
|
||||
public string? ViewerId { get; init; }
|
||||
public ulong SteamId { get; init; }
|
||||
public string? SteamSessionTicket { get; init; }
|
||||
}
|
||||
@@ -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<SteamAuthenticationHandlerOptions>
|
||||
{
|
||||
// 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<SteamAuthenticationHandlerOptions> options, ILoggerFactory logger, UrlEncoder encoder, SteamSessionService sessionService, IViewerRepository viewerRepository) : base(options, logger, encoder)
|
||||
@@ -48,65 +37,43 @@ public class SteamSessionAuthenticationHandler : AuthenticationHandler<SteamAuth
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
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)
|
||||
// Read the auth tuple from HttpContext.Items, populated by ShadowverseTranslationMiddleware
|
||||
// off the raw decrypted msgpack dict BEFORE the action's typed DTO deserialize. This
|
||||
// decouples auth from DTO shape — see AuthFields and the design spec at
|
||||
// docs/superpowers/specs/2026-06-02-baseRequest-auth-footgun-improvement.md. The prior
|
||||
// approach re-parsed Request.Body as JSON into a BaseRequest; any action whose DTO didn't
|
||||
// inherit BaseRequest silently 401'd because the msgpack→DTO→JSON pivot dropped the fields.
|
||||
if (Context.Items[AuthFields.ContextKey] is not AuthFields auth)
|
||||
{
|
||||
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<BaseRequest>(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<SteamAuth
|
||||
viewer = await _viewerRepository.GetViewerByUdid(u);
|
||||
if (viewer is not null)
|
||||
{
|
||||
await _viewerRepository.LinkSteamToViewer(viewer.Id, requestJson.SteamId);
|
||||
await _viewerRepository.LinkSteamToViewer(viewer.Id, auth.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);
|
||||
auth.SteamId, viewer.Id, path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +104,7 @@ public class SteamSessionAuthenticationHandler : AuthenticationHandler<SteamAuth
|
||||
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);
|
||||
auth.SteamId, path);
|
||||
return AuthenticateResult.Fail("User not found.");
|
||||
}
|
||||
}
|
||||
@@ -150,7 +117,7 @@ public class SteamSessionAuthenticationHandler : AuthenticationHandler<SteamAuth
|
||||
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()));
|
||||
identity.AddClaim(new Claim(SteamAuthenticationConstants.SteamIdClaim, auth.SteamId.ToString()));
|
||||
|
||||
// Build and return final ticket
|
||||
AuthenticationTicket ticket =
|
||||
|
||||
57
SVSim.UnitTests/Infrastructure/EncryptedMsgpackHelper.cs
Normal file
57
SVSim.UnitTests/Infrastructure/EncryptedMsgpackHelper.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.Net.Http.Headers;
|
||||
using MessagePack;
|
||||
using MessagePack.Resolvers;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Security;
|
||||
|
||||
namespace SVSim.UnitTests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class EncryptedMsgpackHelper
|
||||
{
|
||||
private static readonly MessagePackSerializerOptions ContractlessOpts =
|
||||
MessagePackSerializerOptions.Standard.WithResolver(ContractlessStandardResolver.Instance);
|
||||
|
||||
/// <summary>
|
||||
/// Pairs a fresh UDID with a unique SID and registers the mapping on the running test host's
|
||||
/// <see cref="ShadowverseSessionService"/> 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.
|
||||
/// </summary>
|
||||
public static (Guid Udid, string Sid) NewSessionIds()
|
||||
{
|
||||
var udid = Guid.NewGuid();
|
||||
var sid = Guid.NewGuid().ToString("N");
|
||||
return (udid, sid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a POST request to <paramref name="path"/> shaped like a real Unity client call:
|
||||
/// msgpack body (contractless dictionary), AES-encrypted with <paramref name="udid"/>, with
|
||||
/// the Unity user-agent and UDID/SID headers wired up. Caller sends it via
|
||||
/// <see cref="HttpClient.SendAsync(HttpRequestMessage)"/>.
|
||||
/// </summary>
|
||||
public static HttpRequestMessage BuildPost(
|
||||
string path,
|
||||
IReadOnlyDictionary<string, object?> 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;
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,17 @@ namespace SVSim.UnitTests.Infrastructure;
|
||||
/// header-driven test versions, and exposes a <see cref="SeedViewerAsync"/> helper for tests
|
||||
/// to create realistic viewer rows.
|
||||
/// </summary>
|
||||
internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
internal class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
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<Program>
|
||||
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<SVSim.EmulatedEntrypoint.Services.ISteamServer,
|
||||
SVSim.EmulatedEntrypoint.Services.DevAlwaysValidSteamServer>();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
51
SVSim.UnitTests/Security/AuthDecouplingTests.cs
Normal file
51
SVSim.UnitTests/Security/AuthDecouplingTests.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System.Net;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Pins down the wire-level contract that authed endpoints work even when their
|
||||
/// <c>[FromBody]</c> DTO doesn't inherit <c>BaseRequest</c>. The translation middleware
|
||||
/// extracts the auth tuple (<c>viewer_id</c> / <c>steam_id</c> / <c>steam_session_ticket</c>)
|
||||
/// from the raw decrypted msgpack dict and stashes it in <c>HttpContext.Items</c> 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
|
||||
/// <c>docs/superpowers/specs/2026-06-02-baseRequest-auth-footgun-improvement.md</c> for the
|
||||
/// design.
|
||||
/// </summary>
|
||||
[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<string, object?>
|
||||
{
|
||||
["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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user