Table of Contents

Desktop and mobile clients

A desktop app (WPF, Windows Forms, console) or a .NET MAUI app signs its user in with OpenID Connect in a browser, keeps the tokens, and sends the access token to the APIs it calls. Arc4u.OAuth2.Client.Authentication does this with Duende.IdentityModel.OidcClient: it registers the OidcClient, a token provider that signs in, refreshes and caches the tokens, and an HttpClient handler.

MSAL status

Arc4u 9 does not ship an MSAL (Microsoft Authentication Library) package: the project that wrapped MSAL (package ID Arc4u.OAuth2.Msal) is removed. The 9.0 roadmap (#129) drops MSAL support and adds IdentityModel for WPF and MAUI apps instead: that is Arc4u.OAuth2.Client.Authentication. See MSAL status in the migration guide and the package support page.

BlazorMsalTokenProvider in Arc4u.OAuth2.Blazor is not affected: it relies on the Microsoft WebAssembly authentication, not on this package (see Blazor).

Install

dotnet add package Arc4u.OAuth2.Client.Authentication --prerelease

The package references Arc4u.OAuth2 and Duende.IdentityModel.OidcClient. You also need an IBrowser implementation that shows the sign-in page: Duende provides samples for desktop apps (a system browser with a loopback redirect), and MAUI has WebAuthenticator.

Configuration

The configuration overload of AddOidcClientAuthentication reads the Authentication section (pass authenticationSectionName to use another one) and registers the settings named OidcClient (pass settingsKey to change it). The client ID and scopes are read from the section whose full path is in the OidcClientIdSettingsSectionPath key of that section, by default Authentication:OidcClient.Settings: this path does not follow authenticationSectionName.

{
  "Authentication": {
    "DefaultAuthority": {
      "Url": "https://login.microsoftonline.com/<tenant-id>/v2.0"
    },
    "OidcClient.Settings": {
      "ClientId": "<client-id>",
      "Scopes": [ "openid", "profile", "offline_access", "api://inventory/access" ]
    },
    "CallbackPath": "http://127.0.0.1:7890/",
    "PostLogoutRedirectUri": "http://127.0.0.1:7890/"
  },
  "Application.Configuration": {
    "ApplicationName": "Inventory.Desktop",
    "Environment": {
      "Name": "Development",
      "LoggingName": "Inventory.Desktop",
      "TimeZone": "UTC"
    }
  }
}
Key Type Default Description
Authentication:DefaultAuthority:Url Uri none (required) The authority. The metadata is read from <Url>/.well-known/openid-configuration, or from MetaDataAddress.
Authentication:OidcClient.Settings:ClientId string none (required) Client ID of the app (a public client: no secret).
Authentication:OidcClient.Settings:Scopes string[] none (required) Scopes requested. Add offline_access to get a refresh token.
Authentication:OidcClient.Settings:ProviderId string OidcClientIdentityModel Key of the token provider.
Authentication:CallbackPath string / Redirect URI registered for the app.
Authentication:PostLogoutRedirectUri string / Redirect URI after sign-out.
Authentication:LoadProfile bool false Loads the claims of the user info endpoint.
Authentication:ForceRefreshTimeoutTimeSpan TimeSpan 00:05:00 The access token is renewed with the refresh token once it expires in less than this value.
Authentication:RefreshTokenLifetime TimeSpan 90.00:00:00 Assumed lifetime of a refresh token. Older than that, the user logs in again. Set it to the lifetime configured in the identity provider.
Authentication:ClockSkew TimeSpan 00:05:00 Tolerated clock difference when the tokens are validated.
Authentication:LoginTimeout TimeSpan 00:05:00 Longest time an interactive login (or the logout of a sign-out) can take in the browser. The token calls wait for the login in progress; a call that waits longer fails, and a login or logout that does not complete in time is cancelled.
Authentication:SecureCacheTimeout TimeSpan 00:00:30 Longest time a read, write or removal of the tokens in the secure cache can take during a sign-out or a token write.

Application.Configuration is read by AddApplicationConfig in the code below (see Configuration). HTTPS is required for the metadata, except when the authority is localhost. The Authentication section also takes the claims settings of the server authentication (ClaimsIdentifier, DomainsMapping, ClaimsMiddleWare:ClaimsFiller); see Server authentication.

The extra parameters of Authentication:OidcClient.Settings:AuthorizationEndpoint are added to the authorize request (in the browser), those of Authentication:OidcClient.Settings:TokenEndpoint to the token requests (code exchange and refresh).

Note

Since 9.0, the client authentication has no NameClaimType, RoleClaimType or ValidateAuthority: they were never used. The principal of the client is built by the AppPrincipalFactory. Remove them from your configuration and code.

Register the services

// Program.cs
using Arc4u.Caching;
using Arc4u.Configuration;
using Arc4u.Dependency;
using Arc4u.OAuth2.Client.Authentication.Cache;
using Arc4u.OAuth2.Client.Authentication.Token;
using Arc4u.OAuth2.Client.Authentication.TokenProvider;
using Arc4u.OAuth2.Extensions;
using Arc4u.OAuth2.Token;
using Arc4u.Security.Principal;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddILogger();
builder.Services.AddApplicationConfig(builder.Configuration);
builder.Services.AddSingleton<IApplicationContext, ApplicationInstanceContext>();
builder.Services.AddSingleton<ISecureCache, CacheTokens>();

builder.Services.AddOidcClientAuthentication(new SystemBrowser(), NullLoggerFactory.Instance, builder.Configuration);
builder.Services.AddKeyedSingleton<ITokenProvider, OidcClientIdentityModelTokenProvider>(OidcClientIdentityModelTokenProvider.TokenProviderName);

builder.Services.AddHttpClient<InventoryClient>(client => client.BaseAddress = new Uri("https://inventory.example.com/"))
    .AddHttpMessageHandler(sp => new JwtHttpHandler<InventoryClient>(
        sp,
        sp.GetRequiredService<ILogger<InventoryClient>>(),
        "OidcClient"));

using var host = builder.Build();

SystemBrowser is your IBrowser implementation. CacheTokens names its files after the environment name and logging name of Application.Configuration.

The first call through the handler signs the user in: OidcClientIdentityModelTokenProvider returns the cached access token while it is valid, redeems the refresh token when the access token expires in less than ForceRefreshTimeoutTimeSpan, and opens the browser when there is no valid refresh token or the authority rejects it. The tokens are kept in the ISecureCache.

Caution

CacheTokens writes the tokens, refresh token included, as plain JSON files in the OAuth2 folder of the user's local application data (%LOCALAPPDATA% on Windows, ~/.local/share on Linux, ~/Library/Application Support on macOS). The files get the default permissions of the process: on Linux and macOS they are typically readable by the other local users (-rw-r--r--) unless the home folder blocks access; on Windows the folder is protected by the user's access control list. Register your own ISecureCache that encrypts them (for example with the Windows Data Protection API, or SecureStorage in MAUI) when this is not acceptable.

The handler (JwtHttpHandler<T>) needs the IApplicationContext when it is created, and throws ConfigurationException without it. It uses the token type as the scheme and, when there is a principal, adds a culture header. The trace context travels in the standard W3C traceparent header that HttpClient adds. Its constructor that takes a settings name (for example OidcClient) resolves the named SimpleKeyValueSettings options registered by AddOidcClientAuthentication (or any services.Configure<SimpleKeyValueSettings>(name, ...)).

Build the principal

AppPrincipalFactory builds the AppPrincipal of the user from the claims of the access token and of the claims filler, caches the claims in the ISecureCache (to work offline), and sets the principal in the IApplicationContext. Call CreatePrincipalAsync(settings) with the OidcClient settings (or CreatePrincipalAsync("OidcClient")) at startup, and SignOutUserAsync(settings, cancellationToken) to sign out. The overloads without settings use the settings named OAuth2, resolved like the handler does. It needs an INetworkInformation (AlwaysConnected when the app has no offline mode) and an ICacheKeyGenerator.

Sign in with a user name and password

UsernamePasswordTokenProvider (key usernamePassword, package Arc4u.OAuth2.Client) asks the user for credentials through your IUserNamePasswordProvider, keeps them in the ISecureCache, and gets a token with the password grant through the CredentialDirect provider. Prefer the browser sign-in above: the password grant does not support multi-factor authentication.

Caution

The user name and the password are stored in the same ISecureCache as the token. With CacheTokens, the password is written to disk in clear text. Use an ISecureCache that encrypts its values with this provider.

Troubleshooting

The API returns 401 after the sign-in window was closed

When the authority rejects the refresh token, OidcClientIdentityModelTokenProvider drops the tokens and signs the user in again through the browser. When the sign-in fails or is cancelled, it returns a failed result: the desktop JwtHttpHandler<T> logs the error in the Technical category and sends the request without a token, so the API answers 401. Sign the user in again and retry.

ConfigurationException: No settings found for OidcClient

No named SimpleKeyValueSettings options are registered with that name (a keyed IKeyValueSettings is not used). Check the settingsKey given to AddOidcClientAuthentication (the default is OidcClient). For the same reason, AppPrincipalFactory.CreatePrincipalAsync(name) returns a failed result.

See also