@@ -17,6 +17,29 @@ namespace Microsoft.Identity.Web.Test.Blazor
1717{
1818 public class BlazorAuthenticationChallengeHandlerTests
1919 {
20+ /// <summary>
21+ /// Concrete <see cref="NavigationManager"/> for unit tests. <c>Uri</c> is driven by
22+ /// <see cref="NavigationManager.Initialize(string, string)"/> and navigation is captured
23+ /// by overriding <see cref="NavigationManager.NavigateToCore(string, NavigationOptions)"/> —
24+ /// the same pattern the ASP.NET Core repo uses to test NavigationManager consumers.
25+ /// </summary>
26+ private sealed class TestNavigationManager : NavigationManager
27+ {
28+ public string ? LastNavigatedTo { get ; private set ; }
29+ public bool LastForceLoad { get ; private set ; }
30+
31+ public TestNavigationManager ( string baseUri , string uri )
32+ {
33+ Initialize ( baseUri , uri ) ;
34+ }
35+
36+ protected override void NavigateToCore ( string uri , NavigationOptions options )
37+ {
38+ LastNavigatedTo = uri ;
39+ LastForceLoad = options . ForceLoad ;
40+ }
41+ }
42+
2043 private readonly NavigationManager _mockNavigationManager ;
2144 private readonly AuthenticationStateProvider _mockAuthStateProvider ;
2245 private readonly IConfiguration _configuration ;
@@ -106,7 +129,7 @@ public async Task IsAuthenticatedAsync_ReturnsFalseForUnauthenticatedUser()
106129 Assert . False ( isAuthenticated ) ;
107130 }
108131
109- [ Fact ( Skip = "NavigationManager.Uri and NavigationManager.NavigateTo cannot be mocked. Integration tests verify this behavior." ) ]
132+ [ Fact ]
110133 public async Task HandleExceptionAsync_DetectsMicrosoftIdentityWebChallengeUserException ( )
111134 {
112135 // Arrange
@@ -119,23 +142,31 @@ public async Task HandleExceptionAsync_DetectsMicrosoftIdentityWebChallengeUserE
119142 var authState = new AuthenticationState ( user ) ;
120143 _mockAuthStateProvider . GetAuthenticationStateAsync ( ) . Returns ( authState ) ;
121144
145+ var navigation = new TestNavigationManager (
146+ "https://app.contoso.com/" ,
147+ "https://app.contoso.com/weather?day=2" ) ;
148+
122149 var handler = new BlazorAuthenticationChallengeHandler (
123- _mockNavigationManager ,
150+ navigation ,
124151 _mockAuthStateProvider ,
125152 _configuration ) ;
126153
127154 var scopes = new [ ] { "user.read" } ;
128155 var msalException = new MsalUiRequiredException ( "error_code" , "error_message" ) ;
129156 var challengeException = new MicrosoftIdentityWebChallengeUserException ( msalException , scopes ) ;
130157
131- // Act & Assert
132- // Note: Since NavigationManager.NavigateTo is not virtual, actual navigation behavior
133- // is tested in integration tests. Here we verify exception detection logic.
158+ // Act
134159 var handled = await handler . HandleExceptionAsync ( challengeException ) ;
160+
161+ // Assert
135162 Assert . True ( handled ) ;
163+ Assert . NotNull ( navigation . LastNavigatedTo ) ;
164+ Assert . True ( navigation . LastForceLoad ) ;
165+ Assert . Contains ( "scope=" , navigation . LastNavigatedTo , StringComparison . Ordinal ) ;
166+ Assert . Contains ( Uri . EscapeDataString ( "user.read" ) , navigation . LastNavigatedTo , StringComparison . Ordinal ) ;
136167 }
137168
138- [ Fact ( Skip = "NavigationManager.Uri and NavigationManager.NavigateTo cannot be mocked. Integration tests verify this behavior." ) ]
169+ [ Fact ]
139170 public async Task HandleExceptionAsync_DetectsMicrosoftIdentityWebChallengeUserExceptionAsInnerException ( )
140171 {
141172 // Arrange
@@ -148,8 +179,12 @@ public async Task HandleExceptionAsync_DetectsMicrosoftIdentityWebChallengeUserE
148179 var authState = new AuthenticationState ( user ) ;
149180 _mockAuthStateProvider . GetAuthenticationStateAsync ( ) . Returns ( authState ) ;
150181
182+ var navigation = new TestNavigationManager (
183+ "https://app.contoso.com/" ,
184+ "https://app.contoso.com/weather?day=2" ) ;
185+
151186 var handler = new BlazorAuthenticationChallengeHandler (
152- _mockNavigationManager ,
187+ navigation ,
153188 _mockAuthStateProvider ,
154189 _configuration ) ;
155190
@@ -158,9 +193,96 @@ public async Task HandleExceptionAsync_DetectsMicrosoftIdentityWebChallengeUserE
158193 var challengeException = new MicrosoftIdentityWebChallengeUserException ( msalException , scopes ) ;
159194 var outerException = new InvalidOperationException ( "Outer exception" , challengeException ) ;
160195
161- // Act & Assert
196+ // Act
162197 var handled = await handler . HandleExceptionAsync ( outerException ) ;
198+
199+ // Assert
163200 Assert . True ( handled ) ;
201+ Assert . NotNull ( navigation . LastNavigatedTo ) ;
202+ }
203+
204+ // -----------------------------------------------------------------------------
205+ // returnUrl shape (issue #3895): the /login endpoint mapped by MapLoginAndLogout
206+ // validates returnUrl with RedirectUriHelper.IsLocalUrl, which rejects absolute
207+ // URLs and falls back to "/". ChallengeUser must therefore send the app-local
208+ // PathAndQuery of the current page — not NavigationManager.Uri verbatim — or the
209+ // user loses their page after the consent round-trip.
210+ // -----------------------------------------------------------------------------
211+
212+ [ Fact ]
213+ public void ChallengeUser_SendsLocalReturnUrl_PreservingPathAndQuery ( )
214+ {
215+ // Arrange
216+ var navigation = new TestNavigationManager (
217+ "https://app.contoso.com/" ,
218+ "https://app.contoso.com/admin/reports?tab=2" ) ;
219+
220+ var handler = new BlazorAuthenticationChallengeHandler (
221+ navigation ,
222+ _mockAuthStateProvider ,
223+ _configuration ) ;
224+
225+ // Act
226+ handler . ChallengeUser ( new ClaimsPrincipal ( new CaseSensitiveClaimsIdentity ( ) ) , new [ ] { "user.read" } ) ;
227+
228+ // Assert
229+ Assert . NotNull ( navigation . LastNavigatedTo ) ;
230+ Assert . StartsWith (
231+ $ "/authentication/login?returnUrl={ Uri . EscapeDataString ( "/admin/reports?tab=2" ) } ",
232+ navigation . LastNavigatedTo ,
233+ StringComparison . Ordinal ) ;
234+ Assert . True ( navigation . LastForceLoad ) ;
235+ }
236+
237+ [ Fact ]
238+ public void ChallengeUser_LocalReturnUrl_PreservesPathBase ( )
239+ {
240+ // Arrange — app hosted under a path base ("/app"). PathAndQuery keeps it;
241+ // NavigationManager.ToBaseRelativePath would lose it.
242+ var navigation = new TestNavigationManager (
243+ "https://host.contoso.com/app/" ,
244+ "https://host.contoso.com/app/page?x=1" ) ;
245+
246+ var handler = new BlazorAuthenticationChallengeHandler (
247+ navigation ,
248+ _mockAuthStateProvider ,
249+ _configuration ) ;
250+
251+ // Act
252+ handler . ChallengeUser ( new ClaimsPrincipal ( new CaseSensitiveClaimsIdentity ( ) ) ) ;
253+
254+ // Assert
255+ Assert . NotNull ( navigation . LastNavigatedTo ) ;
256+ Assert . StartsWith (
257+ $ "/authentication/login?returnUrl={ Uri . EscapeDataString ( "/app/page?x=1" ) } ",
258+ navigation . LastNavigatedTo ,
259+ StringComparison . Ordinal ) ;
260+ }
261+
262+ [ Fact ]
263+ public void ChallengeUser_ProtocolRelativePathShape_CoercedToRoot ( )
264+ {
265+ // Arrange — PathAndQuery of "https://host//evil.example/x" is "//evil.example/x":
266+ // a protocol-relative shape that a downstream Location header would follow off-origin.
267+ // The handler must re-check IsLocalUrl on the coerced value and fall back to "/".
268+ var navigation = new TestNavigationManager (
269+ "https://host.contoso.com/" ,
270+ "https://host.contoso.com//evil.example/x" ) ;
271+
272+ var handler = new BlazorAuthenticationChallengeHandler (
273+ navigation ,
274+ _mockAuthStateProvider ,
275+ _configuration ) ;
276+
277+ // Act
278+ handler . ChallengeUser ( new ClaimsPrincipal ( new CaseSensitiveClaimsIdentity ( ) ) ) ;
279+
280+ // Assert
281+ Assert . NotNull ( navigation . LastNavigatedTo ) ;
282+ Assert . StartsWith (
283+ $ "/authentication/login?returnUrl={ Uri . EscapeDataString ( "/" ) } ",
284+ navigation . LastNavigatedTo ,
285+ StringComparison . Ordinal ) ;
164286 }
165287
166288 [ Fact ]
@@ -185,9 +307,10 @@ public async Task HandleExceptionAsync_ReturnsFalseForNonChallengeException()
185307 Assert . False ( handled ) ;
186308 }
187309
188- // Note: Additional tests for ChallengeUser, GetLoginHint, and GetDomainHint
189- // behavior are covered in integration tests since NavigationManager.NavigateTo()
190- // and NavigationManager.Uri are not virtual and cannot be mocked.
191- // These tests validate URL construction and parameter passing through real Blazor components.
310+ // Note: NavigationManager.Uri and NavigateTo ARE unit-testable via a concrete
311+ // subclass that calls Initialize() and overrides NavigateToCore (see
312+ // TestNavigationManager above) — the same pattern the ASP.NET Core repo uses.
313+ // End-to-end URL construction through real Blazor components remains covered
314+ // by integration tests.
192315 }
193316}
0 commit comments