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 →
|
||||
|
||||
Reference in New Issue
Block a user