Fix #3895: BlazorAuthenticationChallengeHandler sends a local returnUrl instead of the absolute NavigationManager.Uri - #3995
Open
Devon (DevonEast) wants to merge 1 commit into
Conversation
Author
|
@microsoft-github-policy-service agree |
Bogdan Gavril (bgavrilMS)
approved these changes
Aug 7, 2026
Ignacio Inglese (iNinja)
approved these changes
Aug 7, 2026
…ler (fix AzureAD#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.
Bogdan Gavril (bgavrilMS)
force-pushed
the
fix/blazor-challenge-local-returnurl
branch
from
August 18, 2026 12:52
d26db0c to
b16f6f0
Compare
Bogdan Gavril (bgavrilMS)
enabled auto-merge (squash)
August 18, 2026 12:53
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3895.
Problem
BlazorAuthenticationChallengeHandler.ChallengeUserpassesNavigationManager.Uri— always an absolute URL — as thereturnUrlquery parameter of the/authentication/loginendpoint mapped byMapLoginAndLogout(). That endpoint validatesreturnUrlwithRedirectUriHelper.IsLocalUrl, which (correctly) rejects absolute URLs and falls back to/. Net effect: every incremental-consent or Conditional Access round-trip in Blazor Server drops the user on the app root instead of returning them to the page they were on.The two halves of the flow ship in the same assembly and are documented as a pair, but they disagree about the shape of
returnUrl.Why fix the sender, not the validator
The strict local-only validation in
GetAuthPropertiesis a deliberate hardening: its regression tests pin that absolute URLs must be coerced to/(the earlier behaviour of coercing absolutes viaPathAndQueryat the endpoint was removed as part of the open-redirect hardening). Relaxing the validator would reopen that surface. The sender, by contrast, can trivially produce the local form — it is the current page's URI.This also mirrors what the MVC path already does:
AccountController.Challengeaccepts same-origin absolute URLs by coercing them toPathAndQuery(with anIsLocalUrlre-check on the coerced value), explicitly because the consent handler passesNavigationManager.Uri. This PR gives the Blazor Minimal-API path the equivalent treatment — at the sending end, so the endpoint's validation stays strict.Change
ChallengeUsernow sendsnew Uri(navigation.Uri).PathAndQuery:https://host/app/page?x=1→/app/page?x=1) — which is why it usesPathAndQueryrather thanNavigationManager.ToBaseRelativePath.PathAndQueryof a same-origin URL can still begin with//or/\(e.g. a request path of//evil.example/x), which a downstreamLocationheader would treat as protocol-relative. The coerced value is re-validated withRedirectUriHelper.IsLocalUrland falls back to/— the same re-checkAccountController.Challengeperforms.No public API change; no change to
LoginLogoutEndpointRouteBuilderExtensions.Tests
BlazorAuthenticationChallengeHandlerTestsgains a concreteTestNavigationManager(a subclass callingInitialize()and overridingNavigateToCore— the pattern the ASP.NET Core repo uses), which makesNavigationManager.Uri/NavigateTounit-testable. With it:ChallengeUser_SendsLocalReturnUrl_PreservingPathAndQuery— returnUrl is the escaped local/admin/reports?tab=2,forceLoad: true.ChallengeUser_LocalReturnUrl_PreservesPathBase— an app under/app/keeps its base.ChallengeUser_ProtocolRelativePathShape_CoercedToRoot— a//evil.example/xpath shape coerces to/.HandleExceptionAsync_Detects…tests previously skipped as "NavigationManager cannot be mocked" now run, asserting navigation occurred withforceLoadand the requested scope present.The existing
GetAuthProperties_CoercesNonLocalReturnUrlstheory is untouched and still pins the endpoint's strict validation — the flow now composes: handler sends local, endpoint accepts local. All 37 tests in the Blazor test namespace pass on net10.0.Repro (before) / behaviour (after)
Blazor Server app without
Microsoft.Identity.Web.UI,MapLoginAndLogout()mapped, incremental consent triggered from/admin/reports?tab=2:returnUrl=https%3A%2F%2Fapp…%2Fadmin%2Freports%3Ftab%3D2→IsLocalUrlfalse →RedirectUri = "/"→ user lands on the app root after consent.returnUrl=%2Fadmin%2Freports%3Ftab%3D2→IsLocalUrltrue → user returns to/admin/reports?tab=2.