All skills
davidortinau avatar

/maui-authentication

@0f66779

Add authentication to .NET MAUI apps. Covers WebAuthenticator for generic OAuth 2.0 / social login, and MSAL.NET for Microsoft Entra ID (Azure AD) with broker support (Microsoft Authenticator), token caching, Conditional Access, platform-specific setup (Android, iOS, Windows), DelegatingHandler for bearer token API calls, and Blazor Hybrid integration. USE FOR: "add authentication", "OAuth login", "MSAL.NET", "social login", "WebAuthenticator", "Entra ID MAUI", "Azure AD login", "Google login MAUI", "Apple login MAUI", "token caching", "bearer token", "sign in". DO NOT USE FOR: secure local storage of tokens (use maui-secure-storage), Aspire-specific auth setup (use maui-aspire), or server-side Entra ID provisioning (use entra-id-aspire-provisioning).

Use this Skill: https://skilld.dev/gh/davidortinau/maui-skills/maui-authentication

This session only. Nothing lands on disk.

referencesauthentication-api.md

≈4.3k tokens on demand. Your agent reads this file only when SKILL.md points to it.

.NET MAUI Authentication — API Reference

WebAuthenticator Core API

var result = await WebAuthenticator.Default.AuthenticateAsync(
    new WebAuthenticatorOptions
    {
        Url = new Uri("https://your-server.com/auth/login"),
        CallbackUrl = new Uri("myapp://callback"),
        PrefersEphemeralWebBrowserSession = true
    });

string accessToken = result.AccessToken;
string refreshToken = result.Properties["refresh_token"];
  • Url — the authorization endpoint (your server or identity provider).
  • CallbackUrl — the URI scheme your app is registered to handle.
  • PrefersEphemeralWebBrowserSession — when true (iOS 13+), uses a private browser session that does not share cookies or data with Safari.

WebAuthenticator Platform Setup

Android

1. Callback Activity
using Android.App;
using Android.Content.PM;

namespace MyApp.Platforms.Android;

[Activity(NoHistory = true, LaunchMode = LaunchMode.SingleTop, Exported = true)]
[IntentFilter(
    new[] { Android.Content.Intent.ActionView },
    Categories = new[] { Android.Content.Intent.CategoryDefault, Android.Content.Intent.CategoryBrowsable },
    DataScheme = "myapp",
    DataHost = "callback")]
public class WebAuthenticationCallbackActivity : Microsoft.Maui.Authentication.WebAuthenticatorCallbackActivity
{
}
2. Package Visibility (Android 11+)
<manifest>
  <queries>
    <intent>
      <action android:name="android.support.customtabs.action.CustomTabsService" />
    </intent>
  </queries>
</manifest>

iOS / Mac Catalyst

Register the callback URI scheme in Info.plist:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLName</key>
    <string>myapp</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>myapp</string>
    </array>
  </dict>
</array>

No additional code is needed — MAUI handles the callback automatically on Apple platforms.

Windows

Register the protocol in Package.appxmanifest:

<Extensions>
  <uap:Extension Category="windows.protocol">
    <uap:Protocol Name="myapp">
      <uap:DisplayName>My App Auth</uap:DisplayName>
    </uap:Protocol>
  </uap:Extension>
</Extensions>

Apple Sign In

var result = await AppleSignInAuthenticator.Default.AuthenticateAsync(
    new AppleSignInAuthenticator.Options
    {
        IncludeFullNameScope = true,
        IncludeEmailScope = true
    });

string idToken = result.IdToken;
string name = result.Properties["name"];

Token Persistence with SecureStorage

// Save
await SecureStorage.Default.SetAsync("access_token", accessToken);
await SecureStorage.Default.SetAsync("refresh_token", refreshToken);

// Retrieve
string token = await SecureStorage.Default.GetAsync("access_token");

// Clear on logout
SecureStorage.Default.RemoveAll();

DI-Friendly WebAuth Service

public interface IAuthService
{
    Task<AuthResult> LoginAsync(CancellationToken ct = default);
    Task LogoutAsync();
    Task<string?> GetAccessTokenAsync();
}

public record AuthResult(bool Success, string? ErrorMessage = null);

public class WebAuthService : IAuthService
{
    private const string AuthUrl = "https://your-server.com/auth/login";
    private const string CallbackUrl = "myapp://callback";

    public async Task<AuthResult> LoginAsync(CancellationToken ct = default)
    {
        try
        {
            var result = await WebAuthenticator.Default.AuthenticateAsync(
                new WebAuthenticatorOptions
                {
                    Url = new Uri(AuthUrl),
                    CallbackUrl = new Uri(CallbackUrl),
                    PrefersEphemeralWebBrowserSession = true
                });

            await SecureStorage.Default.SetAsync("access_token", result.AccessToken);
            return new AuthResult(true);
        }
        catch (TaskCanceledException)
        {
            return new AuthResult(false, "Login cancelled.");
        }
    }

    public Task LogoutAsync()
    {
        SecureStorage.Default.RemoveAll();
        return Task.CompletedTask;
    }

    public Task<string?> GetAccessTokenAsync()
        => SecureStorage.Default.GetAsync("access_token");
}

Register in MauiProgram.cs:

builder.Services.AddSingleton<IAuthService, WebAuthService>();

MSAL.NET — Entra ID App Registration

Manual registration

  1. Go to Microsoft Entra admin center → App registrations → New registration
  2. Name: your app name
  3. Supported account types: choose your scenario (single tenant, multi-tenant, personal accounts)
  4. Do NOT set a redirect URI yet — add platform-specific URIs after:
    • Add a platform → Mobile and desktop applications
    • Android: msal{ClientId}://auth
    • iOS: msauth.{BundleId}://auth
    • Windows/macOS: http://localhost
  5. Note the Application (client) ID and Directory (tenant) ID
  6. Under API permissions, add User.Read (Microsoft Graph) for basic profile access
  7. If calling your own API: register the API app separately, expose a scope (e.g., access_as_user), then add that scope as a permission to the client app

Install the Entra provisioning skill (automated alternative)

mkdir -p .github/skills && cd .github/skills
curl -LO https://aka.ms/msidweb/aspire/entra-id-provisioning-skill

Then ask your AI assistant: "Provision Entra ID app registrations for my MAUI app"

Source: https://github.com/AzureAD/microsoft-identity-web/tree/master/.github/skills

MSAL.NET Configuration

{
  "AzureAd": {
    "Authority": "https://login.microsoftonline.com/{TenantId}",
    "TenantId": "<your-tenant-id>",
    "ClientId": "<your-client-id>",
    "Scopes": "User.Read"
  }
}

Or use a config class for mobile:

public static class AuthConfig
{
    public const string TenantId = "<your-tenant-id>";
    public const string ClientId = "<your-client-id>";
    public const string Authority = $"https://login.microsoftonline.com/{TenantId}";
    public static readonly string[] Scopes = ["User.Read"];

    public const string AndroidRedirectUri = $"msal{ClientId}://auth";
    public const string IosRedirectUri = $"msauth.com.companyname.myapp://auth";
}

MSAL Auth Service

public interface IAuthService
{
    Task<AuthenticationResult?> SignInAsync(CancellationToken ct = default);
    Task<AuthenticationResult?> AcquireTokenSilentAsync(CancellationToken ct = default);
    Task SignOutAsync();
    Task<string?> GetAccessTokenAsync(string[] scopes, CancellationToken ct = default);
    bool IsSignedIn { get; }
}
using Microsoft.Identity.Client;

public class MsalAuthService : IAuthService
{
    private readonly IPublicClientApplication _pca;
    private readonly string[] _defaultScopes;

    public bool IsSignedIn => _cachedAccount != null;
    private IAccount? _cachedAccount;

    public MsalAuthService()
    {
        _defaultScopes = AuthConfig.Scopes;

        var builder = PublicClientApplicationBuilder
            .Create(AuthConfig.ClientId)
            .WithAuthority(AuthConfig.Authority)
            .WithIosKeychainSecurityGroup("com.microsoft.adalcache");

#if ANDROID
        builder = builder.WithRedirectUri(AuthConfig.AndroidRedirectUri)
                         .WithParentActivityOrWindow(() => Platform.CurrentActivity);
#elif IOS || MACCATALYST
        builder = builder.WithRedirectUri(AuthConfig.IosRedirectUri);
#else
        builder = builder.WithRedirectUri("http://localhost");
#endif

#if ANDROID || IOS
        builder = builder.WithBroker();
#endif

        _pca = builder.Build();
    }

    public async Task<AuthenticationResult?> SignInAsync(CancellationToken ct = default)
    {
        var result = await AcquireTokenSilentAsync(ct);
        if (result != null) return result;

        try
        {
            result = await _pca.AcquireTokenInteractive(_defaultScopes)
                .WithLoginHint(_cachedAccount?.Username)
#if ANDROID
                .WithParentActivityOrWindow(Platform.CurrentActivity)
#endif
                .ExecuteAsync(ct);

            _cachedAccount = result.Account;
            return result;
        }
        catch (MsalClientException ex) when (ex.ErrorCode == "authentication_canceled")
        {
            return null;
        }
    }

    public async Task<AuthenticationResult?> AcquireTokenSilentAsync(CancellationToken ct = default)
    {
        try
        {
            var accounts = await _pca.GetAccountsAsync();
            _cachedAccount = accounts.FirstOrDefault();

            if (_cachedAccount == null) return null;

            var result = await _pca.AcquireTokenSilent(_defaultScopes, _cachedAccount)
                .ExecuteAsync(ct);

            _cachedAccount = result.Account;
            return result;
        }
        catch (MsalUiRequiredException)
        {
            return null;
        }
    }

    public async Task<string?> GetAccessTokenAsync(string[] scopes, CancellationToken ct = default)
    {
        var accounts = await _pca.GetAccountsAsync();
        var account = accounts.FirstOrDefault();
        if (account == null) return null;

        try
        {
            var result = await _pca.AcquireTokenSilent(scopes, account)
                .ExecuteAsync(ct);
            return result.AccessToken;
        }
        catch (MsalUiRequiredException)
        {
            var result = await _pca.AcquireTokenInteractive(scopes)
#if ANDROID
                .WithParentActivityOrWindow(Platform.CurrentActivity)
#endif
                .ExecuteAsync(ct);
            _cachedAccount = result.Account;
            return result.AccessToken;
        }
    }

    public async Task SignOutAsync()
    {
        var accounts = await _pca.GetAccountsAsync();
        foreach (var account in accounts)
        {
            await _pca.RemoveAsync(account);
        }
        _cachedAccount = null;
    }
}

Register in MauiProgram.cs:

builder.Services.AddSingleton<IAuthService, MsalAuthService>();

MSAL Platform Setup

Android

AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
  <application android:allowBackup="true" />
  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
  <uses-permission android:name="android.permission.INTERNET" />
  <queries>
    <package android:name="com.azure.authenticator" />
    <package android:name="com.microsoft.windowsintune.companyportal" />
    <intent>
      <action android:name="android.intent.action.VIEW" />
      <category android:name="android.intent.category.BROWSABLE" />
      <data android:scheme="https" />
    </intent>
    <intent>
      <action android:name="android.support.customtabs.action.CustomTabsService" />
    </intent>
  </queries>
</manifest>
MainActivity.cs
using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.OS;
using Android.Runtime;
using Microsoft.Identity.Client;

namespace MyApp;

[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true,
    ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation |
    ConfigChanges.UiMode | ConfigChanges.ScreenLayout |
    ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
    protected override void OnActivityResult(int requestCode,
        [GeneratedEnum] Result resultCode, Intent? data)
    {
        base.OnActivityResult(requestCode, resultCode, data);
        AuthenticationContinuationHelper
            .SetAuthenticationContinuationEventArgs(requestCode, resultCode, data);
    }
}

iOS / Mac Catalyst

Info.plist
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLName</key>
    <string>com.companyname.myapp</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>msauth.com.companyname.myapp</string>
    </array>
  </dict>
</array>
Entitlements.plist
<key>keychain-access-groups</key>
<array>
  <string>$(AppIdentifierPrefix)com.microsoft.adalcache</string>
</array>
AppDelegate.cs
using Foundation;
using Microsoft.Identity.Client;
using UIKit;

namespace MyApp;

[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
    protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();

    public override bool OpenUrl(UIApplication app, NSUrl url,
        NSDictionary options)
    {
        AuthenticationContinuationHelper.SetAuthenticationContinuationEventArgs(url);
        return base.OpenUrl(app, url, options);
    }
}

Windows

No special platform setup. MSAL uses http://localhost redirect by default. For broker (WAM) support on Windows, add:

#if WINDOWS
using Microsoft.Identity.Client.Desktop;

builder = builder.WithBroker(new BrokerOptions(BrokerOptions.OperatingSystems.Windows));
#endif

Bearer Token DelegatingHandler

public class AuthTokenHandler : DelegatingHandler
{
    private readonly IAuthService _authService;
    private readonly string[] _scopes;

    public AuthTokenHandler(IAuthService authService, string[] scopes)
    {
        _authService = authService;
        _scopes = scopes;
        InnerHandler = new HttpClientHandler();
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        var token = await _authService.GetAccessTokenAsync(_scopes, ct);
        if (token != null)
        {
            request.Headers.Authorization =
                new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
        }
        return await base.SendAsync(request, ct);
    }
}

Register in MauiProgram.cs:

builder.Services.AddTransient(sp =>
    new AuthTokenHandler(
        sp.GetRequiredService<IAuthService>(),
        new[] { "api://<your-api-client-id>/access_as_user" }));

builder.Services.AddHttpClient<IMyApiClient, MyApiClient>(client =>
{
    client.BaseAddress = new Uri("https://your-api.azurewebsites.net/");
})
.AddHttpMessageHandler<AuthTokenHandler>();

Login UI Examples

XAML + ViewModel

<Button Text="{Binding LoginButtonText}"
        Command="{Binding LoginCommand}" />
public partial class AuthViewModel : ObservableObject
{
    private readonly IAuthService _auth;

    [ObservableProperty] string loginButtonText = "Sign In";
    [ObservableProperty] string? userName;

    public AuthViewModel(IAuthService auth) => _auth = auth;

    [RelayCommand]
    async Task Login()
    {
        if (_auth.IsSignedIn)
        {
            await _auth.SignOutAsync();
            UserName = null;
            LoginButtonText = "Sign In";
        }
        else
        {
            var result = await _auth.SignInAsync();
            if (result != null)
            {
                UserName = result.Account.Username;
                LoginButtonText = "Sign Out";
            }
        }
    }
}

Blazor Hybrid

@inject IAuthService Auth

<AuthorizeView>
    <Authorized>
        <span>Hello, @context.User.Identity?.Name</span>
        <button @onclick="SignOut">Sign Out</button>
    </Authorized>
    <NotAuthorized>
        <button @onclick="SignIn">Sign In</button>
    </NotAuthorized>
</AuthorizeView>

@code {
    async Task SignIn() => await Auth.SignInAsync();
    async Task SignOut() => await Auth.SignOutAsync();
}

MsalAuthenticationStateProvider (for Blazor Hybrid)

public class MsalAuthenticationStateProvider : AuthenticationStateProvider
{
    private readonly IAuthService _auth;

    public MsalAuthenticationStateProvider(IAuthService auth) => _auth = auth;

    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        var result = await _auth.AcquireTokenSilentAsync();
        if (result == null)
            return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));

        var identity = new ClaimsIdentity(result.ClaimsPrincipal.Claims, "msal");
        return new AuthenticationState(new ClaimsPrincipal(identity));
    }

    public void NotifyAuthStateChanged() =>
        NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}

Register:

builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<AuthenticationStateProvider, MsalAuthenticationStateProvider>();

Entra ID + Aspire Backend

If your MAUI app calls a .NET Aspire-hosted backend API, the API-side JWT Bearer protection is handled by the Entra team's existing skills.

Install the Entra authentication skill (for the API/backend)

mkdir -p .github/skills && cd .github/skills
curl -LO https://aka.ms/msidweb/aspire/entra-id-code-skill

Then ask: "Add Entra ID authentication to my Aspire app"

Source: https://github.com/AzureAD/microsoft-identity-web/tree/master/.github/skills

Source: SKILL.md on GitHub

1 alert15d4 checks · Risk SAFE
  • Gen Agent Trust Hub15d

    The skill is a comprehensive documentation resource for .NET MAUI authentication workflows. It includes standard implementation patterns for MSAL.NET and WebAuthenticator. The skill suggests downloading setup scripts from a trusted service provider and provides natural language templates that influence subsequent agent actions.

  • Socket15d

    No alerts

  • Snyk15d

    Risk: LOW · No issues

  • Runlayer7mo

    1/1 file flagged

Signed by skilld at 0f66779. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Steadyupdated 6 months ago

README badge

README badge for davidortinau/maui-skills/maui-authentication