Skip to content

.NET MAUI — iOS + Android (Duende.IdentityModel.OidcClient)

Use a public client with PKCE. Drive the flow with Duende.IdentityModel.OidcClient and a custom IBrowser backed by MAUI's WebAuthenticator. iOS and Android only — WebAuthenticator is not functional on Windows.

Pick your path: Follow the guide below, or jump to the AI prompt.

Follow the guide

A — Login vs enrol

  • Login: await client.LoginAsync() → standard /authorize.
  • Enrol: the same call with a LoginRequest whose front-channel parameters carry prompt=createrequest.FrontChannelExtraParameters.Add("prompt", "create")/authorize?prompt=create.

Choose one of:

  • Option 1 (recommended): two buttons — "Sign in" and "Create account" — the latter adding prompt=create.
  • Option 2: attempt login; if LoginResult.Error is access_denied and LoginResult.ErrorDescription is user_not_registered, retry with prompt=create.

B — Redirect callback setup

  • WebAuthenticator uses ASWebAuthenticationSession on iOS 12+ and Chrome Custom Tabs on Android. Do not use a WebView, BlazorWebView, or HybridWebView for the auth UI — Apple rejects apps that put auth in an embedded web view.
  • Use a custom URL scheme. EntryIdP does not host apple-app-site-association or assetlinks.json, so Universal Links / App Links are not available. The redirect URI (e.g. com.yourapp://callback) must exactly match the value registered for your client.
  • iOS: register the scheme in Platforms/iOS/Info.plist under CFBundleURLTypes (CFBundleURLName, CFBundleURLSchemes, CFBundleTypeRole = Editor). No AppDelegate.OpenUrl override is needed — MauiUIApplicationDelegate plus ASWebAuthenticationSession deliver the callback to the session.
  • Android: add a class under Platforms/Android deriving from Microsoft.Maui.Authentication.WebAuthenticatorCallbackActivity, annotated [Activity(NoHistory = true, LaunchMode = LaunchMode.SingleTop, Exported = true)] and [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable }, DataScheme = "com.yourapp")]. When targeting Android 11 (API 30) or higher, also add a <queries><intent><action android:name="android.support.customtabs.action.CustomTabsService" /></intent></queries> node to AndroidManifest.xml, or the custom tab will not resolve.
  • The IBrowser bridge needs one specific line. WebAuthenticator.AuthenticateAsync returns a WebAuthenticatorResult exposing a parsed Properties dictionary, never the raw callback URL — but OidcClient expects a URL in BrowserResult.Response. Rebuild one from the parsed values: new RequestUrl(options.EndUrl).Create(new Parameters(result.Properties)). Catch TaskCanceledException and return BrowserResultType.UserCancel.
Why the callback URL has to be rebuilt

BrowserOptions.EndUrl is the RedirectUri OidcClient was configured with, so rebuilding from it reproduces com.yourapp://callback?code=…&state=… exactly as OidcClient's parser expects. There is no raw-URI property on WebAuthenticatorResult to use instead — MAUI has already split the callback into key/value pairs by the time you get it.

Symbol names and the 6.0 package rename (Duende.IdentityModel.OidcClient 6.x–7.x)

The package was renamed at 6.0 — namespaces are Duende.IdentityModel.OidcClient, Duende.IdentityModel.OidcClient.Browser and Duende.IdentityModel.Client. The pre-6.0 IdentityModel.OidcClient package and its IdentityModel.* namespaces are a different library; do not mix them.

IBrowser has one member, Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default). BrowserResult has ResultType and Response; BrowserOptions.StartUrl / EndUrl are get-only. OidcClient generates state and PKCE itself and always sends code_challenge_method=S256 — there is no PKCE switch, and no nonce option, because OidcClient does not send nonce for the code flow.

C — Secure token storage

TokenWhere
Access tokenSecureStorage.Default (Microsoft.Maui.Storage) — Keychain on iOS, EncryptedSharedPreferences (Keystore) on Android.
Refresh tokenSame store, under its own key. Never Preferences, never a plain file.

Wrap every SecureStorage call in a try/catch. Refresh tokens rotate on every use — see Refresh tokens rotate.

Why the try/catch — two platform quirks that break SecureStorage

Android Auto Backup can restore ciphertext this device's key cannot decrypt, so a reinstalled or restored app can hit a decryption failure on a key that looks present. And on the iOS simulator you must enable the Keychain entitlement in Entitlements.plist or reads and writes fail outright.

ID token validation needs your attention on this platform. OidcClientOptions.Policy.RequireIdentityTokenSignature defaults to false, which makes OidcClient fall back to NoValidationIdentityTokenValidator — it base64-decodes the ID token payload and checks nothing: no signature, no iss, no aud, no exp. Setting the flag to true without also assigning OidcClientOptions.IdentityTokenValidator throws InvalidOperationException when you call LoginAsync, and there is no first-party validator package to install. Either treat /userinfo as your source of identity claims (LoadProfile is true by default, so OidcClient already calls it over TLS and cross-checks sub) and never authorise on ID-token claims, or supply your own IIdentityTokenValidator that verifies RS256 against options.ProviderInformation.KeySet plus iss/aud/exp and rejects alg=none.

Use an AI prompt

Add EntryIdP biometric OIDC login to this .NET MAUI app (iOS + Android) using
Duende.IdentityModel.OidcClient.

EntryIdP is an OpenID Connect provider. Users authenticate ONLY with a face liveness
check — no typed credentials, OTPs, or social logins. Do not build any sign-in form or
credential-entry UI.
Issuer: https://idp-test.entryidp.com (use the issuer from my client registration; read
from configuration — never hardcode).

Before writing code:
1. Read the .csproj and add the NuGet package Duende.IdentityModel.OidcClient (6.x or
   7.x) if missing. Do NOT add IdentityModel.OidcClient — that is the pre-6.0 package
   with IdentityModel.* namespaces and is a different, incompatible library.
2. Scope the work to the net*-ios and net*-android target frameworks only. MAUI's
   WebAuthenticator does not work on Windows — do not wire it up for Windows, Mac
   Catalyst, Tizen, or Blazor Hybrid.
3. Open Platforms/iOS/Info.plist and Platforms/Android/AndroidManifest.xml, and check
   whether a WebAuthenticatorCallbackActivity subclass already exists under
   Platforms/Android.

Implementation:
- Authorization Code + PKCE only. OidcClient always sends code_challenge_method=S256;
  there is no PKCE option and no way to disable it. Never implicit flow, never
  response_type=token, never grant_type=client_credentials.
- PUBLIC client: no client_secret anywhere. Leave OidcClientOptions.ClientSecret unset.
- Configure OidcClientOptions with these members only: Authority (the issuer), ClientId,
  Scope ("openid profile offline_access"), RedirectUri, PostLogoutRedirectUri (required
  if you call LogoutAsync — the logout EndUrl is derived from it), and Browser. Discovery
  is automatic from Authority + /.well-known/openid-configuration: do not set
  ProviderInformation and do not hardcode endpoints.
- Register the OidcClient as a singleton in MauiProgram.CreateMauiApp and inject it.
- Implement IBrowser over WebAuthenticator using these exact symbols (verified against
  Duende.IdentityModel.OidcClient 6.x-7.x — do NOT invent variants):
    * using Duende.IdentityModel.Client;
      using Duende.IdentityModel.OidcClient.Browser;
    * Task<BrowserResult> InvokeAsync(BrowserOptions options,
          CancellationToken cancellationToken = default)
    * Launch with: await WebAuthenticator.Default.AuthenticateAsync(
          new Uri(options.StartUrl), new Uri(options.EndUrl))
    * WebAuthenticatorResult exposes a PARSED Properties dictionary, NOT the raw callback
      URL, and OidcClient needs a URL or query string in BrowserResult.Response. Rebuild
      one:
          var url = new RequestUrl(options.EndUrl)
              .Create(new Parameters(result.Properties));
          return new BrowserResult
              { Response = url, ResultType = BrowserResultType.Success };
      There is NO raw-Uri property on WebAuthenticatorResult — do not look for one.
    * On cancel MAUI throws TaskCanceledException: catch it and return
      new BrowserResult { ResultType = BrowserResultType.UserCancel };
    * BrowserOptions.StartUrl and BrowserOptions.EndUrl are get-only. EndUrl is the
      RedirectUri OidcClient was configured with — use it, don't hardcode the callback.
- Android callback: add a class under Platforms/Android deriving from
  Microsoft.Maui.Authentication.WebAuthenticatorCallbackActivity with
      [Activity(NoHistory = true, LaunchMode = LaunchMode.SingleTop, Exported = true)]
      [IntentFilter(new[] { Intent.ActionView },
                    Categories = new[] { Intent.CategoryDefault,
                                         Intent.CategoryBrowsable },
                    DataScheme = "<your-scheme>")]
  When targeting Android 11 (API 30) or higher, also add to AndroidManifest.xml:
      <queries><intent><action
        android:name="android.support.customtabs.action.CustomTabsService" />
      </intent></queries>
- iOS callback: register the scheme in Platforms/iOS/Info.plist under CFBundleURLTypes
  (CFBundleURLName, CFBundleURLSchemes, CFBundleTypeRole=Editor). Do NOT add an
  AppDelegate OpenUrl override — MauiUIApplicationDelegate and
  ASWebAuthenticationSession already deliver the callback.
- Redirect URI is a CUSTOM URL SCHEME (e.g. com.yourapp://callback) and must exactly
  match the registered value. EntryIdP does NOT support Universal Links / App Links — do
  not configure apple-app-site-association or assetlinks.json.
- Do NOT use a WebView, BlazorWebView, or HybridWebView for authentication.
- Store tokens with SecureStorage.Default (Microsoft.Maui.Storage) — Keychain on iOS,
  EncryptedSharedPreferences on Android. Never Preferences, never a plain file. Wrap
  every call in try/catch and RemoveAll() on failure.
- ID token: Policy.RequireIdentityTokenSignature defaults to false, which makes
  OidcClient use NoValidationIdentityTokenValidator — it base64-decodes the payload and
  validates NOTHING (no signature, iss, aud, or exp). Do not ship that silently. Either
  (a) treat /userinfo as the source of identity claims (LoadProfile is true by default,
  so OidcClient already calls it over TLS and cross-checks sub) and never authorise on
  ID-token claims, or (b) set Policy.RequireIdentityTokenSignature = true AND assign
  OidcClientOptions.IdentityTokenValidator to your own IIdentityTokenValidator that
  verifies RS256 against options.ProviderInformation.KeySet plus iss/aud/exp and rejects
  alg=none. Setting the flag true WITHOUT a validator throws InvalidOperationException at
  login, and there is NO Duende.IdentityModel.OidcClient.IdentityTokenValidator package —
  do not try to install one.
- There is no nonce here: OidcClient does not send nonce for the code flow and exposes no
  Nonce option. Do not invent one. state and PKCE are generated and checked by OidcClient.

Login vs enrol (EntryIdP-specific):
- "Sign in" → await client.LoginAsync() (login of an existing face).
- "Create account" → first-time face enrolment:
      var request = new LoginRequest();
      request.FrontChannelExtraParameters.Add("prompt", "create");
      var result = await client.LoginAsync(request);
  FrontChannelExtraParameters is a Duende.IdentityModel.Client.Parameters and is already
  initialised — do not replace it with a Dictionary.
- If LoginResult.Error == "access_denied" and
  LoginResult.ErrorDescription == "user_not_registered", send the user into the
  prompt=create flow.

Refresh handling: call await client.RefreshTokenAsync(storedRefreshToken), which returns a
RefreshTokenResult (AccessToken, RefreshToken, IdentityToken, AccessTokenExpiration).
ALWAYS overwrite the stored refresh token with result.RefreshToken (EntryIdP rotates
refresh tokens and burns the whole family on replay). On IsError with invalid_grant, clear
SecureStorage and start a fresh LoginAsync.

Guardrails:
- No sign-in form, OTP, or social-login UI.
- No client_secret; no client_credentials grant; no implicit flow.
- No WebView / BlazorWebView for authentication; no Universal Links / App Links.
- Custom URL scheme redirect only; endpoints from discovery, not hardcoded.
- iOS and Android targets only — do not wire WebAuthenticator on Windows.
- Do not use the pre-6.0 IdentityModel.OidcClient package or IdentityModel.* namespaces.

Done? Run through the pre-launch checklist before you ship, and see Refresh tokens rotate if you requested offline_access.

EntryIdP — Synapser