Skip to content

[.NET 10] Add .NET MAUI Blazor Hybrid sample using Entra External ID#649

Open
mattleibow wants to merge 12 commits intodotnet:mainfrom
mattleibow:dev/maui-blazor-web-entra-10
Open

[.NET 10] Add .NET MAUI Blazor Hybrid sample using Entra External ID#649
mattleibow wants to merge 12 commits intodotnet:mainfrom
mattleibow:dev/maui-blazor-web-entra-10

Conversation

@mattleibow
Copy link
Member

@mattleibow mattleibow commented Mar 16, 2026

New sample: 10.0/MauiBlazorWebEntra

Demonstrates Microsoft Entra External ID (CIAM) authentication for a .NET 10 MAUI Blazor Hybrid app with a shared Blazor Web companion.

Architecture

  • Dual auth on web: OIDC+Cookie for browsers, JWT Bearer for MAUI API calls
  • MSAL on native: platform-specific interactive sign-in with silent token refresh
  • Shared UI: Razor class library with Home, Counter, Weather, and Account pages

Platform support

Platform Auth method Status
Web (Blazor Server) OIDC via Microsoft.Identity.Web
iOS MSAL + ASWebAuthenticationSession
Android MSAL + Chrome Custom Tabs
Windows MSAL + embedded WebView2
Mac Catalyst MSAL + custom ASWebAuthenticationSession WebUI ✅ (workaround for MSAL #3527)

Key features

  • Setup-Azure.ps1 — Guided 5-step interactive script to create CIAM tenant, register apps, configure user flows
  • Teardown-Azure.ps1 — Clean removal of app registrations and user flows
  • CIAM sign-up via prompt=create deep-link (screen_hint=signup is not supported by CIAM)
  • Account page showing user profile claims
  • Chrome Custom Tabs on Android (requires <queries> manifest entry for API 30+)
  • Mac Catalyst auth via custom ICustomWebUi using ASWebAuthenticationSession since MSAL has no native Mac Catalyst support

Setup

Run Setup-Azure.ps1 for guided configuration, or see README for manual steps.


Known Issues and Platform Workarounds

Windows: Token Cache Persistence

Problem: On Windows, MSAL.NET uses an in-memory token cache by default (InMemoryPartitionedUserTokenCacheAccessor). When the app closes, the cache is lost — GetAccountsAsync() returns empty on next launch, forcing the user to sign in interactively every time.

This is not an issue on iOS or Android because MSAL overrides the base class with platform-native persistent storage:

Windows has no such override — it falls through to the base InMemoryPartitionedUserTokenCacheAccessor.

Solution: We use MSAL's SetBeforeAccessAsync / SetAfterAccessAsync cache serialization callbacks to persist the serialized token cache (via SerializeMsalV3 / DeserializeMsalV3) to .NET MAUI's SecureStorage. On Windows, SecureStorage encrypts data using DataProtectionProvider("LOCAL=user"), scoped to the current Windows user. Packaged (MSIX) apps store values in ApplicationData.Current.LocalSettings; unpackaged (WindowsPackageType=None) apps persist to a securestorage.dat JSON file in AppData.

The implementation is in MsalServiceExtensions.EnableSecureStorageTokenCachePersistence() and is wired up via #if WINDOWS || MACCATALYST.

Windows: Embedded WebView2 via Microsoft.Identity.Client.Desktop.WinUI3

WAM (Web Account Manager) broker does not support Entra External ID (CIAM) tenants — it only works with workforce Entra ID. When WAM encounters a CIAM authority, it falls back to the system browser, which is a poor UX (opens external browser, requires http://localhost loopback listener).

Instead, we use WithWindowsDesktopFeatures(BrokerOptions) from the Microsoft.Identity.Client.Desktop.WinUI3 package. This registers WAM as the primary auth mechanism and an embedded WebView2 browser as fallback. For CIAM tenants, WAM fails gracefully and the embedded WebView2 shows the login page inside the app window — no external browser needed.

The WebView2 intercepts navigation to the redirect URI (http://localhost) in-process via the WebView2_NavigationStarting event handler. No HTTP listener is opened and no network traffic occurs.

Key requirement: WithParentActivityOrWindow() must receive a Microsoft.UI.Xaml.Window object (not an IntPtr handle) for the embedded WebView2 to work — passing a window handle causes MsalException: Invalid owner window type.

Windows: Redirect URI http://localhost

WithDefaultRedirectUri() on MAUI resolves to https://login.microsoftonline.com/common/oauth2/nativeclient (not http://localhost as it does on plain .NET Core). This legacy nativeclient URI does not work with CIAM tenants. We explicitly set WithRedirectUri("http://localhost") which works for both the embedded WebView2 flow (intercepted in-process) and the system browser fallback (loopback port matching per RFC 8252 §7.3).

The Setup-Azure.ps1 script registers both msal{ClientId}://auth (iOS/Android/Mac) and http://localhost (Windows) as redirect URIs on the app registration.

Mac Catalyst: MSAL does not ship a maccatalyst TFM (#3527)

Problem: MSAL.NET (as of v4.x) does not include a net*-maccatalyst target framework. When running on Mac Catalyst, NuGet resolves to the generic net8.0 assembly instead of a platform-specific one. This causes two issues:

  1. System browser auth throws PlatformNotSupportedException — MSAL's AcquireTokenInteractive calls OpenBrowserAsync which is not implemented in the generic assembly. The call chain goes through PlatformProxy.CreateWebUiFactory()InteractiveRequest → throws because no system browser integration exists for the generic .NET target.

  2. Token cache is not persisted — The generic assembly uses InMemoryPartitionedUserTokenCacheAccessor (same as Windows). Unlike iOS where MSAL's net*-ios assembly overrides this with iOSTokenCacheAccessor (backed by SecKeyChain), the generic assembly has no Keychain integration. Tokens are lost on every app restart.

Solution — Authentication: A custom ICustomWebUi implementation uses Apple's ASWebAuthenticationSession to present the Entra login page. This is the same API that MSAL uses internally on iOS via SystemWebUI — we just call it directly since MSAL's generic assembly doesn't know about it. The implementation:

  • Creates an ASWebAuthenticationSession with the authorization URI and redirect scheme
  • Provides an ASWebAuthenticationPresentationContextProvider to anchor the session to the app window
  • Returns the callback URI to MSAL for token exchange
  • Is wired up via a WithMacCatalystWebView() extension method on AcquireTokenInteractiveParameterBuilder

Solution — Token Persistence: The same SecureStorage-based cache serialization used for Windows is also enabled for Mac Catalyst via #if WINDOWS || MACCATALYST. On macOS, MAUI's SecureStorage is backed by the macOS Keychain, providing encrypted per-app token storage that survives app restarts.

Both workarounds reference MSAL issue #3527 and can be removed once MSAL ships a native net*-maccatalyst TFM.

Android: Chrome Custom Tabs Package Visibility

On Android API 30+, apps must declare which other apps they query. Without the <queries> entry for android.support.customtabs.action.CustomTabsService in AndroidManifest.xml, MSAL cannot discover Chrome Custom Tabs and falls back to opening a full separate browser window — a degraded UX.

CIAM: Sign-up Deep Link

Entra External ID (CIAM) does not support screen_hint=signup (it is silently ignored). Use prompt=create instead to deep-link to the sign-up page.

@mattleibow mattleibow force-pushed the dev/maui-blazor-web-entra-10 branch 2 times, most recently from 800764c to 597b596 Compare March 16, 2026 21:47
@guardrex
Copy link
Contributor

Let me know when you're ready. We'll ping Stephen, but we won't wait more than a day or two. I'd like to get this out there for the community as soon as we can.

@mattleibow mattleibow marked this pull request as draft March 17, 2026 14:06
@mattleibow
Copy link
Member Author

Moving to draft until I get clarity around mac.

mattleibow and others added 11 commits March 24, 2026 16:54
New .NET MAUI Blazor Hybrid + ASP.NET Core Web App sample that replaces
ASP.NET Core Identity with Microsoft Entra External ID (CIAM) for
authentication.

Web server:
- Dual auth via BearerOrCookie policy scheme: OIDC + Cookie for browser
  users, JWT Bearer for MAUI API calls
- Uses Microsoft.Identity.Web instead of EF Core/Identity
- No local user database — Entra manages all accounts
- Login/logout/weather API endpoints

MAUI app:
- MSAL.NET (Microsoft.Identity.Client) for native authentication
- Interactive sign-in via system browser, silent token refresh
- Android: MsalActivity for MSAL redirect URI callback
- iOS: CFBundleURLTypes for MSAL redirect URI scheme

Infrastructure:
- Setup-Azure.ps1: Interactive PowerShell script that creates Entra app
  registrations, exposes API scope, generates client secret, and patches
  all config files with real values
- Teardown-Azure.ps1: Cleanup script to remove app registrations
- README.md with architecture overview and quick start guide

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… setup improvements

- Add Account.razor shared page with two-column claims display
- Add /authentication/register endpoint with prompt=create for CIAM sign-up
- Add Register and Account nav links (Web + MAUI)
- iOS: Add MSAL callback in AppDelegate.cs, keychain security group
- Android: Add OnActivityResult callback, Chrome Custom Tabs <queries> manifest
- Mac Catalyst: Custom ICustomWebUi using ASWebAuthenticationSession (MSAL has no native Mac Catalyst support - issue #3527)
- Upgrade Microsoft.Identity.Client 4.70.0 → 4.83.1
- Rewrite Setup-Azure.ps1 as guided 5-step interactive walkthrough
- Update Teardown-Azure.ps1 to match new setup flow
- Update README quick start section

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add missing Platforms/iOS/Entitlements.plist with keychain-access-groups
- Use $(AppIdentifierPrefix)$(CFBundleIdentifier) instead of hardcoded adalcache group
- Update WithIosKeychainSecurityGroup to match bundle identifier
- Align MacCatalyst entitlements to same pattern

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Convert all .cs files to file-scoped namespaces (C# 10)
- Use primary constructors for MsalAuthenticationStateProvider and WeatherService (C# 12)
- Replace collection initializers with collection expressions (C# 12)
- Remove custom MacCatalystWebUi (ASWebAuthenticationSession workaround)
- Mac Catalyst now uses WithSystemWebViewOptions like iOS
- Remove network.server entitlement (no longer needed without loopback)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
On first call to GetAuthenticationStateAsync, attempt a silent token
acquisition using MSAL's cached credentials. This restores the user's
authenticated session automatically when the app restarts, without
requiring an interactive sign-in.

Also converts MsalConfig.cs to file-scoped namespace.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Step 3: Check if app registrations exist by display name before
  creating. Reuse existing apps and preserve client secrets when
  appsettings.json already has a real value. Only generate a new
  secret when the config file still has a placeholder.
- Step 4: Check if signup_signin user flow exists before creating.
  If it exists, verify linked apps and add any missing ones.

This allows the script to be safely re-run without duplicating
resources or invalidating secrets on other machines.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace credential reset with Graph API addPassword so new secrets
never invalidate existing ones on other machines. When app already
exists, interactively ask whether to keep the current secret, paste
one from another machine, or generate a new one.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Update PSScriptRoot paths to resolve parent directory and update
README with new script locations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add Microsoft.Identity.Client.Desktop.WinUI3 package for embedded WebView2
  auth on Windows (WAM broker doesn't support CIAM tenants)
- Add Microsoft.Identity.Client.Broker package (Windows-only)
- Update MauiVersion to 10.0.50 to fix HybridWebView.js build issue
- Configure WithWindowsDesktopFeatures for embedded WebView2 + WAM broker
- Set http://localhost redirect URI for Windows (CIAM requires explicit URI,
  WithDefaultRedirectUri resolves to nativeclient on MAUI which doesn't work)
- Pass WinUI3 Window object (not IntPtr handle) to WithParentActivityOrWindow
  for embedded WebView2 compatibility
- Move MsalConfig.cs to project root
- Add platform-specific redirect URI via #if WINDOWS in MsalConfig
- Register http://localhost in Setup-Azure.ps1 alongside msal{ClientId}://auth
- Add Windows token cache persistence via SecureStorage (DPAPI-backed) since
  MSAL only persists automatically on iOS (Keychain) and Android (SharedPrefs)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Extract MSAL client setup into MsalServiceExtensions.AddMsalClient()
  extension on IServiceCollection, cleaning up MauiProgram.cs
- Extract platform-specific interactive auth config into
  WithPlatformOptions() extension on AcquireTokenInteractiveParameterBuilder,
  removing #if directives from MsalAuthenticationStateProvider
- Extract Windows token cache persistence into private
  EnableSecureStorageTokenCachePersistence() method using MAUI SecureStorage
  (DPAPI-backed); iOS/Android persist natively via Keychain/SharedPreferences
- Add null checks for platform window/activity in WithPlatformOptions()

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Align iOS keychain-access-groups entitlement with Mac Catalyst to use
com.microsoft.adalcache (MSAL's default shared keychain group). Add
WithIosKeychainSecurityGroup to the MSAL builder for iOS and Mac
Catalyst so tokens persist correctly across app restarts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mattleibow

This comment was marked as outdated.

MSAL.NET doesn't ship a maccatalyst TFM (issue #3527), so the generic
.NET assembly is used at runtime. This causes two problems:

1. AcquireTokenInteractive throws PlatformNotSupportedException because
   the generic assembly has no system browser integration.
2. The token cache is in-memory only — tokens are lost on app restart.

Auth fix: Add MacCatalystWebUi implementing ICustomWebUi, which drives
ASWebAuthenticationSession directly. Wired up via a WithMacCatalystWebView()
extension method on AcquireTokenInteractiveParameterBuilder.

Persistence fix: Enable SecureStorage-based token cache serialization for
Mac Catalyst (same mechanism already used on Windows).

Both workarounds reference MSAL issue #3527 and can be removed once MSAL
ships native Mac Catalyst support.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mattleibow mattleibow force-pushed the dev/maui-blazor-web-entra-10 branch from 597b596 to 555df7b Compare March 24, 2026 17:09
@mattleibow mattleibow changed the title Add MauiBlazorWebEntra sample — Entra External ID (CIAM) for .NET 10 MAUI + Blazor Web [.NET 10] Add .NET MAUI Blazor Hybrid sample using Entra External ID Mar 24, 2026
@mattleibow mattleibow marked this pull request as ready for review March 24, 2026 17:32
@mattleibow
Copy link
Member Author

@guardrex I think this is what we are going to have to do. I am still not sure what the best solution for Mac Catalyst is, but this sample has just 1 file of things to make it work. It is documented as a workaround for now, and hopefully Entra can support Mac Catalyst a bit better as well as maybe make Windows smoother.

But besides a few Entra extensions, the sample and the auth seems to work correctly.

@guardrex
Copy link
Contributor

Thanks @mattleibow! 👍 Writing a companion article for the doc set would be our normal approach to raising visibility for the sample. If so, the article would be worked per a new issue on the main doc set repo. I can set up the issue and write the article draft. Then, you can review and provide feedback and additional content from there. Sound good?

@halter73 ... Do you want to review this sample?

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.

2 participants