@@ -192,6 +192,201 @@ public async Task LookupServicePrincipalAsync_DoesNotIncludeConsistencyLevelHead
192192 "ConsistencyLevel header should NOT be present for simple service principal lookup queries. " +
193193 "This header is only needed for advanced Graph query capabilities and causes HTTP 400 errors otherwise." ) ;
194194 }
195+
196+ [ Theory ]
197+ [ InlineData ( "token-with-trailing-newline\n " ) ]
198+ [ InlineData ( "token-with-trailing-crlf\r \n " ) ]
199+ [ InlineData ( "token\n with\n embedded\n newlines" ) ]
200+ [ InlineData ( "token\r \n with\r \n embedded\r \n crlf" ) ]
201+ [ InlineData ( "token\r with\r carriage\r returns" ) ]
202+ [ InlineData ( "\n leading-newline-token" ) ]
203+ [ InlineData ( "\r \n leading-crlf-token" ) ]
204+ [ InlineData ( " token-with-whitespace \n " ) ]
205+ public async Task GraphGetAsync_SanitizesTokenWithNewlineCharacters ( string tokenWithNewlines )
206+ {
207+ // This test verifies that tokens containing newline characters (\r, \n, \r\n)
208+ // are properly sanitized before being used in HTTP Authorization headers.
209+ // Without this fix, System.FormatException is thrown:
210+ // "New-line characters are not allowed in header values."
211+ // Regression test for newline character issue in token handling.
212+
213+ // Arrange
214+ HttpRequestMessage ? capturedRequest = null ;
215+ var handler = new CapturingHttpMessageHandler ( ( req ) => capturedRequest = req ) ;
216+ var logger = Substitute . For < ILogger < GraphApiService > > ( ) ;
217+ var executor = Substitute . For < CommandExecutor > ( Substitute . For < ILogger < CommandExecutor > > ( ) ) ;
218+
219+ // Mock az CLI to return a token WITH newline characters (simulating real-world issue)
220+ executor . ExecuteAsync ( Arg . Any < string > ( ) , Arg . Any < string > ( ) , Arg . Any < string ? > ( ) , Arg . Any < bool > ( ) , Arg . Any < bool > ( ) , Arg . Any < CancellationToken > ( ) )
221+ . Returns ( callInfo =>
222+ {
223+ var cmd = callInfo . ArgAt < string > ( 0 ) ;
224+ var args = callInfo . ArgAt < string > ( 1 ) ;
225+
226+ if ( cmd == "az" && args != null && args . StartsWith ( "account show" , StringComparison . OrdinalIgnoreCase ) )
227+ {
228+ return Task . FromResult ( new CommandResult
229+ {
230+ ExitCode = 0 ,
231+ StandardOutput = "{}" ,
232+ StandardError = string . Empty
233+ } ) ;
234+ }
235+
236+ if ( cmd == "az" && args != null && args . Contains ( "get-access-token" , StringComparison . OrdinalIgnoreCase ) )
237+ {
238+ // Return token WITH newline characters - this simulates the real-world issue
239+ return Task . FromResult ( new CommandResult
240+ {
241+ ExitCode = 0 ,
242+ StandardOutput = tokenWithNewlines ,
243+ StandardError = string . Empty
244+ } ) ;
245+ }
246+
247+ return Task . FromResult ( new CommandResult { ExitCode = 0 , StandardOutput = string . Empty , StandardError = string . Empty } ) ;
248+ } ) ;
249+
250+ var service = new GraphApiService ( logger , executor , handler ) ;
251+
252+ // Queue a successful response
253+ using var queuedResponse = new HttpResponseMessage ( HttpStatusCode . OK )
254+ {
255+ Content = new StringContent ( "{\" value\" :[]}" )
256+ } ;
257+ handler . QueueResponse ( queuedResponse ) ;
258+
259+ // Act - This should NOT throw FormatException even with newlines in token
260+ var result = await service . GraphGetAsync ( "tenant-123" , "/v1.0/me" ) ;
261+
262+ // Assert
263+ capturedRequest . Should ( ) . NotBeNull ( "HTTP request should have been sent" ) ;
264+ capturedRequest ! . Headers . Authorization . Should ( ) . NotBeNull ( "Authorization header should be set" ) ;
265+ capturedRequest . Headers . Authorization ! . Scheme . Should ( ) . Be ( "Bearer" ) ;
266+
267+ // The token in the header should NOT contain any newline characters
268+ var actualToken = capturedRequest . Headers . Authorization . Parameter ;
269+ actualToken . Should ( ) . NotBeNull ( ) ;
270+ actualToken . Should ( ) . NotContain ( "\r " , "Token should not contain carriage return characters" ) ;
271+ actualToken . Should ( ) . NotContain ( "\n " , "Token should not contain newline characters" ) ;
272+ actualToken . Should ( ) . NotStartWith ( " " , "Token should not have leading whitespace" ) ;
273+ actualToken . Should ( ) . NotEndWith ( " " , "Token should not have trailing whitespace" ) ;
274+ }
275+
276+ [ Fact ]
277+ public async Task GraphGetAsync_TokenFromTokenProvider_SanitizesNewlines ( )
278+ {
279+ // This test verifies that tokens from IMicrosoftGraphTokenProvider are also sanitized.
280+ // The token provider path uses a different code branch in EnsureGraphHeadersAsync.
281+
282+ // Arrange
283+ HttpRequestMessage ? capturedRequest = null ;
284+ var handler = new CapturingHttpMessageHandler ( ( req ) => capturedRequest = req ) ;
285+ var logger = Substitute . For < ILogger < GraphApiService > > ( ) ;
286+ var executor = Substitute . For < CommandExecutor > ( Substitute . For < ILogger < CommandExecutor > > ( ) ) ;
287+ var tokenProvider = Substitute . For < IMicrosoftGraphTokenProvider > ( ) ;
288+
289+ // Mock token provider to return a token WITH embedded newlines
290+ tokenProvider . GetMgGraphAccessTokenAsync (
291+ Arg . Any < string > ( ) ,
292+ Arg . Any < IEnumerable < string > > ( ) ,
293+ Arg . Any < bool > ( ) ,
294+ Arg . Any < string ? > ( ) ,
295+ Arg . Any < CancellationToken > ( ) )
296+ . Returns ( "token-from-provider\r \n with-embedded-newlines\n " ) ;
297+
298+ var service = new GraphApiService ( logger , executor , handler , tokenProvider ) ;
299+
300+ // Queue a successful response
301+ using var queuedResponse = new HttpResponseMessage ( HttpStatusCode . OK )
302+ {
303+ Content = new StringContent ( "{\" value\" :[]}" )
304+ } ;
305+ handler . QueueResponse ( queuedResponse ) ;
306+
307+ // Act - Call with scopes to trigger token provider path
308+ var result = await service . GraphGetAsync ( "tenant-123" , "/v1.0/me" , default , new [ ] { "User.Read" } ) ;
309+
310+ // Assert
311+ capturedRequest . Should ( ) . NotBeNull ( "HTTP request should have been sent" ) ;
312+ capturedRequest ! . Headers . Authorization . Should ( ) . NotBeNull ( "Authorization header should be set" ) ;
313+
314+ var actualToken = capturedRequest . Headers . Authorization ! . Parameter ;
315+ actualToken . Should ( ) . NotBeNull ( ) ;
316+ actualToken . Should ( ) . NotContain ( "\r " , "Token should not contain carriage return characters" ) ;
317+ actualToken . Should ( ) . NotContain ( "\n " , "Token should not contain newline characters" ) ;
318+ }
319+
320+ [ Fact ]
321+ public async Task CheckServicePrincipalCreationPrivilegesAsync_SanitizesTokenWithNewlines ( )
322+ {
323+ // This test verifies that CheckServicePrincipalCreationPrivilegesAsync also
324+ // sanitizes tokens with newlines. This method has its own token handling code
325+ // separate from EnsureGraphHeadersAsync.
326+
327+ // Arrange
328+ HttpRequestMessage ? capturedRequest = null ;
329+ var handler = new CapturingHttpMessageHandler ( ( req ) => capturedRequest = req ) ;
330+ var logger = Substitute . For < ILogger < GraphApiService > > ( ) ;
331+ var executor = Substitute . For < CommandExecutor > ( Substitute . For < ILogger < CommandExecutor > > ( ) ) ;
332+
333+ // Mock az CLI to return a token WITH newline characters
334+ executor . ExecuteAsync ( Arg . Any < string > ( ) , Arg . Any < string > ( ) , Arg . Any < string ? > ( ) , Arg . Any < bool > ( ) , Arg . Any < bool > ( ) , Arg . Any < CancellationToken > ( ) )
335+ . Returns ( callInfo =>
336+ {
337+ var cmd = callInfo . ArgAt < string > ( 0 ) ;
338+ var args = callInfo . ArgAt < string > ( 1 ) ;
339+
340+ if ( cmd == "az" && args != null && args . StartsWith ( "account show" , StringComparison . OrdinalIgnoreCase ) )
341+ {
342+ return Task . FromResult ( new CommandResult
343+ {
344+ ExitCode = 0 ,
345+ StandardOutput = "{}" ,
346+ StandardError = string . Empty
347+ } ) ;
348+ }
349+
350+ if ( cmd == "az" && args != null && args . Contains ( "get-access-token" , StringComparison . OrdinalIgnoreCase ) )
351+ {
352+ // Return token WITH embedded newlines
353+ return Task . FromResult ( new CommandResult
354+ {
355+ ExitCode = 0 ,
356+ StandardOutput = "privileges-check-token\r \n \n " ,
357+ StandardError = string . Empty
358+ } ) ;
359+ }
360+
361+ return Task . FromResult ( new CommandResult { ExitCode = 0 , StandardOutput = string . Empty , StandardError = string . Empty } ) ;
362+ } ) ;
363+
364+ var service = new GraphApiService ( logger , executor , handler ) ;
365+
366+ // Queue a successful response for the directory roles query
367+ using var queuedResponse = new HttpResponseMessage ( HttpStatusCode . OK )
368+ {
369+ Content = new StringContent ( "{\" value\" :[{\" displayName\" :\" Application Administrator\" }]}" )
370+ } ;
371+ handler . QueueResponse ( queuedResponse ) ;
372+
373+ // Act - This should NOT throw FormatException
374+ var ( hasPrivileges , roles ) = await service . CheckServicePrincipalCreationPrivilegesAsync ( "tenant-123" ) ;
375+
376+ // Assert
377+ capturedRequest . Should ( ) . NotBeNull ( "HTTP request should have been sent" ) ;
378+ capturedRequest ! . Headers . Authorization . Should ( ) . NotBeNull ( "Authorization header should be set" ) ;
379+
380+ var actualToken = capturedRequest . Headers . Authorization ! . Parameter ;
381+ actualToken . Should ( ) . NotBeNull ( ) ;
382+ actualToken . Should ( ) . NotContain ( "\r " , "Token should not contain carriage return characters" ) ;
383+ actualToken . Should ( ) . NotContain ( "\n " , "Token should not contain newline characters" ) ;
384+ actualToken . Should ( ) . Be ( "privileges-check-token" , "Token should be sanitized to just the token value" ) ;
385+
386+ // Also verify the method returns correct results
387+ hasPrivileges . Should ( ) . BeTrue ( "User has Application Administrator role" ) ;
388+ roles . Should ( ) . Contain ( "Application Administrator" ) ;
389+ }
195390}
196391
197392// Simple test handler that returns queued responses sequentially
0 commit comments