1+ using System . Security . Claims ;
2+ using System . Security . Cryptography ;
3+ using System . Text ;
14using System . Text . Json ;
2- using FastEndpoints ;
3- using Microsoft . Extensions . Caching . Memory ;
5+ using Elsa . Abstractions ;
6+ using Elsa . Resilience . Options ;
7+ using Microsoft . AspNetCore . Http ;
8+ using Microsoft . Extensions . Options ;
49using static Elsa . Resilience . Endpoints . SimulateResponse . StatusCodeMessageLookup ;
510
611namespace Elsa . Resilience . Endpoints . SimulateResponse ;
712
8- public class SimulateResponseEndpoint ( IMemoryCache memoryCache ) : EndpointWithoutRequest < SimulatedResponse >
13+ public class SimulateResponseEndpoint ( SimulateResponseSessionStore sessionStore , IOptions < SimulateResponseOptions > options ) : ElsaEndpointWithoutRequest < SimulatedResponse >
914{
10- private static readonly TimeSpan SlidingExpirationTimeSpan = TimeSpan . FromMinutes ( 15 ) ;
15+ private static readonly int [ ] DefaultCodes = [ 429 , 503 , 200 ] ;
16+ private readonly SimulateResponseOptions _options = options . Value ;
1117
1218 public override void Configure ( )
1319 {
1420 Get ( "/simulate-response" ) ;
15- AllowAnonymous ( ) ;
21+ ConfigurePermissions ( "exec:*" , "exec:resilience" , "exec:resilience:simulate-response" ) ;
1622 }
1723
1824 public override async Task HandleAsync ( CancellationToken ct )
1925 {
20- var sessionId = HttpContext . Request . Query [ "sessionId" ] . FirstOrDefault ( ) ?? "default" ;
21- var codes = GetCodes ( ) ;
22- var cacheKey = $ "status-simulation-session-{ sessionId } ";
23- var nextIndex = memoryCache . GetOrCreate ( cacheKey , entry =>
26+ if ( ! TryGetSessionId ( out var sessionId , out var error ) || ! TryGetCodes ( out var codes , out error ) )
2427 {
25- entry . SlidingExpiration = SlidingExpirationTimeSpan ;
26- return 0 ;
27- } ) ;
28+ AddError ( error ) ;
29+ await Send . ErrorsAsync ( StatusCodes . Status400BadRequest , ct ) ;
30+ return ;
31+ }
2832
29- var currentCode = nextIndex < codes . Length ? codes [ nextIndex ] : codes [ ^ 1 ] ;
33+ var scopedSessionId = CreateScopedSessionId ( sessionId ) ;
34+ if ( ! sessionStore . TryGetNextIndex ( scopedSessionId , codes . Length , out var nextIndex ) )
35+ {
36+ AddError ( $ "The maximum number of active simulate-response sessions ({ _options . SessionCapacity } ) has been reached.") ;
37+ await Send . ErrorsAsync ( StatusCodes . Status429TooManyRequests , ct ) ;
38+ return ;
39+ }
40+
41+ var currentCode = codes [ nextIndex ] ;
3042 var message = StatusMessages . TryGetValue ( currentCode , out var reason )
3143 ? reason
3244 : $ "Status Code { currentCode } ";
3345
34- if ( nextIndex + 1 >= codes . Length )
46+ await Send . ResponseAsync ( new ( message ) , currentCode , ct ) ;
47+ }
48+
49+ private bool TryGetSessionId ( out string sessionId , out string error )
50+ {
51+ sessionId = "default" ;
52+ error = "" ;
53+ var sessionIdParam = HttpContext . Request . Query [ "sessionId" ] . FirstOrDefault ( ) ;
54+
55+ if ( string . IsNullOrWhiteSpace ( sessionIdParam ) )
56+ return true ;
57+
58+ if ( sessionIdParam . Length > _options . MaxSessionIdLength )
59+ {
60+ error = $ "The sessionId query parameter must be { _options . MaxSessionIdLength } characters or fewer.";
61+ return false ;
62+ }
63+
64+ sessionId = sessionIdParam ;
65+ return true ;
66+ }
67+
68+ private bool TryGetCodes ( out int [ ] codes , out string error )
69+ {
70+ codes = DefaultCodes ;
71+ error = "" ;
72+ var codesParam = HttpContext . Request . Query [ "codes" ] . FirstOrDefault ( ) ;
73+
74+ if ( string . IsNullOrWhiteSpace ( codesParam ) )
75+ return true ;
76+
77+ if ( codesParam . Length > _options . MaxCodesQueryLength )
3578 {
36- memoryCache . Remove ( cacheKey ) ;
79+ error = $ "The codes query parameter must be { _options . MaxCodesQueryLength } characters or fewer.";
80+ return false ;
3781 }
38- else
82+
83+ try
3984 {
40- memoryCache . Set ( cacheKey , nextIndex + 1 , new MemoryCacheEntryOptions
85+ using var document = JsonDocument . Parse ( codesParam ) ;
86+
87+ if ( document . RootElement . ValueKind != JsonValueKind . Array )
88+ {
89+ error = "The codes query parameter must be a JSON array of HTTP status codes." ;
90+ return false ;
91+ }
92+
93+ var parsedCodes = new List < int > ( ) ;
94+ foreach ( var codeElement in document . RootElement . EnumerateArray ( ) )
95+ {
96+ if ( parsedCodes . Count >= _options . MaxCodes )
97+ {
98+ error = $ "The codes query parameter can contain at most { _options . MaxCodes } status codes.";
99+ return false ;
100+ }
101+
102+ if ( codeElement . ValueKind != JsonValueKind . Number || ! codeElement . TryGetInt32 ( out var code ) || code is < 100 or > 599 )
103+ {
104+ error = "The codes query parameter must contain HTTP status codes between 100 and 599." ;
105+ return false ;
106+ }
107+
108+ parsedCodes . Add ( code ) ;
109+ }
110+
111+ if ( parsedCodes . Count == 0 )
41112 {
42- SlidingExpiration = SlidingExpirationTimeSpan
43- } ) ;
113+ error = "The codes query parameter must contain at least one status code." ;
114+ return false ;
115+ }
116+
117+ codes = parsedCodes . ToArray ( ) ;
118+ return true ;
44119 }
120+ catch ( JsonException )
121+ {
122+ error = "The codes query parameter must be valid JSON." ;
123+ return false ;
124+ }
125+ }
45126
46- await Send . ResponseAsync ( new ( message ) , currentCode , ct ) ;
127+ private string CreateScopedSessionId ( string sessionId )
128+ {
129+ var key = $ "{ GetAuthenticatedIdentityKey ( ) } \0 { sessionId } ";
130+ return Convert . ToHexString ( SHA256 . HashData ( Encoding . UTF8 . GetBytes ( key ) ) ) ;
47131 }
48132
49- private int [ ] GetCodes ( )
133+ private string GetAuthenticatedIdentityKey ( )
50134 {
51- var codesParam = HttpContext . Request . Query [ "codes" ] . FirstOrDefault ( ) ;
52- int [ ] defaultCodes = [ 429 , 503 , 200 ] ;
53- return string . IsNullOrWhiteSpace ( codesParam ) ? defaultCodes : JsonSerializer . Deserialize < int [ ] > ( codesParam ) ! ;
135+ var user = HttpContext . User ;
136+ var identityClaim = user . FindFirst ( ClaimTypes . NameIdentifier )
137+ ?? user . FindFirst ( "sub" )
138+ ?? user . FindFirst ( "client_id" )
139+ ?? user . FindFirst ( "name" )
140+ ?? user . FindFirst ( ClaimTypes . Name ) ;
141+
142+ if ( identityClaim != null )
143+ return $ "{ identityClaim . Type } :{ identityClaim . Value } ";
144+
145+ if ( ! string . IsNullOrWhiteSpace ( user . Identity ? . Name ) )
146+ return $ "name:{ user . Identity . Name } ";
147+
148+ var claimsKey = string . Join ( '\u001e ' , user . Claims
149+ . Where ( x => x . Type != "permissions" )
150+ . OrderBy ( x => x . Type , StringComparer . Ordinal )
151+ . ThenBy ( x => x . Issuer , StringComparer . Ordinal )
152+ . ThenBy ( x => x . Value , StringComparer . Ordinal )
153+ . Select ( x => $ "{ x . Type } :{ x . Issuer } :{ x . Value } ") ) ;
154+
155+ return ! string . IsNullOrEmpty ( claimsKey )
156+ ? claimsKey
157+ : $ "auth:{ user . Identity ? . AuthenticationType ?? "unknown" } ";
54158 }
55- }
159+ }
0 commit comments