Code/Endpoints/NetworkStorageEndpointSecurity.cs
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
namespace Sandbox;
public static partial class NetworkStorage
{
private sealed class EndpointSecurityRequest
{
public object Body { get; init; }
public Dictionary<string, string> Headers { get; init; }
public string Mode { get; init; }
public string RoutePath { get; init; }
public string RouteLabel { get; init; }
}
private sealed class RuntimeAuthSession
{
public string Token { get; init; }
public string Id { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
}
private static RuntimeAuthSession _runtimeAuthSession;
private static async Task<EndpointSecurityRequest> BuildEndpointSecurityRequest( string slug, object input, bool allowAuthSession = true, bool useDedicatedServerSecret = false )
{
await EnsureRuntimeSecurityConfigAsync( "endpoint" );
var session = RuntimeEnableAuthSessions && allowAuthSession && !useDedicatedServerSecret
? await TryEnsureRuntimeAuthSessionAsync()
: null;
if ( !RuntimeEnableAuthSessions )
_runtimeAuthSession = null;
var mode = useDedicatedServerSecret
? RuntimeEnableEncryptedRequests ? "encrypted" : "legacy"
: RuntimeEnableEncryptedRequests && session is null
? "encrypted"
: RuntimeSecurityClientMode;
var headers = useDedicatedServerSecret
? BuildPublicHeaders( mode )
: await BuildAuthHeaders( session?.Token, mode );
var payload = ObjectToDictionary( input );
payload["security"] = BuildClientSecurityContext( mode );
if ( !RuntimeEnableEncryptedRequests )
{
payload["_endpointSlug"] = slug;
return new EndpointSecurityRequest
{
Body = payload,
Headers = headers,
Mode = mode,
RoutePath = $"/endpoints/{ProjectId}/{EscapeRouteSegment( slug )}",
RouteLabel = $"/endpoints/{ProjectId}/{slug}"
};
}
var encryptedRequestId = CreateEncryptedRequestId();
payload["encryptedRequestId"] = encryptedRequestId;
payload["_endpointSlug"] = slug;
if ( NetworkStorageLogConfig.LogRequests )
Log.Info( $"[NetworkStorage] {slug} encrypted request id={encryptedRequestId} mode={mode}" );
var envelope = CreateEncryptedEndpointEnvelope( slug, payload, session );
return new EndpointSecurityRequest
{
Body = new Dictionary<string, object>
{
["security"] = BuildClientSecurityContext( mode ),
["encrypted"] = true,
["envelope"] = envelope
},
Headers = headers,
Mode = mode,
RoutePath = $"/endpoints/{ProjectId}/{EscapeRouteSegment( slug )}",
RouteLabel = $"/endpoints/{ProjectId}/{slug}"
};
}
private static Dictionary<string, object> BuildClientSecurityContext( string mode ) => new()
{
["configVersion"] = RuntimeSecurityConfigVersion ?? "",
["clientMode"] = mode,
["authSessions"] = RuntimeEnableAuthSessions ? "enabled" : "disabled",
["encryptedRequests"] = RuntimeEnableEncryptedRequests ? "required" : "disabled",
["revisionId"] = NetworkStoragePackageInfo.RuntimeRevisionId ?? 0,
["revisionOutdated"] = NetworkStoragePackageInfo.IsOutdatedRevision,
};
private static async Task<RuntimeAuthSession> TryEnsureRuntimeAuthSessionAsync()
{
try
{
return await EnsureRuntimeAuthSessionAsync();
}
catch ( Exception ex )
{
_runtimeAuthSession = null;
if ( NetworkStorageLogConfig.LogTokens )
Log.Warning( $"[NetworkStorage] auth session unavailable ({ex.Message}); using steam-bound encrypted request mode" );
return null;
}
}
private static async Task<RuntimeAuthSession> EnsureRuntimeAuthSessionAsync()
{
if ( _runtimeAuthSession is not null && _runtimeAuthSession.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds( 30 ) )
return _runtimeAuthSession;
if ( IsDedicatedServerProcess )
{
LogDedicatedPlayerAuthSuppressedOnce();
throw new InvalidOperationException( DedicatedServerAuthUnavailableMessage() );
}
var steamId = Game.SteamId.ToString();
var token = await GetAuthTokenWithRetry( $"steamId={steamId}" );
if ( string.IsNullOrWhiteSpace( token ) )
throw new InvalidOperationException( "Auth session required, but s&box auth token is unavailable." );
var url = BuildUrl( $"/auth-sessions/{ProjectId}/create" );
var headers = new Dictionary<string, string>
{
["x-api-key"] = ApiKey ?? "",
["x-public-key"] = ApiKey ?? "",
["x-steam-id"] = steamId,
["x-sbox-token"] = token
};
AddRuntimeTargetHeaders( headers );
var body = new Dictionary<string, object> { ["steamId"] = steamId };
var raw = await Http.RequestStringAsync( url, "POST", Http.CreateJsonContent( body ), headers );
if ( NetworkStorageLogConfig.LogTokens )
Log.Info( $"[NetworkStorage] auth session create -> {TruncateForLog( raw, 500 )}" );
using var doc = JsonDocument.Parse( raw );
var root = doc.RootElement;
if ( root.TryGetProperty( "ok", out var ok ) && ok.ValueKind == JsonValueKind.False )
throw new InvalidOperationException( ReadServerMessage( root, "Auth session create failed." ) );
var sessionToken = ReadString( root, "sessionToken" );
var ttlSeconds = ReadInt( root, "ttlSeconds", 3600 );
var session = root.TryGetProperty( "session", out var sessionProp ) ? sessionProp : default;
var sessionId = ReadString( session, "id" );
if ( string.IsNullOrWhiteSpace( sessionToken ) || string.IsNullOrWhiteSpace( sessionId ) )
throw new InvalidOperationException( "Auth session create response was missing token or session id." );
_runtimeAuthSession = new RuntimeAuthSession
{
Token = sessionToken,
Id = sessionId,
ExpiresAt = DateTimeOffset.UtcNow.AddSeconds( Math.Max( 60, ttlSeconds ) )
};
if ( NetworkStorageLogConfig.LogTokens )
Log.Info( $"[NetworkStorage] auth session loaded id={sessionId} ttl={ttlSeconds}s mode={RuntimeSecurityClientMode}" );
return _runtimeAuthSession;
}
}