Autentifikácia v .NET MAUI: OAuth2, JWT a bezpečné úložisko tokenov

Praktický sprievodca implementáciou OAuth2, OpenID Connect a JWT tokenov v .NET MAUI 10. SecureStorage, refresh tokeny, biometria a HTTP message handler v jednej kompletnej príručke.

OAuth2 JWT v .NET MAUI 10: Kompletný návod

Po tom, čo váš MAUI klient vie volať REST API, prichádza na rad otázka, ktorá sa jednoducho nedá obísť: ako overiť používateľa a chrániť volania na backend? Tento sprievodca je priamym pokračovaním série o sieťovej vrstve a zameriava sa na produkčne vyzretú autentifikáciu v .NET MAUI 10 — od OAuth2 a OpenID Connect, cez bezpečné úložisko tokenov, automatický refresh, až po biometrické odomykanie aplikácie.

Ukážeme si konkrétne knižnice (IdentityModel.OidcClient, Microsoft.Identity.Client, WebAuthenticator), reálny kód, ktorý vám prejde aj tým najprísnejším code review, a popri tom sa vyhneme typickým chybám — ako je ukladanie tokenov do Preferences, žiadne logout-cleanup, či úplne chýbajúce odhalenie token expirácie.

Prečo nestačí jednoduchý HTTP Basic Auth

V mobilnej aplikácii nemáte server-side session. Token, ktorý získate, žije priamo v zariadení používateľa — a to znamená štyri konkrétne riziká, ktoré OAuth2 a JWT riešia systémovo:

  • Krádež tokenu — ak token netečie cez HTTPS s certificate pinning a leží v plain texte, root-nutý alebo jailbreak-nutý prístroj ho jednoducho vie prečítať.
  • Žiadna granulárnosť — Basic Auth posiela meno a heslo pri každom requeste. JWT prenáša claims (role, scope, tenant) bez ďalšieho dotazu na server.
  • Rotácia hesla — ak používateľ zmení heslo, Basic Auth aplikácie musia zlyhať. Refresh token mechanizmus pri OAuth2 to rieši automaticky.
  • 3rd-party login — Google, Microsoft, Apple. Bez OAuth2 / OIDC ich neintegrujete bezpečne.

Úprimne, aj keby ste mali len klasický backend bez federácie, OAuth2 sa oplatí už len kvôli tomu refresh-flow.

Architektúra autentifikácie: čo musí MAUI klient zvládať

Skôr ako sa pustíme do kódu, ujasnime si zodpovednosti vrstiev. Nasledovný "diagram" je mentálny model, ktorý budeme implementovať:

  1. Auth provider — komunikuje s identity providerom (Azure AD B2C, Auth0, Keycloak, IdentityServer, Cognito). Spúšťa OAuth2 flow a vracia access a refresh token.
  2. Token store — bezpečne uloží tokeny cez SecureStorage (Keychain na iOS, Keystore na Androide) a chráni ich biometriou.
  3. HTTP message handler — pripája hlavičku Authorization: Bearer ... a pri 401 odpovedi automaticky obnoví token cez refresh token.
  4. Auth state service — vystavuje observable IsAuthenticated, CurrentUser a SignOut() pre ViewModelu (Shell navigácia, prepínanie AppShell vs. LoginShell).

Voľba OAuth2 flow: PKCE je v 2026 jediná správna odpoveď

Pre natívne mobilné aplikácie už nepoužívame Implicit ani holý Authorization Code flow bez PKCE. Štandard je Authorization Code Flow with PKCE (RFC 7636), ktorý chráni voči interception útokom na redirect URI. Prakticky to znamená:

  • Vygenerujeme code_verifier (kryptograficky náhodný reťazec).
  • Pošleme jeho SHA-256 hash ako code_challenge.
  • Pri výmene kódu za token musíme dokázať pôvod cez code_verifier.

Knižnice IdentityModel.OidcClient a Microsoft.Identity.Client (MSAL) PKCE riešia interne — nemusíte ho implementovať ručne, ale rozhodne by ste mali vedieť, že je zapnutý.

Krok 1: Inštalácia balíčkov a konfigurácia projektu

Predpokladáme čerstvý .NET MAUI 10 projekt. Do .csproj pridáme:

<ItemGroup>
  <PackageReference Include="IdentityModel.OidcClient" Version="6.0.0" />
  <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
  <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
</ItemGroup>

Registrácia redirect URI

Identity provider musí vedieť, kam má vrátiť výsledok. V .NET MAUI použijeme WebAuthenticator, ktorý očakáva URI v tvare myapp://callback.

Android — v Platforms/Android/AndroidManifest.xml:

<activity android:name="microsoft.maui.authentication.WebAuthenticatorCallbackActivity"
          android:exported="true">
  <intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="myapp" android:host="callback" />
  </intent-filter>
</activity>

iOS — v Platforms/iOS/Info.plist:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLName</key>
    <string>com.example.myapp</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>myapp</string>
    </array>
  </dict>
</array>

Maličkosť, ale stojí za zmienku: scheme nech je vždy malými písmenami. Android vie byť pri tomto občas prekvapivo prísny.

Krok 2: Bezpečné úložisko tokenov

Najčastejšia chyba v MAUI tutoriáloch (a verte mi, videl som ich dosť) je ukladanie tokenov do Preferences. Tie sú na Androide v plain text XML a ktorýkoľvek backup ich vyleje. Správna voľba je SecureStorage, ktorá pod kapotou používa Keychain (iOS) a EncryptedSharedPreferences/Keystore (Android).

public interface ITokenStore
{
    Task SaveAsync(AuthTokens tokens);
    Task<AuthTokens?> LoadAsync();
    Task ClearAsync();
}

public sealed record AuthTokens(
    string AccessToken,
    string RefreshToken,
    DateTimeOffset ExpiresAt,
    string IdToken);

public sealed class SecureTokenStore : ITokenStore
{
    private const string Key = "auth_tokens_v1";

    public async Task SaveAsync(AuthTokens tokens)
    {
        var json = JsonSerializer.Serialize(tokens);
        await SecureStorage.Default.SetAsync(Key, json);
    }

    public async Task<AuthTokens?> LoadAsync()
    {
        var json = await SecureStorage.Default.GetAsync(Key);
        return json is null ? null : JsonSerializer.Deserialize<AuthTokens>(json);
    }

    public Task ClearAsync()
    {
        SecureStorage.Default.Remove(Key);
        return Task.CompletedTask;
    }
}

Verzionovanie kľúča (auth_tokens_v1) je úmysel — keď v budúcnosti zmeníte štruktúru, jednoducho preskočíte na v2 a starí používatelia sa preautentifikujú namiesto crashu pri deserializácii. Ušetríte si tým vlnu Sentry chýb po updatete, ktoré inak prichádzajú dosť spoľahlivo.

Krok 3: OAuth2 flow s OidcClient

IdentityModel.OidcClient je knižnica od autorov IdentityServeru a podporuje akéhokoľvek OIDC-conformant providera. Konfigurácia:

public sealed class AuthService
{
    private readonly OidcClient _client;
    private readonly ITokenStore _store;
    private readonly IAuthState _state;

    public AuthService(ITokenStore store, IAuthState state)
    {
        _store = store;
        _state = state;

        _client = new OidcClient(new OidcClientOptions
        {
            Authority = "https://identity.example.com",
            ClientId = "maui-app",
            Scope = "openid profile offline_access api.read api.write",
            RedirectUri = "myapp://callback",
            Browser = new MauiAuthBrowser()
        });
    }

    public async Task<bool> LoginAsync()
    {
        var result = await _client.LoginAsync(new LoginRequest());
        if (result.IsError)
        {
            // Telemetria, nikdy nelogovať tokeny
            return false;
        }

        var tokens = new AuthTokens(
            result.AccessToken,
            result.RefreshToken,
            result.AccessTokenExpiration,
            result.IdentityToken);

        await _store.SaveAsync(tokens);
        _state.SetUser(result.User);
        return true;
    }

    public async Task LogoutAsync()
    {
        var tokens = await _store.LoadAsync();
        if (tokens is not null)
        {
            await _client.LogoutAsync(new LogoutRequest
            {
                IdTokenHint = tokens.IdToken
            });
        }
        await _store.ClearAsync();
        _state.Clear();
    }
}

MauiAuthBrowser: most medzi OidcClient a WebAuthenticator

OidcClient potrebuje implementáciu IBrowser, ktorá otvorí systémový prehliadač. V MAUI to spravíme cez WebAuthenticator:

public sealed class MauiAuthBrowser : IBrowser
{
    public async Task<BrowserResult> InvokeAsync(
        BrowserOptions options,
        CancellationToken cancellationToken = default)
    {
        try
        {
            var result = await WebAuthenticator.Default.AuthenticateAsync(
                new Uri(options.StartUrl),
                new Uri(options.EndUrl));

            return new BrowserResult
            {
                ResultType = BrowserResultType.Success,
                Response = result.Properties
                    .Aggregate(string.Empty,
                        (acc, kv) => acc + $"&{kv.Key}={kv.Value}")
            };
        }
        catch (TaskCanceledException)
        {
            return new BrowserResult { ResultType = BrowserResultType.UserCancel };
        }
    }
}

Krok 4: HTTP message handler s automatickým refresh

Najelegantnejšie riešenie je DelegatingHandler, ktorý prilepí token a pri 401 ho automaticky obnoví. ViewModelu sa o autentifikáciu nemusia starať vôbec — a presne tak to má byť.

public sealed class AuthMessageHandler : DelegatingHandler
{
    private readonly ITokenStore _store;
    private readonly OidcClient _oidc;
    private readonly SemaphoreSlim _refreshLock = new(1, 1);

    public AuthMessageHandler(ITokenStore store, OidcClient oidc)
    {
        _store = store;
        _oidc = oidc;
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        var tokens = await _store.LoadAsync();
        if (tokens is null)
        {
            return await base.SendAsync(request, ct);
        }

        // Proaktívne obnovenie 60s pred expiráciou
        if (tokens.ExpiresAt < DateTimeOffset.UtcNow.AddSeconds(60))
        {
            tokens = await RefreshAsync(tokens, ct) ?? tokens;
        }

        request.Headers.Authorization =
            new AuthenticationHeaderValue("Bearer", tokens.AccessToken);

        var response = await base.SendAsync(request, ct);

        if (response.StatusCode == HttpStatusCode.Unauthorized)
        {
            var newTokens = await RefreshAsync(tokens, ct);
            if (newTokens is null) return response;

            response.Dispose();
            request.Headers.Authorization =
                new AuthenticationHeaderValue("Bearer", newTokens.AccessToken);
            response = await base.SendAsync(request, ct);
        }

        return response;
    }

    private async Task<AuthTokens?> RefreshAsync(AuthTokens current, CancellationToken ct)
    {
        await _refreshLock.WaitAsync(ct);
        try
        {
            // Druhá kontrola — iné vlákno mohlo medzitým obnoviť
            var fresh = await _store.LoadAsync();
            if (fresh is not null && fresh.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(60))
            {
                return fresh;
            }

            var result = await _oidc.RefreshTokenAsync(current.RefreshToken, cancellationToken: ct);
            if (result.IsError) return null;

            var updated = new AuthTokens(
                result.AccessToken,
                result.RefreshToken ?? current.RefreshToken,
                result.AccessTokenExpiration,
                current.IdToken);

            await _store.SaveAsync(updated);
            return updated;
        }
        finally
        {
            _refreshLock.Release();
        }
    }
}

Pozor na race condition: ak naraz prebieha viacero requestov a všetky vrátia 401, bez semaforu by sa refresh token spotreboval naraz viackrát a backend by ho odmietol. Toto je presne ten typ chyby, ktorá sa prejaví len v produkcii pod záťažou — a potom strávite poldňa hľadaním dôvodu, prečo sa používatelia náhodne odhlasujú. SemaphoreSlim spolu s druhou kontrolou platnosti tokenu rieši aj tento prípad.

Krok 5: Registrácia v DI a prepojenie s HttpClient

V MauiProgram.cs:

builder.Services.AddSingleton<ITokenStore, SecureTokenStore>();
builder.Services.AddSingleton<IAuthState, AuthState>();
builder.Services.AddSingleton<AuthService>();
builder.Services.AddSingleton<OidcClient>(sp =>
{
    var browser = new MauiAuthBrowser();
    return new OidcClient(new OidcClientOptions
    {
        Authority = "https://identity.example.com",
        ClientId = "maui-app",
        Scope = "openid profile offline_access api.read",
        RedirectUri = "myapp://callback",
        Browser = browser
    });
});

builder.Services.AddTransient<AuthMessageHandler>();

builder.Services.AddHttpClient<IApiClient, ApiClient>(c =>
{
    c.BaseAddress = new Uri("https://api.example.com");
})
.AddHttpMessageHandler<AuthMessageHandler>();

Krok 6: Biometrická ochrana tokenu

Ak chcete aplikáciu odomykať odtlačkom prsta alebo Face ID, použite balík Plugin.Maui.Biometric. Tok je jednoduchý: pri studenom štarte aplikácia nenahrá tokeny dovtedy, kým biometria neprejde.

public async Task<bool> UnlockAsync()
{
    var auth = await Biometric.Default.AuthenticateAsync(
        new AuthenticationRequest
        {
            Title = "Odomknite aplikáciu",
            Reason = "Pre prístup k vašim dátam je potrebné overenie",
            CancelTitle = "Zrušiť",
            FallbackTitle = "Použiť PIN"
        });

    return auth.Status == BiometricResponseStatus.Success;
}

Ak biometria zlyhá viac ako N-krát alebo ju používateľ zruší, prepnite na klasické prihlásenie cez AuthService.LoginAsync(). Z UX pohľadu je dobrý nápad nedrieť používateľa po troch zlých pokusoch — radšej rovno do plnohodnotného loginu.

Krok 7: Reagovanie na auth stav v Shell navigácii

Aplikácia by mala mať dva korene: LoginShell a AppShell. AuthState je observable a hlavná stránka sa naň prihlasuje:

public partial class App : Application
{
    private readonly IAuthState _auth;

    public App(IAuthState auth)
    {
        InitializeComponent();
        _auth = auth;
        _auth.StateChanged += OnStateChanged;
    }

    protected override Window CreateWindow(IActivationState? state)
    {
        Page root = _auth.IsAuthenticated
            ? new AppShell()
            : new LoginShell();
        return new Window(root);
    }

    private void OnStateChanged(object? s, EventArgs e)
    {
        MainThread.BeginInvokeOnMainThread(() =>
        {
            if (Windows.FirstOrDefault() is { } w)
            {
                w.Page = _auth.IsAuthenticated ? new AppShell() : new LoginShell();
            }
        });
    }
}

Krok 8: Testovanie autentifikačnej vrstvy

Auth kód sa testuje ťažko, pokiaľ nie je správne abstrahovaný. Držte sa týchto pravidiel:

  • ITokenStore má test double — InMemoryTokenStore pre unit testy.
  • OidcClient obaľte do vlastného IIdentityProvider rozhrania, aby ste vedeli mockovať.
  • Pre AuthMessageHandler použite HttpMessageHandler stub a otestujte:
[Fact]
public async Task SendAsync_When401_RefreshesAndRetries()
{
    var store = new InMemoryTokenStore();
    await store.SaveAsync(new AuthTokens("expired", "refresh", DateTimeOffset.UtcNow.AddMinutes(-1), "id"));

    var idp = Substitute.For<IIdentityProvider>();
    idp.RefreshAsync("refresh", default).Returns(
        new AuthTokens("new", "refresh2", DateTimeOffset.UtcNow.AddHours(1), "id"));

    var inner = new SequenceHandler(
        HttpStatusCode.Unauthorized,
        HttpStatusCode.OK);

    var handler = new AuthMessageHandler(store, idp) { InnerHandler = inner };
    var client = new HttpClient(handler);

    var response = await client.GetAsync("https://api.example.com/me");

    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    Assert.Equal("Bearer new", inner.LastAuthHeader);
}

Bezpečnostný checklist pred releasom

  • Tokeny iba v SecureStorage, nikdy nie v Preferences alebo súbore.
  • Pri logout vymazať všetky tokeny + zavolať server-side end_session_endpoint.
  • Authorization Code Flow + PKCE; žiadny Implicit flow.
  • SemaphoreSlim okolo refresh logiky, aby sa refresh token nespotreboval viackrát naraz.
  • Nelogovať access ani refresh tokeny do telemetrie/Sentry. Vážne, ani v debug builde.
  • Aktivovať certificate pinning pre identity i resource server (cez HttpClientHandler.ServerCertificateCustomValidationCallback alebo System.Net.Http.SocketsHttpHandler).
  • Nastaviť android:allowBackup="false", aby sa SecureStorage nedostalo cez ADB backup.
  • iOS — SecureStorage nakonfigurovať na SecAccessible.AfterFirstUnlockThisDeviceOnly.

Časté chyby a ich riešenia

"WebAuthenticator vracia UserCancel pri Androide 14"

Pravdepodobne vám chýba android:exported="true" na callback aktivite, alebo intent-filter má nesprávny scheme/host. Skontrolujte logcat na riadky WebAuthenticatorCallbackActivity.

"Refresh token vracia invalid_grant"

Identity provider má refresh token rotáciu. Po každom refreshi vám vráti nový refresh token a starý zneplatní. Uistite sa, že ho v RefreshAsync ukladáte (kód vyššie to robí). Toto je mimochodom najčastejší dôvod tichých odhlásení v produkcii.

"Tokeny zmiznú po update aplikácie"

Na iOS to spravidla znamená zmenu Bundle Identifier alebo entitlements. Skontrolujte, že máte rovnaké keychain-access-groups v provisioning profile.

FAQ

Aký je rozdiel medzi access tokenom a refresh tokenom v .NET MAUI?

Access token (typicky JWT) je krátkodobý — zvyčajne 15 až 60 minút — a používa sa v hlavičke Authorization: Bearer. Refresh token žije dlho (dni až mesiace) a slúži iba na výmenu za nový access token cez identity provider. Refresh token nikdy neposielate na resource API.

Mám použiť MSAL alebo IdentityModel.OidcClient?

MSAL (Microsoft.Identity.Client) je optimálny, ak používate Azure AD, Entra ID alebo Azure AD B2C — má najlepšiu integráciu, podporuje Conditional Access a SSO cez Microsoft Authenticator. Pre ostatné identity providery (Auth0, Keycloak, IdentityServer, Okta) je vhodnejší IdentityModel.OidcClient, ktorý je provider-agnostický.

Ako bezpečne uložím tokeny v .NET MAUI?

Používajte SecureStorage.Default, ktorý pod kapotou volá Keychain (iOS) a Android Keystore. Nikdy nepoužívajte Preferences, FileSystem ani statické polia. Pre maximálnu bezpečnosť kombinujte SecureStorage s biometrickou validáciou pri studenom štarte aplikácie.

Funguje OAuth2 PKCE v .NET MAUI bez backendu?

Áno — to je presne dôvod, prečo PKCE existuje. Mobilná aplikácia je public client (nedokáže udržať tajomstvo), takže namiesto client_secret používa dvojicu code_verifier/code_challenge. Knižnice ako OidcClient a MSAL PKCE riešia automaticky.

Ako vyriešim odhlásenie naprieč zariadeniami?

Štandardný OAuth2 logout odhlási iba aktuálne zariadenie. Pre globálne odhlásenie potrebujete na backende invalidovať refresh tokeny daného používateľa (cez token_revocation endpoint) a pri 401 odpovedi z resource API klient zistí, že refresh tiež zlyhá, a prejde na login obrazovku.

Záver

Autentifikácia v .NET MAUI nie je raketová veda, ale má veľa drobných detailov, ktoré rozhodujú medzi bezpečnou aplikáciou a noticou v App Store. Ak ste prešli krokmi v tomto sprievodcovi, váš klient má všetko, čo potrebuje: bezpečné úložisko tokenov, automatický refresh, biometrické odomykanie, čistú reakciu na zmenu auth stavu a testovateľnú architektúru.

Ďalším logickým krokom v sérii je offline-first synchronizácia medzi SQLite a chráneným REST API — keď chceme, aby aplikácia fungovala aj v podzemnej dráhe a po obnove pripojenia bezpečne synchronizovala zmeny. Tomu sa budeme venovať v ďalšom diele.

O Autorovi Editorial Team

Our team of expert writers and editors.