Skip to content

Fix #3895: BlazorAuthenticationChallengeHandler sends a local returnUrl instead of the absolute NavigationManager.Uri - #3995

Open
Devon (DevonEast) wants to merge 1 commit into
AzureAD:masterfrom
DevonEast:fix/blazor-challenge-local-returnurl
Open

Fix #3895: BlazorAuthenticationChallengeHandler sends a local returnUrl instead of the absolute NavigationManager.Uri#3995
Devon (DevonEast) wants to merge 1 commit into
AzureAD:masterfrom
DevonEast:fix/blazor-challenge-local-returnurl

Conversation

@DevonEast

Copy link
Copy Markdown

Fixes #3895.

Problem

BlazorAuthenticationChallengeHandler.ChallengeUser passes NavigationManager.Uri — always an absolute URL — as the returnUrl query parameter of the /authentication/login endpoint mapped by MapLoginAndLogout(). That endpoint validates returnUrl with RedirectUriHelper.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 GetAuthProperties is a deliberate hardening: its regression tests pin that absolute URLs must be coerced to / (the earlier behaviour of coercing absolutes via PathAndQuery at 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.Challenge accepts same-origin absolute URLs by coercing them to PathAndQuery (with an IsLocalUrl re-check on the coerced value), explicitly because the consent handler passes NavigationManager.Uri. This PR gives the Blazor Minimal-API path the equivalent treatment — at the sending end, so the endpoint's validation stays strict.

Change

ChallengeUser now sends new Uri(navigation.Uri).PathAndQuery:

  • Preserves the app path base (https://host/app/page?x=1/app/page?x=1) — which is why it uses PathAndQuery rather than NavigationManager.ToBaseRelativePath.
  • Drops the fragment, matching the MVC coercion's documented behaviour.
  • Defensive re-check: PathAndQuery of a same-origin URL can still begin with // or /\ (e.g. a request path of //evil.example/x), which a downstream Location header would treat as protocol-relative. The coerced value is re-validated with RedirectUriHelper.IsLocalUrl and falls back to / — the same re-check AccountController.Challenge performs.

No public API change; no change to LoginLogoutEndpointRouteBuilderExtensions.

Tests

BlazorAuthenticationChallengeHandlerTests gains a concrete TestNavigationManager (a subclass calling Initialize() and overriding NavigateToCore — the pattern the ASP.NET Core repo uses), which makes NavigationManager.Uri/NavigateTo unit-testable. With it:

  • New: ChallengeUser_SendsLocalReturnUrl_PreservingPathAndQuery — returnUrl is the escaped local /admin/reports?tab=2, forceLoad: true.
  • New: ChallengeUser_LocalReturnUrl_PreservesPathBase — an app under /app/ keeps its base.
  • New: ChallengeUser_ProtocolRelativePathShape_CoercedToRoot — a //evil.example/x path shape coerces to /.
  • Un-skipped: both HandleExceptionAsync_Detects… tests previously skipped as "NavigationManager cannot be mocked" now run, asserting navigation occurred with forceLoad and the requested scope present.

The existing GetAuthProperties_CoercesNonLocalReturnUrls theory 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:

  • Before: returnUrl=https%3A%2F%2Fapp…%2Fadmin%2Freports%3Ftab%3D2IsLocalUrl false → RedirectUri = "/" → user lands on the app root after consent.
  • After: returnUrl=%2Fadmin%2Freports%3Ftab%3D2IsLocalUrl true → user returns to /admin/reports?tab=2.

@DevonEast
Devon (DevonEast) requested a review from a team as a code owner August 5, 2026 14:45
@DevonEast

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

…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.
@bgavrilMS
Bogdan Gavril (bgavrilMS) force-pushed the fix/blazor-challenge-local-returnurl branch from d26db0c to b16f6f0 Compare August 18, 2026 12:52
@bgavrilMS
Bogdan Gavril (bgavrilMS) enabled auto-merge (squash) August 18, 2026 12:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Blazor MapLoginAndLogout: incremental-consent returnUrl (absolute NavigationManager.Uri) coerced to '/' — user loses their page

3 participants