Appearance
.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
LoginRequestwhose front-channel parameters carryprompt=create—request.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.Errorisaccess_deniedandLoginResult.ErrorDescriptionisuser_not_registered, retry withprompt=create.
B — Redirect callback setup
WebAuthenticatorusesASWebAuthenticationSessionon iOS 12+ and Chrome Custom Tabs on Android. Do not use aWebView,BlazorWebView, orHybridWebViewfor 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-associationorassetlinks.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.plistunderCFBundleURLTypes(CFBundleURLName,CFBundleURLSchemes,CFBundleTypeRole=Editor). NoAppDelegate.OpenUrloverride is needed —MauiUIApplicationDelegateplusASWebAuthenticationSessiondeliver the callback to the session. - Android: add a class under
Platforms/Androidderiving fromMicrosoft.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 toAndroidManifest.xml, or the custom tab will not resolve. - The
IBrowserbridge needs one specific line.WebAuthenticator.AuthenticateAsyncreturns aWebAuthenticatorResultexposing a parsedPropertiesdictionary, never the raw callback URL — but OidcClient expects a URL inBrowserResult.Response. Rebuild one from the parsed values:new RequestUrl(options.EndUrl).Create(new Parameters(result.Properties)). CatchTaskCanceledExceptionand returnBrowserResultType.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
| Token | Where |
|---|---|
| Access token | SecureStorage.Default (Microsoft.Maui.Storage) — Keychain on iOS, EncryptedSharedPreferences (Keystore) on Android. |
| Refresh token | Same 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.