Skip to content

Commit 34dfd04

Browse files
DevonEastbgavrilMS
authored andcommitted
Blazor: send a local returnUrl from BlazorAuthenticationChallengeHandler (fix #3895)
ChallengeUser passed NavigationManager.Uri - always absolute - as returnUrl, but the /login endpoint mapped by MapLoginAndLogout validates returnUrl with RedirectUriHelper.IsLocalUrl, which rejects absolute URLs and falls back to '/'. Every incremental-consent or Conditional Access round-trip therefore dropped the user on the app root instead of returning them to their page. Send new Uri(navigation.Uri).PathAndQuery instead: local, keeps the app path base, drops the fragment - the same coercion AccountController.Challenge applies to same-origin absolute URLs on the MVC path, applied here at the sending end so the endpoint's hardened local-only validation stays exactly as pinned by its regression tests. The coerced value is re-checked with IsLocalUrl (a path of '//host/x' yields a protocol-relative PathAndQuery) and falls back to '/', mirroring the MVC re-check. Tests: BlazorAuthenticationChallengeHandlerTests gains a concrete TestNavigationManager (Initialize + NavigateToCore override), three returnUrl-shape tests (local path+query preserved, path base preserved, protocol-relative path shape coerced to '/'), and the two HandleExceptionAsync tests previously skipped as unmockable now run for real.
1 parent 7de7b1d commit 34dfd04

2 files changed

Lines changed: 152 additions & 14 deletions

File tree

src/Microsoft.Identity.Web/Blazor/BlazorAuthenticationChallengeHandler.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,22 @@ public async Task<bool> HandleExceptionAsync(Exception exception)
7272
/// </summary>
7373
public void ChallengeUser(ClaimsPrincipal user, string[]? scopes = null, string? claims = null)
7474
{
75-
var currentUri = navigation.Uri;
75+
// NavigationManager.Uri is always absolute, but the /login endpoint mapped by
76+
// MapLoginAndLogout validates returnUrl with RedirectUriHelper.IsLocalUrl, which
77+
// rejects absolute URLs and falls back to "/" — so passing the absolute URI loses
78+
// the user's page after the consent round-trip. Send the app-local PathAndQuery
79+
// instead (preserves any path base, drops the fragment — matching the MVC
80+
// AccountController.Challenge coercion of same-origin absolute URLs).
81+
//
82+
// Defensive re-check: PathAndQuery can still begin with "//" or "/\" for a request
83+
// path like "//evil.example/x", which a downstream Location header would treat as
84+
// protocol-relative. Re-run IsLocalUrl on the coerced value and fall back to "/",
85+
// mirroring the endpoint's own validation.
86+
var returnUrl = new Uri(navigation.Uri).PathAndQuery;
87+
if (!RedirectUriHelper.IsLocalUrl(returnUrl))
88+
{
89+
returnUrl = "/";
90+
}
7691

7792
// Build scopes string (add OIDC scopes)
7893
var allScopes = (scopes ?? [])
@@ -87,7 +102,7 @@ public void ChallengeUser(ClaimsPrincipal user, string[]? scopes = null, string?
87102
var domainHint = Uri.EscapeDataString(GetDomainHint(user));
88103

89104
// Build the challenge URL
90-
var challengeUrl = $"/authentication/login?returnUrl={Uri.EscapeDataString(currentUri)}" +
105+
var challengeUrl = $"/authentication/login?returnUrl={Uri.EscapeDataString(returnUrl)}" +
91106
$"&scope={scopeString}" +
92107
$"&loginHint={loginHint}" +
93108
$"&domainHint={domainHint}";

tests/Microsoft.Identity.Web.Test/Blazor/BlazorAuthenticationChallengeHandlerTests.cs

Lines changed: 135 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)