Skip to content
KB

What is the right way to authenticate a .NET desktop client against an API that issues JWTs?

howto · dotnet · security · authentication · jwt · oauth

Run OAuth 2.0 Authorization Code with PKCE in the system browser, not an embedded WebView, landing the callback on a loopback address or a registered custom URI scheme, rather than collecting the password in app code. Store the access and refresh tokens in the OS credential store (DPAPI-backed on Windows), never in a config file. Treat the JWT as an opaque bearer token the client attaches to requests but never decodes to decide what the user may do.

Why a desktop app can’t authenticate like a web server

Section titled “Why a desktop app can’t authenticate like a web server”

A web server keeps a client_secret on a machine only it can reach. A desktop app ships the whole binary to the user’s machine, where it can be decompiled or just inspected with a debugger, so it is a “public client” in OAuth terms: anything embedded in it - a secret, a hardcoded password - is not a secret at all. RFC 8252 (“OAuth 2.0 for Native Apps”) is written around that constraint and settles on Authorization Code with PKCE (Proof Key for Code Exchange) as the flow: the app generates a random code_verifier, sends its hash as code_challenge in the authorization request, and must present the original verifier when it redeems the code for tokens, which stops a stolen authorization code from being redeemed by anything except the process that started the request - no client secret needed for that protection to hold.

Do the interactive part in the system browser

Section titled “Do the interactive part in the system browser”

The authorization request opens in the OS’s default browser, not an embedded WebBrowser or WebView2 control hosted inside the app. An embedded control can inspect and log every keystroke on the login page, including the password, and it cannot share the cookies and passkeys the user’s real browser already has, so it forces a fresh login where the system browser would just reuse an existing session. Identity providers increasingly detect and block known embedded-webview user agents outright for this reason. The redirect back to the app is either a loopback address the app is listening on (http://127.0.0.1:<port>/callback, opened with a short-lived HttpListener) or a custom URI scheme registered with the OS (myapp://callback); RFC 8252 requires an authorization server that supports native apps to accept a loopback redirect for exactly this reason.

Use a library instead of driving the flow by hand

Section titled “Use a library instead of driving the flow by hand”
  • Microsoft Entra ID (Azure AD) or any Microsoft account: Microsoft.Identity.Client (MSAL.NET), configured as a PublicClientApplication. Call AcquireTokenSilent first and only fall back to AcquireTokenInteractive when it throws MsalUiRequiredException, so a returning user isn’t reprompted every launch. On Windows, Microsoft’s current guidance is to hand the whole interactive step to WAM (Web Account Manager, the OS broker) rather than a browser tab: add the Microsoft.Identity.Client.Broker package and call .WithBroker(new BrokerOptions(BrokerOptions.OperatingSystems.Windows)) alongside .WithParentActivityOrWindow(...), passing the window handle the broker’s modal should parent itself to (in WPF, new WindowInteropHelper(window).Handle) - MSAL requires that handle now, because leaving it to guess left the broker’s window opening behind the app’s on some machines. WAM reuses the account already signed into Windows, keeps tokens device-bound under the broker’s own protection rather than the app’s, and needs Windows 10 1703+ or Windows Server 2019+; MSAL falls back to the browser automatically everywhere else, including on Windows against an authority WAM does not support (Azure B2C, ADFS). WAM speaks only to Microsoft Entra ID: it never applies to the generic OpenID Connect path below, or to a Microsoft account signed in on macOS or Linux.

    examples/dotnet/jwt-authentication-desktop-client.cs
    static class MsalSignIn
    {
    // app is the one long-lived PublicClientApplication built once at
    // startup ("Registering the pieces in DI" below), not a new one per
    // call: a second instance starts with an empty cache of its own.
    public static async Task<string> AcquireTokenAsync(IPublicClientApplication app)
    {
    string[] scopes = ["api://your-api/access_as_user"];
    var accounts = await app.GetAccountsAsync();
    AuthenticationResult result;
    try
    {
    result = await app.AcquireTokenSilent(scopes, accounts.FirstOrDefault()).ExecuteAsync();
    }
    catch (MsalUiRequiredException)
    {
    result = await app.AcquireTokenInteractive(scopes).ExecuteAsync();
    }
    return result.AccessToken;
    }
    }

    compiled and run by the build

    Show the whole file
    examples/dotnet/jwt-authentication-desktop-client.cs
    #:package Microsoft.Identity.Client@4.90.0
    #:package Duende.IdentityModel.OidcClient@7.1.0
    #:package Microsoft.Extensions.Hosting@10.0.12
    #:package Microsoft.Extensions.Http@10.0.12
    #:package xunit.assert@2.9.3
    #:project shared/WidgetApi
    // PKCE sign-in, token storage and the 401 retry for a desktop client. The
    // marked regions are shown on the page; the rest proves each claim without
    // a real browser or a real identity provider: MSAL's own local cache proves
    // the silent-then-interactive fallback is genuinely needed, and a stub
    // token endpoint stands in for the identity provider's token route.
    // Interactive sign-in itself - AcquireTokenInteractive, OidcClient.LoginAsync
    // - needs a real browser a headless run has none of, so those two calls are
    // shown and compiled here but never invoked; everything reachable without
    // one is exercised. A failed Assert throws, and the run exits non-zero.
    using System.Net;
    using System.Net.Http.Headers;
    using Duende.IdentityModel.OidcClient;
    using Duende.IdentityModel.OidcClient.Browser;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Hosting;
    using Microsoft.Extensions.Logging;
    using Microsoft.Identity.Client;
    using JsonWebKeySet = Duende.IdentityModel.Jwk.JsonWebKeySet;
    using WidgetApi;
    using Xunit;
    await Claims.SilentAcquireNeedsACachedAccountBeforeItTriesTheNetwork();
    await Claims.SignOutHasNothingToRemoveOnAFreshCache();
    await Claims.HandlerAttachesTheTokenAndRetriesOnceOn401();
    await Claims.HandlerDoesNotRetryASecondTime();
    await Claims.OidcRefreshTradesTheRefreshTokenForAnAccessToken();
    await Claims.OidcRefreshThrowsWhenTheProviderRejectsIt();
    await Claims.RefreshLockSerializesConcurrentCallers();
    await Claims.RegistrationWiresTheHandlerToTheProvider();
    Console.WriteLine("jwt-authentication-desktop-client: all claims hold");
    static class Claims
    {
    // A cached-account-less PublicClientApplication never reaches the
    // network: AcquireTokenSilent fails locally the moment it has no
    // account to look up, which is exactly the signal the catch below
    // exists to react to.
    public static async Task SilentAcquireNeedsACachedAccountBeforeItTriesTheNetwork()
    {
    var app = PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
    .WithDefaultRedirectUri()
    .Build();
    Assert.Empty(await app.GetAccountsAsync());
    var provider = new MsalTokenProvider(app, ["api://your-api/access_as_user"]);
    await Assert.ThrowsAsync<MsalUiRequiredException>(() => provider.GetAccessTokenAsync());
    await Assert.ThrowsAsync<MsalUiRequiredException>(() => provider.RefreshAccessTokenAsync());
    }
    public static async Task SignOutHasNothingToRemoveOnAFreshCache()
    {
    var app = PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
    .WithDefaultRedirectUri()
    .Build();
    var provider = new MsalTokenProvider(app, ["api://your-api/access_as_user"]);
    await provider.SignOutAsync(); // loops over zero cached accounts; throws nothing
    Assert.Empty(await app.GetAccountsAsync());
    }
    public static async Task HandlerAttachesTheTokenAndRetriesOnceOn401()
    {
    var tokens = new TestTokenProvider();
    var stub = new SequencedHandler(HttpStatusCode.Unauthorized, HttpStatusCode.OK);
    var handler = new BearerTokenHandler(tokens) { InnerHandler = stub };
    using var client = new HttpClient(handler);
    var response = await client.GetAsync("https://api.example.invalid/widgets");
    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    Assert.Equal(["token-1", "token-2"], stub.SeenAuthorization);
    Assert.Equal(1, tokens.Refreshes);
    }
    public static async Task HandlerDoesNotRetryASecondTime()
    {
    var tokens = new TestTokenProvider();
    var stub = new SequencedHandler(HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized);
    var handler = new BearerTokenHandler(tokens) { InnerHandler = stub };
    using var client = new HttpClient(handler);
    var response = await client.GetAsync("https://api.example.invalid/widgets");
    Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); // still 401: no second retry
    Assert.Equal(2, stub.SeenAuthorization.Count);
    Assert.Equal(1, tokens.Refreshes);
    }
    public static async Task OidcRefreshTradesTheRefreshTokenForAnAccessToken()
    {
    var endpoint = new TokenEndpointStub(TokenEndpointStub.Success);
    var client = OidcClientAgainst(endpoint);
    var store = new FakeCredentialStore("refresh-1");
    var provider = new OidcTokenProvider(client, store);
    var accessToken = await provider.GetAccessTokenAsync();
    Assert.Equal("fresh-access-token", accessToken);
    Assert.Equal("fresh-refresh-token", store.Saved); // rotated refresh token is persisted
    }
    public static async Task OidcRefreshThrowsWhenTheProviderRejectsIt()
    {
    var endpoint = new TokenEndpointStub(TokenEndpointStub.InvalidGrant);
    var client = OidcClientAgainst(endpoint);
    var store = new FakeCredentialStore("refresh-expired");
    var provider = new OidcTokenProvider(client, store);
    // Duende's RefreshTokenAsync returns IsError rather than throwing;
    // the provider is what turns that into the exception the handler's
    // uncaught RefreshAccessTokenAsync call needs to fall back on.
    var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => provider.GetAccessTokenAsync());
    Assert.Contains("invalid_grant", ex.Message);
    }
    // Two 401s arriving close together both call RefreshAccessTokenAsync with
    // the same stale refresh token; a rotating provider accepts that token
    // exactly once, so an unguarded second call would fail even though the
    // first one succeeded. The stub blocks the first call's network request
    // open until the second call is queued on the lock, forcing the overlap
    // this test exists to catch rather than leaving it to scheduling luck.
    public static async Task RefreshLockSerializesConcurrentCallers()
    {
    var release = new TaskCompletionSource();
    var endpoint = new SlowTokenEndpointStub(release);
    var client = OidcClientAgainst(endpoint);
    var store = new FakeCredentialStore("refresh-1");
    var provider = new OidcTokenProvider(client, store);
    var first = provider.RefreshAccessTokenAsync();
    var second = provider.RefreshAccessTokenAsync();
    await Task.Delay(20); // let the second call reach the lock before releasing the first
    release.SetResult();
    var tokens = await Task.WhenAll(first, second);
    Assert.Equal(1, endpoint.Calls); // the lock reused the first refresh instead of racing a second
    Assert.Equal(["fresh-access-token", "fresh-access-token"], tokens);
    }
    public static async Task RegistrationWiresTheHandlerToTheProvider()
    {
    var builder = Host.CreateApplicationBuilder();
    builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["ApiBaseUrl"] = "https://api.example.invalid/" });
    builder.Logging.ClearProviders();
    Register(builder);
    builder.Services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new SequencedHandler(HttpStatusCode.OK)));
    using var host = builder.Build();
    // Nothing here has ever signed in, so the chain the registration
    // wires up - typed client, BearerTokenHandler, MsalTokenProvider -
    // reaches the same local, network-free failure the first claim
    // above proves directly; a wiring mistake would fail differently,
    // with a DI resolution error instead of this one.
    var apiClient = host.Services.GetRequiredService<IWidgetApiClient>();
    await Assert.ThrowsAsync<MsalUiRequiredException>(() => apiClient.GetWidgetsAsync());
    }
    static void Register(HostApplicationBuilder builder)
    {
    builder.Services.AddSingleton<IPublicClientApplication>(_ =>
    PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
    .WithDefaultRedirectUri()
    .Build());
    builder.Services.AddSingleton<ITokenProvider>(sp => new MsalTokenProvider(
    sp.GetRequiredService<IPublicClientApplication>(),
    scopes: ["api://your-api/access_as_user"]));
    builder.Services.AddTransient<BearerTokenHandler>();
    builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
    client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
    .AddHttpMessageHandler<BearerTokenHandler>();
    }
    // A discovery-free OidcClient: ProviderInformation is supplied directly
    // instead of fetched from Authority/.well-known, and BackchannelHandler
    // is the stub, so RefreshTokenAsync's only HTTP call goes there.
    static OidcClient OidcClientAgainst(HttpMessageHandler tokenEndpoint) => new(new OidcClientOptions
    {
    Authority = "https://your-authority",
    ClientId = "your-client-id",
    RedirectUri = "http://127.0.0.1:7890/",
    Scope = "api", // no "openid": no id_token comes back, so there is no signature to validate
    Browser = new SystemBrowser(),
    BackchannelHandler = tokenEndpoint,
    ProviderInformation = new ProviderInformation
    {
    IssuerName = "https://your-authority",
    TokenEndpoint = "https://your-authority/connect/token",
    AuthorizeEndpoint = "https://your-authority/connect/authorize",
    KeySet = new JsonWebKeySet(), // no "openid" scope: no id_token comes back to check it against
    },
    });
    }
    static class MsalSignIn
    {
    // app is the one long-lived PublicClientApplication built once at
    // startup ("Registering the pieces in DI" below), not a new one per
    // call: a second instance starts with an empty cache of its own.
    public static async Task<string> AcquireTokenAsync(IPublicClientApplication app)
    {
    string[] scopes = ["api://your-api/access_as_user"];
    var accounts = await app.GetAccountsAsync();
    AuthenticationResult result;
    try
    {
    result = await app.AcquireTokenSilent(scopes, accounts.FirstOrDefault()).ExecuteAsync();
    }
    catch (MsalUiRequiredException)
    {
    result = await app.AcquireTokenInteractive(scopes).ExecuteAsync();
    }
    return result.AccessToken;
    }
    }
    static class OidcSignIn
    {
    public static async Task<string?> LoginAsync()
    {
    var options = new OidcClientOptions
    {
    Authority = "https://your-authority",
    ClientId = "your-client-id",
    RedirectUri = "http://127.0.0.1:7890/",
    Scope = "openid profile api offline_access", // offline_access: without it most providers issue no refresh token
    Browser = new SystemBrowser(), // loopback HttpListener browser
    };
    var client = new OidcClient(options);
    var result = await client.LoginAsync();
    return result.IsError ? null : result.AccessToken;
    }
    }
    // A minimal stand-in for the loopback-listener IBrowser a real desktop app
    // registers: enough for the sign-in snippets above to compile, never
    // exercised, since invoking it would open a real OS browser.
    sealed class SystemBrowser : IBrowser
    {
    public Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default) =>
    throw new NotSupportedException("opens the OS browser and listens on the loopback port; not invoked by this file");
    }
    public sealed class BearerTokenHandler(ITokenProvider tokens) : DelegatingHandler
    {
    protected override async Task<HttpResponseMessage> SendAsync(
    HttpRequestMessage request, CancellationToken cancellationToken)
    {
    request.Headers.Authorization = new("Bearer", await tokens.GetAccessTokenAsync(cancellationToken));
    var response = await base.SendAsync(request, cancellationToken);
    if (response.StatusCode == HttpStatusCode.Unauthorized)
    {
    response.Dispose(); // replaced below; leaving it undisposed leaks its connection
    request.Headers.Authorization = new("Bearer", await tokens.RefreshAccessTokenAsync(cancellationToken));
    response = await base.SendAsync(request, cancellationToken);
    }
    return response;
    }
    }
    public interface ITokenProvider
    {
    Task<string> GetAccessTokenAsync(CancellationToken ct = default);
    Task<string> RefreshAccessTokenAsync(CancellationToken ct = default);
    Task SignOutAsync(CancellationToken ct = default);
    }
    public sealed class MsalTokenProvider(IPublicClientApplication app, string[] scopes) : ITokenProvider
    {
    public async Task<string> GetAccessTokenAsync(CancellationToken ct = default)
    {
    var account = (await app.GetAccountsAsync()).FirstOrDefault();
    var result = await app.AcquireTokenSilent(scopes, account).ExecuteAsync(ct);
    return result.AccessToken;
    }
    public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
    {
    var account = (await app.GetAccountsAsync()).FirstOrDefault();
    var result = await app.AcquireTokenSilent(scopes, account)
    .WithForceRefresh(true)
    .ExecuteAsync(ct);
    return result.AccessToken;
    }
    public async Task SignOutAsync(CancellationToken ct = default)
    {
    foreach (var account in await app.GetAccountsAsync())
    {
    await app.RemoveAsync(account);
    }
    }
    }
    public interface ICredentialStore
    {
    Task<string?> GetRefreshTokenAsync(CancellationToken ct = default);
    Task SaveRefreshTokenAsync(string? refreshToken, CancellationToken ct = default);
    Task DeleteRefreshTokenAsync(CancellationToken ct = default);
    }
    public sealed class OidcTokenProvider(OidcClient client, ICredentialStore store) : ITokenProvider
    {
    private readonly SemaphoreSlim _refreshLock = new(1, 1);
    private string? _accessToken;
    public async Task<string> GetAccessTokenAsync(CancellationToken ct = default) =>
    _accessToken ?? await RefreshAccessTokenAsync(ct);
    public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
    {
    var beforeLock = _accessToken;
    await _refreshLock.WaitAsync(ct);
    try
    {
    // Another caller already rotated the refresh token while this one
    // waited on the lock; reusing its result instead of sending the
    // now-stale refresh token to the server a second time.
    if (_accessToken is not null && _accessToken != beforeLock)
    {
    return _accessToken;
    }
    var refreshToken = await store.GetRefreshTokenAsync(ct);
    var result = await client.RefreshTokenAsync(refreshToken, cancellationToken: ct);
    if (result.IsError)
    {
    throw new InvalidOperationException(result.Error);
    }
    await store.SaveRefreshTokenAsync(result.RefreshToken, ct);
    return _accessToken = result.AccessToken;
    }
    finally
    {
    _refreshLock.Release();
    }
    }
    public async Task SignOutAsync(CancellationToken ct = default)
    {
    _accessToken = null;
    await store.DeleteRefreshTokenAsync(ct);
    }
    }
    sealed class TestTokenProvider : ITokenProvider
    {
    public int Refreshes { get; private set; }
    public Task<string> GetAccessTokenAsync(CancellationToken ct = default) => Task.FromResult("token-1");
    public Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
    {
    Refreshes++;
    return Task.FromResult("token-2");
    }
    public Task SignOutAsync(CancellationToken ct = default) => Task.CompletedTask;
    }
    sealed class SequencedHandler(params HttpStatusCode[] statuses) : HttpMessageHandler
    {
    private int _next;
    public List<string?> SeenAuthorization { get; } = [];
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
    SeenAuthorization.Add(request.Headers.Authorization?.Parameter);
    var status = statuses[Math.Min(_next++, statuses.Length - 1)];
    return Task.FromResult(new HttpResponseMessage(status));
    }
    }
    sealed class FakeCredentialStore(string? refreshToken) : ICredentialStore
    {
    public string? Saved { get; private set; }
    public Task<string?> GetRefreshTokenAsync(CancellationToken ct = default) => Task.FromResult(refreshToken);
    public Task SaveRefreshTokenAsync(string? token, CancellationToken ct = default)
    {
    Saved = token;
    return Task.CompletedTask;
    }
    public Task DeleteRefreshTokenAsync(CancellationToken ct = default)
    {
    Saved = null;
    return Task.CompletedTask;
    }
    }
    sealed class TokenEndpointStub(string body) : HttpMessageHandler
    {
    public const string Success = """{"access_token":"fresh-access-token","refresh_token":"fresh-refresh-token","expires_in":3600,"token_type":"Bearer"}""";
    public const string InvalidGrant = """{"error":"invalid_grant","error_description":"refresh token expired or revoked"}""";
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
    Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") });
    }
    // Holds its one call open until the test releases it, so a second caller has
    // time to queue on OidcTokenProvider's lock instead of racing it by luck.
    sealed class SlowTokenEndpointStub(TaskCompletionSource release) : HttpMessageHandler
    {
    public int Calls;
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
    Interlocked.Increment(ref Calls);
    await release.Task;
    return new HttpResponseMessage(HttpStatusCode.OK)
    {
    Content = new StringContent(TokenEndpointStub.Success, System.Text.Encoding.UTF8, "application/json"),
    };
    }
    }

    AcquireTokenSilent only has anything to try once a token cache is wired up (Microsoft.Identity.Client.Extensions.Msal, see below) - without one, every launch falls straight into the catch and reprompts. app here is the one PublicClientApplication the process builds once and keeps for its whole life (“Registering the pieces in DI” below): building a second one to sign in starts from an empty cache and can pop up a sign-in prompt on top of a session the first instance already established, so the call takes the shared instance rather than building its own.

  • Any other OpenID Connect provider: Duende.IdentityModel.OidcClient implements the same Authorization Code + PKCE flow against a generic authority, with a pluggable browser abstraction (a HttpListener-based loopback browser for WPF/WinForms/console, MAUI’s own WebAuthenticator for a MAUI head).

    examples/dotnet/jwt-authentication-desktop-client.cs
    static class OidcSignIn
    {
    public static async Task<string?> LoginAsync()
    {
    var options = new OidcClientOptions
    {
    Authority = "https://your-authority",
    ClientId = "your-client-id",
    RedirectUri = "http://127.0.0.1:7890/",
    Scope = "openid profile api offline_access", // offline_access: without it most providers issue no refresh token
    Browser = new SystemBrowser(), // loopback HttpListener browser
    };
    var client = new OidcClient(options);
    var result = await client.LoginAsync();
    return result.IsError ? null : result.AccessToken;
    }
    }

    compiled and run by the build

    Show the whole file
    examples/dotnet/jwt-authentication-desktop-client.cs
    #:package Microsoft.Identity.Client@4.90.0
    #:package Duende.IdentityModel.OidcClient@7.1.0
    #:package Microsoft.Extensions.Hosting@10.0.12
    #:package Microsoft.Extensions.Http@10.0.12
    #:package xunit.assert@2.9.3
    #:project shared/WidgetApi
    // PKCE sign-in, token storage and the 401 retry for a desktop client. The
    // marked regions are shown on the page; the rest proves each claim without
    // a real browser or a real identity provider: MSAL's own local cache proves
    // the silent-then-interactive fallback is genuinely needed, and a stub
    // token endpoint stands in for the identity provider's token route.
    // Interactive sign-in itself - AcquireTokenInteractive, OidcClient.LoginAsync
    // - needs a real browser a headless run has none of, so those two calls are
    // shown and compiled here but never invoked; everything reachable without
    // one is exercised. A failed Assert throws, and the run exits non-zero.
    using System.Net;
    using System.Net.Http.Headers;
    using Duende.IdentityModel.OidcClient;
    using Duende.IdentityModel.OidcClient.Browser;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Hosting;
    using Microsoft.Extensions.Logging;
    using Microsoft.Identity.Client;
    using JsonWebKeySet = Duende.IdentityModel.Jwk.JsonWebKeySet;
    using WidgetApi;
    using Xunit;
    await Claims.SilentAcquireNeedsACachedAccountBeforeItTriesTheNetwork();
    await Claims.SignOutHasNothingToRemoveOnAFreshCache();
    await Claims.HandlerAttachesTheTokenAndRetriesOnceOn401();
    await Claims.HandlerDoesNotRetryASecondTime();
    await Claims.OidcRefreshTradesTheRefreshTokenForAnAccessToken();
    await Claims.OidcRefreshThrowsWhenTheProviderRejectsIt();
    await Claims.RefreshLockSerializesConcurrentCallers();
    await Claims.RegistrationWiresTheHandlerToTheProvider();
    Console.WriteLine("jwt-authentication-desktop-client: all claims hold");
    static class Claims
    {
    // A cached-account-less PublicClientApplication never reaches the
    // network: AcquireTokenSilent fails locally the moment it has no
    // account to look up, which is exactly the signal the catch below
    // exists to react to.
    public static async Task SilentAcquireNeedsACachedAccountBeforeItTriesTheNetwork()
    {
    var app = PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
    .WithDefaultRedirectUri()
    .Build();
    Assert.Empty(await app.GetAccountsAsync());
    var provider = new MsalTokenProvider(app, ["api://your-api/access_as_user"]);
    await Assert.ThrowsAsync<MsalUiRequiredException>(() => provider.GetAccessTokenAsync());
    await Assert.ThrowsAsync<MsalUiRequiredException>(() => provider.RefreshAccessTokenAsync());
    }
    public static async Task SignOutHasNothingToRemoveOnAFreshCache()
    {
    var app = PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
    .WithDefaultRedirectUri()
    .Build();
    var provider = new MsalTokenProvider(app, ["api://your-api/access_as_user"]);
    await provider.SignOutAsync(); // loops over zero cached accounts; throws nothing
    Assert.Empty(await app.GetAccountsAsync());
    }
    public static async Task HandlerAttachesTheTokenAndRetriesOnceOn401()
    {
    var tokens = new TestTokenProvider();
    var stub = new SequencedHandler(HttpStatusCode.Unauthorized, HttpStatusCode.OK);
    var handler = new BearerTokenHandler(tokens) { InnerHandler = stub };
    using var client = new HttpClient(handler);
    var response = await client.GetAsync("https://api.example.invalid/widgets");
    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    Assert.Equal(["token-1", "token-2"], stub.SeenAuthorization);
    Assert.Equal(1, tokens.Refreshes);
    }
    public static async Task HandlerDoesNotRetryASecondTime()
    {
    var tokens = new TestTokenProvider();
    var stub = new SequencedHandler(HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized);
    var handler = new BearerTokenHandler(tokens) { InnerHandler = stub };
    using var client = new HttpClient(handler);
    var response = await client.GetAsync("https://api.example.invalid/widgets");
    Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); // still 401: no second retry
    Assert.Equal(2, stub.SeenAuthorization.Count);
    Assert.Equal(1, tokens.Refreshes);
    }
    public static async Task OidcRefreshTradesTheRefreshTokenForAnAccessToken()
    {
    var endpoint = new TokenEndpointStub(TokenEndpointStub.Success);
    var client = OidcClientAgainst(endpoint);
    var store = new FakeCredentialStore("refresh-1");
    var provider = new OidcTokenProvider(client, store);
    var accessToken = await provider.GetAccessTokenAsync();
    Assert.Equal("fresh-access-token", accessToken);
    Assert.Equal("fresh-refresh-token", store.Saved); // rotated refresh token is persisted
    }
    public static async Task OidcRefreshThrowsWhenTheProviderRejectsIt()
    {
    var endpoint = new TokenEndpointStub(TokenEndpointStub.InvalidGrant);
    var client = OidcClientAgainst(endpoint);
    var store = new FakeCredentialStore("refresh-expired");
    var provider = new OidcTokenProvider(client, store);
    // Duende's RefreshTokenAsync returns IsError rather than throwing;
    // the provider is what turns that into the exception the handler's
    // uncaught RefreshAccessTokenAsync call needs to fall back on.
    var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => provider.GetAccessTokenAsync());
    Assert.Contains("invalid_grant", ex.Message);
    }
    // Two 401s arriving close together both call RefreshAccessTokenAsync with
    // the same stale refresh token; a rotating provider accepts that token
    // exactly once, so an unguarded second call would fail even though the
    // first one succeeded. The stub blocks the first call's network request
    // open until the second call is queued on the lock, forcing the overlap
    // this test exists to catch rather than leaving it to scheduling luck.
    public static async Task RefreshLockSerializesConcurrentCallers()
    {
    var release = new TaskCompletionSource();
    var endpoint = new SlowTokenEndpointStub(release);
    var client = OidcClientAgainst(endpoint);
    var store = new FakeCredentialStore("refresh-1");
    var provider = new OidcTokenProvider(client, store);
    var first = provider.RefreshAccessTokenAsync();
    var second = provider.RefreshAccessTokenAsync();
    await Task.Delay(20); // let the second call reach the lock before releasing the first
    release.SetResult();
    var tokens = await Task.WhenAll(first, second);
    Assert.Equal(1, endpoint.Calls); // the lock reused the first refresh instead of racing a second
    Assert.Equal(["fresh-access-token", "fresh-access-token"], tokens);
    }
    public static async Task RegistrationWiresTheHandlerToTheProvider()
    {
    var builder = Host.CreateApplicationBuilder();
    builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["ApiBaseUrl"] = "https://api.example.invalid/" });
    builder.Logging.ClearProviders();
    Register(builder);
    builder.Services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new SequencedHandler(HttpStatusCode.OK)));
    using var host = builder.Build();
    // Nothing here has ever signed in, so the chain the registration
    // wires up - typed client, BearerTokenHandler, MsalTokenProvider -
    // reaches the same local, network-free failure the first claim
    // above proves directly; a wiring mistake would fail differently,
    // with a DI resolution error instead of this one.
    var apiClient = host.Services.GetRequiredService<IWidgetApiClient>();
    await Assert.ThrowsAsync<MsalUiRequiredException>(() => apiClient.GetWidgetsAsync());
    }
    static void Register(HostApplicationBuilder builder)
    {
    builder.Services.AddSingleton<IPublicClientApplication>(_ =>
    PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
    .WithDefaultRedirectUri()
    .Build());
    builder.Services.AddSingleton<ITokenProvider>(sp => new MsalTokenProvider(
    sp.GetRequiredService<IPublicClientApplication>(),
    scopes: ["api://your-api/access_as_user"]));
    builder.Services.AddTransient<BearerTokenHandler>();
    builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
    client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
    .AddHttpMessageHandler<BearerTokenHandler>();
    }
    // A discovery-free OidcClient: ProviderInformation is supplied directly
    // instead of fetched from Authority/.well-known, and BackchannelHandler
    // is the stub, so RefreshTokenAsync's only HTTP call goes there.
    static OidcClient OidcClientAgainst(HttpMessageHandler tokenEndpoint) => new(new OidcClientOptions
    {
    Authority = "https://your-authority",
    ClientId = "your-client-id",
    RedirectUri = "http://127.0.0.1:7890/",
    Scope = "api", // no "openid": no id_token comes back, so there is no signature to validate
    Browser = new SystemBrowser(),
    BackchannelHandler = tokenEndpoint,
    ProviderInformation = new ProviderInformation
    {
    IssuerName = "https://your-authority",
    TokenEndpoint = "https://your-authority/connect/token",
    AuthorizeEndpoint = "https://your-authority/connect/authorize",
    KeySet = new JsonWebKeySet(), // no "openid" scope: no id_token comes back to check it against
    },
    });
    }
    static class MsalSignIn
    {
    // app is the one long-lived PublicClientApplication built once at
    // startup ("Registering the pieces in DI" below), not a new one per
    // call: a second instance starts with an empty cache of its own.
    public static async Task<string> AcquireTokenAsync(IPublicClientApplication app)
    {
    string[] scopes = ["api://your-api/access_as_user"];
    var accounts = await app.GetAccountsAsync();
    AuthenticationResult result;
    try
    {
    result = await app.AcquireTokenSilent(scopes, accounts.FirstOrDefault()).ExecuteAsync();
    }
    catch (MsalUiRequiredException)
    {
    result = await app.AcquireTokenInteractive(scopes).ExecuteAsync();
    }
    return result.AccessToken;
    }
    }
    static class OidcSignIn
    {
    public static async Task<string?> LoginAsync()
    {
    var options = new OidcClientOptions
    {
    Authority = "https://your-authority",
    ClientId = "your-client-id",
    RedirectUri = "http://127.0.0.1:7890/",
    Scope = "openid profile api offline_access", // offline_access: without it most providers issue no refresh token
    Browser = new SystemBrowser(), // loopback HttpListener browser
    };
    var client = new OidcClient(options);
    var result = await client.LoginAsync();
    return result.IsError ? null : result.AccessToken;
    }
    }
    // A minimal stand-in for the loopback-listener IBrowser a real desktop app
    // registers: enough for the sign-in snippets above to compile, never
    // exercised, since invoking it would open a real OS browser.
    sealed class SystemBrowser : IBrowser
    {
    public Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default) =>
    throw new NotSupportedException("opens the OS browser and listens on the loopback port; not invoked by this file");
    }
    public sealed class BearerTokenHandler(ITokenProvider tokens) : DelegatingHandler
    {
    protected override async Task<HttpResponseMessage> SendAsync(
    HttpRequestMessage request, CancellationToken cancellationToken)
    {
    request.Headers.Authorization = new("Bearer", await tokens.GetAccessTokenAsync(cancellationToken));
    var response = await base.SendAsync(request, cancellationToken);
    if (response.StatusCode == HttpStatusCode.Unauthorized)
    {
    response.Dispose(); // replaced below; leaving it undisposed leaks its connection
    request.Headers.Authorization = new("Bearer", await tokens.RefreshAccessTokenAsync(cancellationToken));
    response = await base.SendAsync(request, cancellationToken);
    }
    return response;
    }
    }
    public interface ITokenProvider
    {
    Task<string> GetAccessTokenAsync(CancellationToken ct = default);
    Task<string> RefreshAccessTokenAsync(CancellationToken ct = default);
    Task SignOutAsync(CancellationToken ct = default);
    }
    public sealed class MsalTokenProvider(IPublicClientApplication app, string[] scopes) : ITokenProvider
    {
    public async Task<string> GetAccessTokenAsync(CancellationToken ct = default)
    {
    var account = (await app.GetAccountsAsync()).FirstOrDefault();
    var result = await app.AcquireTokenSilent(scopes, account).ExecuteAsync(ct);
    return result.AccessToken;
    }
    public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
    {
    var account = (await app.GetAccountsAsync()).FirstOrDefault();
    var result = await app.AcquireTokenSilent(scopes, account)
    .WithForceRefresh(true)
    .ExecuteAsync(ct);
    return result.AccessToken;
    }
    public async Task SignOutAsync(CancellationToken ct = default)
    {
    foreach (var account in await app.GetAccountsAsync())
    {
    await app.RemoveAsync(account);
    }
    }
    }
    public interface ICredentialStore
    {
    Task<string?> GetRefreshTokenAsync(CancellationToken ct = default);
    Task SaveRefreshTokenAsync(string? refreshToken, CancellationToken ct = default);
    Task DeleteRefreshTokenAsync(CancellationToken ct = default);
    }
    public sealed class OidcTokenProvider(OidcClient client, ICredentialStore store) : ITokenProvider
    {
    private readonly SemaphoreSlim _refreshLock = new(1, 1);
    private string? _accessToken;
    public async Task<string> GetAccessTokenAsync(CancellationToken ct = default) =>
    _accessToken ?? await RefreshAccessTokenAsync(ct);
    public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
    {
    var beforeLock = _accessToken;
    await _refreshLock.WaitAsync(ct);
    try
    {
    // Another caller already rotated the refresh token while this one
    // waited on the lock; reusing its result instead of sending the
    // now-stale refresh token to the server a second time.
    if (_accessToken is not null && _accessToken != beforeLock)
    {
    return _accessToken;
    }
    var refreshToken = await store.GetRefreshTokenAsync(ct);
    var result = await client.RefreshTokenAsync(refreshToken, cancellationToken: ct);
    if (result.IsError)
    {
    throw new InvalidOperationException(result.Error);
    }
    await store.SaveRefreshTokenAsync(result.RefreshToken, ct);
    return _accessToken = result.AccessToken;
    }
    finally
    {
    _refreshLock.Release();
    }
    }
    public async Task SignOutAsync(CancellationToken ct = default)
    {
    _accessToken = null;
    await store.DeleteRefreshTokenAsync(ct);
    }
    }
    sealed class TestTokenProvider : ITokenProvider
    {
    public int Refreshes { get; private set; }
    public Task<string> GetAccessTokenAsync(CancellationToken ct = default) => Task.FromResult("token-1");
    public Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
    {
    Refreshes++;
    return Task.FromResult("token-2");
    }
    public Task SignOutAsync(CancellationToken ct = default) => Task.CompletedTask;
    }
    sealed class SequencedHandler(params HttpStatusCode[] statuses) : HttpMessageHandler
    {
    private int _next;
    public List<string?> SeenAuthorization { get; } = [];
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
    SeenAuthorization.Add(request.Headers.Authorization?.Parameter);
    var status = statuses[Math.Min(_next++, statuses.Length - 1)];
    return Task.FromResult(new HttpResponseMessage(status));
    }
    }
    sealed class FakeCredentialStore(string? refreshToken) : ICredentialStore
    {
    public string? Saved { get; private set; }
    public Task<string?> GetRefreshTokenAsync(CancellationToken ct = default) => Task.FromResult(refreshToken);
    public Task SaveRefreshTokenAsync(string? token, CancellationToken ct = default)
    {
    Saved = token;
    return Task.CompletedTask;
    }
    public Task DeleteRefreshTokenAsync(CancellationToken ct = default)
    {
    Saved = null;
    return Task.CompletedTask;
    }
    }
    sealed class TokenEndpointStub(string body) : HttpMessageHandler
    {
    public const string Success = """{"access_token":"fresh-access-token","refresh_token":"fresh-refresh-token","expires_in":3600,"token_type":"Bearer"}""";
    public const string InvalidGrant = """{"error":"invalid_grant","error_description":"refresh token expired or revoked"}""";
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
    Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") });
    }
    // Holds its one call open until the test releases it, so a second caller has
    // time to queue on OidcTokenProvider's lock instead of racing it by luck.
    sealed class SlowTokenEndpointStub(TaskCompletionSource release) : HttpMessageHandler
    {
    public int Calls;
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
    Interlocked.Increment(ref Calls);
    await release.Task;
    return new HttpResponseMessage(HttpStatusCode.OK)
    {
    Content = new StringContent(TokenEndpointStub.Success, System.Text.Encoding.UTF8, "application/json"),
    };
    }
    }

    Scope includes offline_access alongside openid profile api: that is the scope OpenID Connect reserves for asking the token endpoint to include a refresh token in the response at all. Leave it out and every provider that follows the spec - Duende IdentityServer and Auth0 among them, and Microsoft Entra ID’s own v2.0 endpoint too - returns only the short-lived access token, so there is nothing for OidcTokenProvider below to trade for a new one once it expires, and the app falls back to the interactive browser flow every time instead of only when the refresh token itself expires or is revoked. MSAL, in the bullet above, never has to add it by hand because MSAL.NET requests openid, profile and offline_access on every call regardless of what its caller passes; a client that talks to Entra ID’s token endpoint directly, the way OidcClient here does, gets no such help and has to ask for offline_access itself like any other OpenID Connect client.

Either way, the access token that comes back is a JWT the app never needs to open: only the API that issued it, or that trusts its issuer, has to verify the signature and read the claims.

Where the UI reads the user’s name and role

Section titled “Where the UI reads the user’s name and role”

A “signed in as …” label or a role-gated menu reads the ID token’s claims, or the claims principal the sign-in result already carries - MSAL’s AuthenticationResult.ClaimsPrincipal, OidcClient.LoginAsync’s LoginResult.User - never the access token. The two tokens have different audiences: the ID token is minted for the app itself and is safe for the app to read, while the access token is addressed to the API and is exactly the “opaque bearer token” this entry’s answer says never to decode. Some providers also omit standard profile claims from the access token even when they are willing to decode it, or issue an access token that is not a JWT at all - Microsoft Graph’s, for one, is deliberately opaque to the client - so code that reads name or role off the access token can break on a provider swap even where it happens to work today. Requesting the openid and profile scopes is what puts the claims there in the first place; without openid no ID token comes back at all. When the ID token does not carry a claim the UI needs - a profile photo URL, say - the userinfo endpoint (/connect/userinfo, or Microsoft Graph’s /oidc/userinfo) fills the gap, called with the access token as its bearer credential; call it only for what is missing, since everything else duplicates what the ID token already has for less latency and one fewer network call.

Neither the access token nor the refresh token belongs in appsettings.json, an app setting, or a plain file next to the executable - anything else running as the same Windows user can read those. MSAL’s own token cache, added with the Microsoft.Identity.Client.Extensions.Msal package, persists automatically to a per-user store tied to the signed-in profile: a DPAPI-encrypted file under the user’s local app data on Windows (ProtectedData/DataProtectionScope.CurrentUser under the hood, not Windows Credential Manager itself), the Keychain on macOS, and a libsecret-backed keyring (Gnome Keyring, KWallet) on Linux; reach for that before writing a custom store. For a token that should live in the actual Windows Credential Manager rather than a DPAPI-encrypted file, go around MSAL’s cache and reach for Windows.Security.Credentials.PasswordVault (packaged apps) directly, or a wrapper such as the CredentialManagement NuGet package. The access token is short-lived (minutes) by design, so silent refresh - trading the long-lived refresh token for a new access token without involving the user - is the common path; only an expired or revoked refresh token should ever fall back to the interactive browser flow above.

A DelegatingHandler registered on the typed HttpClient (the same registration dotnet/wpf-rest-api-project-structure sets up for the API client) is the natural place to add the Authorization: Bearer <token> header and to catch a 401 by triggering a silent refresh and retrying the request once - a view model or service method calling the API never has to know a token exists, let alone fetch or refresh one itself.

examples/dotnet/jwt-authentication-desktop-client.cs
public sealed class BearerTokenHandler(ITokenProvider tokens) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Authorization = new("Bearer", await tokens.GetAccessTokenAsync(cancellationToken));
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose(); // replaced below; leaving it undisposed leaks its connection
request.Headers.Authorization = new("Bearer", await tokens.RefreshAccessTokenAsync(cancellationToken));
response = await base.SendAsync(request, cancellationToken);
}
return response;
}
}

compiled and run by the build

Show the whole file
examples/dotnet/jwt-authentication-desktop-client.cs
#:package Microsoft.Identity.Client@4.90.0
#:package Duende.IdentityModel.OidcClient@7.1.0
#:package Microsoft.Extensions.Hosting@10.0.12
#:package Microsoft.Extensions.Http@10.0.12
#:package xunit.assert@2.9.3
#:project shared/WidgetApi
// PKCE sign-in, token storage and the 401 retry for a desktop client. The
// marked regions are shown on the page; the rest proves each claim without
// a real browser or a real identity provider: MSAL's own local cache proves
// the silent-then-interactive fallback is genuinely needed, and a stub
// token endpoint stands in for the identity provider's token route.
// Interactive sign-in itself - AcquireTokenInteractive, OidcClient.LoginAsync
// - needs a real browser a headless run has none of, so those two calls are
// shown and compiled here but never invoked; everything reachable without
// one is exercised. A failed Assert throws, and the run exits non-zero.
using System.Net;
using System.Net.Http.Headers;
using Duende.IdentityModel.OidcClient;
using Duende.IdentityModel.OidcClient.Browser;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Client;
using JsonWebKeySet = Duende.IdentityModel.Jwk.JsonWebKeySet;
using WidgetApi;
using Xunit;
await Claims.SilentAcquireNeedsACachedAccountBeforeItTriesTheNetwork();
await Claims.SignOutHasNothingToRemoveOnAFreshCache();
await Claims.HandlerAttachesTheTokenAndRetriesOnceOn401();
await Claims.HandlerDoesNotRetryASecondTime();
await Claims.OidcRefreshTradesTheRefreshTokenForAnAccessToken();
await Claims.OidcRefreshThrowsWhenTheProviderRejectsIt();
await Claims.RefreshLockSerializesConcurrentCallers();
await Claims.RegistrationWiresTheHandlerToTheProvider();
Console.WriteLine("jwt-authentication-desktop-client: all claims hold");
static class Claims
{
// A cached-account-less PublicClientApplication never reaches the
// network: AcquireTokenSilent fails locally the moment it has no
// account to look up, which is exactly the signal the catch below
// exists to react to.
public static async Task SilentAcquireNeedsACachedAccountBeforeItTriesTheNetwork()
{
var app = PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
.WithDefaultRedirectUri()
.Build();
Assert.Empty(await app.GetAccountsAsync());
var provider = new MsalTokenProvider(app, ["api://your-api/access_as_user"]);
await Assert.ThrowsAsync<MsalUiRequiredException>(() => provider.GetAccessTokenAsync());
await Assert.ThrowsAsync<MsalUiRequiredException>(() => provider.RefreshAccessTokenAsync());
}
public static async Task SignOutHasNothingToRemoveOnAFreshCache()
{
var app = PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
.WithDefaultRedirectUri()
.Build();
var provider = new MsalTokenProvider(app, ["api://your-api/access_as_user"]);
await provider.SignOutAsync(); // loops over zero cached accounts; throws nothing
Assert.Empty(await app.GetAccountsAsync());
}
public static async Task HandlerAttachesTheTokenAndRetriesOnceOn401()
{
var tokens = new TestTokenProvider();
var stub = new SequencedHandler(HttpStatusCode.Unauthorized, HttpStatusCode.OK);
var handler = new BearerTokenHandler(tokens) { InnerHandler = stub };
using var client = new HttpClient(handler);
var response = await client.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(["token-1", "token-2"], stub.SeenAuthorization);
Assert.Equal(1, tokens.Refreshes);
}
public static async Task HandlerDoesNotRetryASecondTime()
{
var tokens = new TestTokenProvider();
var stub = new SequencedHandler(HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized);
var handler = new BearerTokenHandler(tokens) { InnerHandler = stub };
using var client = new HttpClient(handler);
var response = await client.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); // still 401: no second retry
Assert.Equal(2, stub.SeenAuthorization.Count);
Assert.Equal(1, tokens.Refreshes);
}
public static async Task OidcRefreshTradesTheRefreshTokenForAnAccessToken()
{
var endpoint = new TokenEndpointStub(TokenEndpointStub.Success);
var client = OidcClientAgainst(endpoint);
var store = new FakeCredentialStore("refresh-1");
var provider = new OidcTokenProvider(client, store);
var accessToken = await provider.GetAccessTokenAsync();
Assert.Equal("fresh-access-token", accessToken);
Assert.Equal("fresh-refresh-token", store.Saved); // rotated refresh token is persisted
}
public static async Task OidcRefreshThrowsWhenTheProviderRejectsIt()
{
var endpoint = new TokenEndpointStub(TokenEndpointStub.InvalidGrant);
var client = OidcClientAgainst(endpoint);
var store = new FakeCredentialStore("refresh-expired");
var provider = new OidcTokenProvider(client, store);
// Duende's RefreshTokenAsync returns IsError rather than throwing;
// the provider is what turns that into the exception the handler's
// uncaught RefreshAccessTokenAsync call needs to fall back on.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => provider.GetAccessTokenAsync());
Assert.Contains("invalid_grant", ex.Message);
}
// Two 401s arriving close together both call RefreshAccessTokenAsync with
// the same stale refresh token; a rotating provider accepts that token
// exactly once, so an unguarded second call would fail even though the
// first one succeeded. The stub blocks the first call's network request
// open until the second call is queued on the lock, forcing the overlap
// this test exists to catch rather than leaving it to scheduling luck.
public static async Task RefreshLockSerializesConcurrentCallers()
{
var release = new TaskCompletionSource();
var endpoint = new SlowTokenEndpointStub(release);
var client = OidcClientAgainst(endpoint);
var store = new FakeCredentialStore("refresh-1");
var provider = new OidcTokenProvider(client, store);
var first = provider.RefreshAccessTokenAsync();
var second = provider.RefreshAccessTokenAsync();
await Task.Delay(20); // let the second call reach the lock before releasing the first
release.SetResult();
var tokens = await Task.WhenAll(first, second);
Assert.Equal(1, endpoint.Calls); // the lock reused the first refresh instead of racing a second
Assert.Equal(["fresh-access-token", "fresh-access-token"], tokens);
}
public static async Task RegistrationWiresTheHandlerToTheProvider()
{
var builder = Host.CreateApplicationBuilder();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["ApiBaseUrl"] = "https://api.example.invalid/" });
builder.Logging.ClearProviders();
Register(builder);
builder.Services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new SequencedHandler(HttpStatusCode.OK)));
using var host = builder.Build();
// Nothing here has ever signed in, so the chain the registration
// wires up - typed client, BearerTokenHandler, MsalTokenProvider -
// reaches the same local, network-free failure the first claim
// above proves directly; a wiring mistake would fail differently,
// with a DI resolution error instead of this one.
var apiClient = host.Services.GetRequiredService<IWidgetApiClient>();
await Assert.ThrowsAsync<MsalUiRequiredException>(() => apiClient.GetWidgetsAsync());
}
static void Register(HostApplicationBuilder builder)
{
builder.Services.AddSingleton<IPublicClientApplication>(_ =>
PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
.WithDefaultRedirectUri()
.Build());
builder.Services.AddSingleton<ITokenProvider>(sp => new MsalTokenProvider(
sp.GetRequiredService<IPublicClientApplication>(),
scopes: ["api://your-api/access_as_user"]));
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>();
}
// A discovery-free OidcClient: ProviderInformation is supplied directly
// instead of fetched from Authority/.well-known, and BackchannelHandler
// is the stub, so RefreshTokenAsync's only HTTP call goes there.
static OidcClient OidcClientAgainst(HttpMessageHandler tokenEndpoint) => new(new OidcClientOptions
{
Authority = "https://your-authority",
ClientId = "your-client-id",
RedirectUri = "http://127.0.0.1:7890/",
Scope = "api", // no "openid": no id_token comes back, so there is no signature to validate
Browser = new SystemBrowser(),
BackchannelHandler = tokenEndpoint,
ProviderInformation = new ProviderInformation
{
IssuerName = "https://your-authority",
TokenEndpoint = "https://your-authority/connect/token",
AuthorizeEndpoint = "https://your-authority/connect/authorize",
KeySet = new JsonWebKeySet(), // no "openid" scope: no id_token comes back to check it against
},
});
}
static class MsalSignIn
{
// app is the one long-lived PublicClientApplication built once at
// startup ("Registering the pieces in DI" below), not a new one per
// call: a second instance starts with an empty cache of its own.
public static async Task<string> AcquireTokenAsync(IPublicClientApplication app)
{
string[] scopes = ["api://your-api/access_as_user"];
var accounts = await app.GetAccountsAsync();
AuthenticationResult result;
try
{
result = await app.AcquireTokenSilent(scopes, accounts.FirstOrDefault()).ExecuteAsync();
}
catch (MsalUiRequiredException)
{
result = await app.AcquireTokenInteractive(scopes).ExecuteAsync();
}
return result.AccessToken;
}
}
static class OidcSignIn
{
public static async Task<string?> LoginAsync()
{
var options = new OidcClientOptions
{
Authority = "https://your-authority",
ClientId = "your-client-id",
RedirectUri = "http://127.0.0.1:7890/",
Scope = "openid profile api offline_access", // offline_access: without it most providers issue no refresh token
Browser = new SystemBrowser(), // loopback HttpListener browser
};
var client = new OidcClient(options);
var result = await client.LoginAsync();
return result.IsError ? null : result.AccessToken;
}
}
// A minimal stand-in for the loopback-listener IBrowser a real desktop app
// registers: enough for the sign-in snippets above to compile, never
// exercised, since invoking it would open a real OS browser.
sealed class SystemBrowser : IBrowser
{
public Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("opens the OS browser and listens on the loopback port; not invoked by this file");
}
public sealed class BearerTokenHandler(ITokenProvider tokens) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Authorization = new("Bearer", await tokens.GetAccessTokenAsync(cancellationToken));
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose(); // replaced below; leaving it undisposed leaks its connection
request.Headers.Authorization = new("Bearer", await tokens.RefreshAccessTokenAsync(cancellationToken));
response = await base.SendAsync(request, cancellationToken);
}
return response;
}
}
public interface ITokenProvider
{
Task<string> GetAccessTokenAsync(CancellationToken ct = default);
Task<string> RefreshAccessTokenAsync(CancellationToken ct = default);
Task SignOutAsync(CancellationToken ct = default);
}
public sealed class MsalTokenProvider(IPublicClientApplication app, string[] scopes) : ITokenProvider
{
public async Task<string> GetAccessTokenAsync(CancellationToken ct = default)
{
var account = (await app.GetAccountsAsync()).FirstOrDefault();
var result = await app.AcquireTokenSilent(scopes, account).ExecuteAsync(ct);
return result.AccessToken;
}
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var account = (await app.GetAccountsAsync()).FirstOrDefault();
var result = await app.AcquireTokenSilent(scopes, account)
.WithForceRefresh(true)
.ExecuteAsync(ct);
return result.AccessToken;
}
public async Task SignOutAsync(CancellationToken ct = default)
{
foreach (var account in await app.GetAccountsAsync())
{
await app.RemoveAsync(account);
}
}
}
public interface ICredentialStore
{
Task<string?> GetRefreshTokenAsync(CancellationToken ct = default);
Task SaveRefreshTokenAsync(string? refreshToken, CancellationToken ct = default);
Task DeleteRefreshTokenAsync(CancellationToken ct = default);
}
public sealed class OidcTokenProvider(OidcClient client, ICredentialStore store) : ITokenProvider
{
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private string? _accessToken;
public async Task<string> GetAccessTokenAsync(CancellationToken ct = default) =>
_accessToken ?? await RefreshAccessTokenAsync(ct);
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var beforeLock = _accessToken;
await _refreshLock.WaitAsync(ct);
try
{
// Another caller already rotated the refresh token while this one
// waited on the lock; reusing its result instead of sending the
// now-stale refresh token to the server a second time.
if (_accessToken is not null && _accessToken != beforeLock)
{
return _accessToken;
}
var refreshToken = await store.GetRefreshTokenAsync(ct);
var result = await client.RefreshTokenAsync(refreshToken, cancellationToken: ct);
if (result.IsError)
{
throw new InvalidOperationException(result.Error);
}
await store.SaveRefreshTokenAsync(result.RefreshToken, ct);
return _accessToken = result.AccessToken;
}
finally
{
_refreshLock.Release();
}
}
public async Task SignOutAsync(CancellationToken ct = default)
{
_accessToken = null;
await store.DeleteRefreshTokenAsync(ct);
}
}
sealed class TestTokenProvider : ITokenProvider
{
public int Refreshes { get; private set; }
public Task<string> GetAccessTokenAsync(CancellationToken ct = default) => Task.FromResult("token-1");
public Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
Refreshes++;
return Task.FromResult("token-2");
}
public Task SignOutAsync(CancellationToken ct = default) => Task.CompletedTask;
}
sealed class SequencedHandler(params HttpStatusCode[] statuses) : HttpMessageHandler
{
private int _next;
public List<string?> SeenAuthorization { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
SeenAuthorization.Add(request.Headers.Authorization?.Parameter);
var status = statuses[Math.Min(_next++, statuses.Length - 1)];
return Task.FromResult(new HttpResponseMessage(status));
}
}
sealed class FakeCredentialStore(string? refreshToken) : ICredentialStore
{
public string? Saved { get; private set; }
public Task<string?> GetRefreshTokenAsync(CancellationToken ct = default) => Task.FromResult(refreshToken);
public Task SaveRefreshTokenAsync(string? token, CancellationToken ct = default)
{
Saved = token;
return Task.CompletedTask;
}
public Task DeleteRefreshTokenAsync(CancellationToken ct = default)
{
Saved = null;
return Task.CompletedTask;
}
}
sealed class TokenEndpointStub(string body) : HttpMessageHandler
{
public const string Success = """{"access_token":"fresh-access-token","refresh_token":"fresh-refresh-token","expires_in":3600,"token_type":"Bearer"}""";
public const string InvalidGrant = """{"error":"invalid_grant","error_description":"refresh token expired or revoked"}""";
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") });
}
// Holds its one call open until the test releases it, so a second caller has
// time to queue on OidcTokenProvider's lock instead of racing it by luck.
sealed class SlowTokenEndpointStub(TaskCompletionSource release) : HttpMessageHandler
{
public int Calls;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref Calls);
await release.Task;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(TokenEndpointStub.Success, System.Text.Encoding.UTF8, "application/json"),
};
}
}

The handler disposes the 401 response before overwriting response with the retry’s: an HttpResponseMessage holds the connection its content streams from, and leaving the first one unreferenced without disposing it leaks that connection until finalization catches up. The retry resends the same HttpRequestMessage, which only works because the request here has no body: a GET has nothing to resend. A request with a body needs a second look before this pattern applies to it - StringContent, ByteArrayContent and JsonContent all buffer into memory up front and re-serialize from that buffer, so resending one of those is safe, but StreamContent over a non-seekable stream (a file upload, say) is read to its end by the first send and has nothing left to give the second; even a seekable stream needs to be rewound to position zero by hand first, since nothing here does that automatically. A client that must retry a streamed body either buffers it into one of the safe content types before the first send or seeks the stream back before the retry.

ITokenProvider here is application code, not a library type: it wraps whichever library issued the token behind one interface the handler doesn’t need to know about, and it is what makes the handler above testable without a real sign-in. Besides the two methods the handler calls it also carries a SignOutAsync, which “How to let the user log out” below covers:

examples/dotnet/jwt-authentication-desktop-client.cs
public interface ITokenProvider
{
Task<string> GetAccessTokenAsync(CancellationToken ct = default);
Task<string> RefreshAccessTokenAsync(CancellationToken ct = default);
Task SignOutAsync(CancellationToken ct = default);
}

compiled and run by the build

Show the whole file
examples/dotnet/jwt-authentication-desktop-client.cs
#:package Microsoft.Identity.Client@4.90.0
#:package Duende.IdentityModel.OidcClient@7.1.0
#:package Microsoft.Extensions.Hosting@10.0.12
#:package Microsoft.Extensions.Http@10.0.12
#:package xunit.assert@2.9.3
#:project shared/WidgetApi
// PKCE sign-in, token storage and the 401 retry for a desktop client. The
// marked regions are shown on the page; the rest proves each claim without
// a real browser or a real identity provider: MSAL's own local cache proves
// the silent-then-interactive fallback is genuinely needed, and a stub
// token endpoint stands in for the identity provider's token route.
// Interactive sign-in itself - AcquireTokenInteractive, OidcClient.LoginAsync
// - needs a real browser a headless run has none of, so those two calls are
// shown and compiled here but never invoked; everything reachable without
// one is exercised. A failed Assert throws, and the run exits non-zero.
using System.Net;
using System.Net.Http.Headers;
using Duende.IdentityModel.OidcClient;
using Duende.IdentityModel.OidcClient.Browser;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Client;
using JsonWebKeySet = Duende.IdentityModel.Jwk.JsonWebKeySet;
using WidgetApi;
using Xunit;
await Claims.SilentAcquireNeedsACachedAccountBeforeItTriesTheNetwork();
await Claims.SignOutHasNothingToRemoveOnAFreshCache();
await Claims.HandlerAttachesTheTokenAndRetriesOnceOn401();
await Claims.HandlerDoesNotRetryASecondTime();
await Claims.OidcRefreshTradesTheRefreshTokenForAnAccessToken();
await Claims.OidcRefreshThrowsWhenTheProviderRejectsIt();
await Claims.RefreshLockSerializesConcurrentCallers();
await Claims.RegistrationWiresTheHandlerToTheProvider();
Console.WriteLine("jwt-authentication-desktop-client: all claims hold");
static class Claims
{
// A cached-account-less PublicClientApplication never reaches the
// network: AcquireTokenSilent fails locally the moment it has no
// account to look up, which is exactly the signal the catch below
// exists to react to.
public static async Task SilentAcquireNeedsACachedAccountBeforeItTriesTheNetwork()
{
var app = PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
.WithDefaultRedirectUri()
.Build();
Assert.Empty(await app.GetAccountsAsync());
var provider = new MsalTokenProvider(app, ["api://your-api/access_as_user"]);
await Assert.ThrowsAsync<MsalUiRequiredException>(() => provider.GetAccessTokenAsync());
await Assert.ThrowsAsync<MsalUiRequiredException>(() => provider.RefreshAccessTokenAsync());
}
public static async Task SignOutHasNothingToRemoveOnAFreshCache()
{
var app = PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
.WithDefaultRedirectUri()
.Build();
var provider = new MsalTokenProvider(app, ["api://your-api/access_as_user"]);
await provider.SignOutAsync(); // loops over zero cached accounts; throws nothing
Assert.Empty(await app.GetAccountsAsync());
}
public static async Task HandlerAttachesTheTokenAndRetriesOnceOn401()
{
var tokens = new TestTokenProvider();
var stub = new SequencedHandler(HttpStatusCode.Unauthorized, HttpStatusCode.OK);
var handler = new BearerTokenHandler(tokens) { InnerHandler = stub };
using var client = new HttpClient(handler);
var response = await client.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(["token-1", "token-2"], stub.SeenAuthorization);
Assert.Equal(1, tokens.Refreshes);
}
public static async Task HandlerDoesNotRetryASecondTime()
{
var tokens = new TestTokenProvider();
var stub = new SequencedHandler(HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized);
var handler = new BearerTokenHandler(tokens) { InnerHandler = stub };
using var client = new HttpClient(handler);
var response = await client.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); // still 401: no second retry
Assert.Equal(2, stub.SeenAuthorization.Count);
Assert.Equal(1, tokens.Refreshes);
}
public static async Task OidcRefreshTradesTheRefreshTokenForAnAccessToken()
{
var endpoint = new TokenEndpointStub(TokenEndpointStub.Success);
var client = OidcClientAgainst(endpoint);
var store = new FakeCredentialStore("refresh-1");
var provider = new OidcTokenProvider(client, store);
var accessToken = await provider.GetAccessTokenAsync();
Assert.Equal("fresh-access-token", accessToken);
Assert.Equal("fresh-refresh-token", store.Saved); // rotated refresh token is persisted
}
public static async Task OidcRefreshThrowsWhenTheProviderRejectsIt()
{
var endpoint = new TokenEndpointStub(TokenEndpointStub.InvalidGrant);
var client = OidcClientAgainst(endpoint);
var store = new FakeCredentialStore("refresh-expired");
var provider = new OidcTokenProvider(client, store);
// Duende's RefreshTokenAsync returns IsError rather than throwing;
// the provider is what turns that into the exception the handler's
// uncaught RefreshAccessTokenAsync call needs to fall back on.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => provider.GetAccessTokenAsync());
Assert.Contains("invalid_grant", ex.Message);
}
// Two 401s arriving close together both call RefreshAccessTokenAsync with
// the same stale refresh token; a rotating provider accepts that token
// exactly once, so an unguarded second call would fail even though the
// first one succeeded. The stub blocks the first call's network request
// open until the second call is queued on the lock, forcing the overlap
// this test exists to catch rather than leaving it to scheduling luck.
public static async Task RefreshLockSerializesConcurrentCallers()
{
var release = new TaskCompletionSource();
var endpoint = new SlowTokenEndpointStub(release);
var client = OidcClientAgainst(endpoint);
var store = new FakeCredentialStore("refresh-1");
var provider = new OidcTokenProvider(client, store);
var first = provider.RefreshAccessTokenAsync();
var second = provider.RefreshAccessTokenAsync();
await Task.Delay(20); // let the second call reach the lock before releasing the first
release.SetResult();
var tokens = await Task.WhenAll(first, second);
Assert.Equal(1, endpoint.Calls); // the lock reused the first refresh instead of racing a second
Assert.Equal(["fresh-access-token", "fresh-access-token"], tokens);
}
public static async Task RegistrationWiresTheHandlerToTheProvider()
{
var builder = Host.CreateApplicationBuilder();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["ApiBaseUrl"] = "https://api.example.invalid/" });
builder.Logging.ClearProviders();
Register(builder);
builder.Services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new SequencedHandler(HttpStatusCode.OK)));
using var host = builder.Build();
// Nothing here has ever signed in, so the chain the registration
// wires up - typed client, BearerTokenHandler, MsalTokenProvider -
// reaches the same local, network-free failure the first claim
// above proves directly; a wiring mistake would fail differently,
// with a DI resolution error instead of this one.
var apiClient = host.Services.GetRequiredService<IWidgetApiClient>();
await Assert.ThrowsAsync<MsalUiRequiredException>(() => apiClient.GetWidgetsAsync());
}
static void Register(HostApplicationBuilder builder)
{
builder.Services.AddSingleton<IPublicClientApplication>(_ =>
PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
.WithDefaultRedirectUri()
.Build());
builder.Services.AddSingleton<ITokenProvider>(sp => new MsalTokenProvider(
sp.GetRequiredService<IPublicClientApplication>(),
scopes: ["api://your-api/access_as_user"]));
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>();
}
// A discovery-free OidcClient: ProviderInformation is supplied directly
// instead of fetched from Authority/.well-known, and BackchannelHandler
// is the stub, so RefreshTokenAsync's only HTTP call goes there.
static OidcClient OidcClientAgainst(HttpMessageHandler tokenEndpoint) => new(new OidcClientOptions
{
Authority = "https://your-authority",
ClientId = "your-client-id",
RedirectUri = "http://127.0.0.1:7890/",
Scope = "api", // no "openid": no id_token comes back, so there is no signature to validate
Browser = new SystemBrowser(),
BackchannelHandler = tokenEndpoint,
ProviderInformation = new ProviderInformation
{
IssuerName = "https://your-authority",
TokenEndpoint = "https://your-authority/connect/token",
AuthorizeEndpoint = "https://your-authority/connect/authorize",
KeySet = new JsonWebKeySet(), // no "openid" scope: no id_token comes back to check it against
},
});
}
static class MsalSignIn
{
// app is the one long-lived PublicClientApplication built once at
// startup ("Registering the pieces in DI" below), not a new one per
// call: a second instance starts with an empty cache of its own.
public static async Task<string> AcquireTokenAsync(IPublicClientApplication app)
{
string[] scopes = ["api://your-api/access_as_user"];
var accounts = await app.GetAccountsAsync();
AuthenticationResult result;
try
{
result = await app.AcquireTokenSilent(scopes, accounts.FirstOrDefault()).ExecuteAsync();
}
catch (MsalUiRequiredException)
{
result = await app.AcquireTokenInteractive(scopes).ExecuteAsync();
}
return result.AccessToken;
}
}
static class OidcSignIn
{
public static async Task<string?> LoginAsync()
{
var options = new OidcClientOptions
{
Authority = "https://your-authority",
ClientId = "your-client-id",
RedirectUri = "http://127.0.0.1:7890/",
Scope = "openid profile api offline_access", // offline_access: without it most providers issue no refresh token
Browser = new SystemBrowser(), // loopback HttpListener browser
};
var client = new OidcClient(options);
var result = await client.LoginAsync();
return result.IsError ? null : result.AccessToken;
}
}
// A minimal stand-in for the loopback-listener IBrowser a real desktop app
// registers: enough for the sign-in snippets above to compile, never
// exercised, since invoking it would open a real OS browser.
sealed class SystemBrowser : IBrowser
{
public Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("opens the OS browser and listens on the loopback port; not invoked by this file");
}
public sealed class BearerTokenHandler(ITokenProvider tokens) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Authorization = new("Bearer", await tokens.GetAccessTokenAsync(cancellationToken));
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose(); // replaced below; leaving it undisposed leaks its connection
request.Headers.Authorization = new("Bearer", await tokens.RefreshAccessTokenAsync(cancellationToken));
response = await base.SendAsync(request, cancellationToken);
}
return response;
}
}
public interface ITokenProvider
{
Task<string> GetAccessTokenAsync(CancellationToken ct = default);
Task<string> RefreshAccessTokenAsync(CancellationToken ct = default);
Task SignOutAsync(CancellationToken ct = default);
}
public sealed class MsalTokenProvider(IPublicClientApplication app, string[] scopes) : ITokenProvider
{
public async Task<string> GetAccessTokenAsync(CancellationToken ct = default)
{
var account = (await app.GetAccountsAsync()).FirstOrDefault();
var result = await app.AcquireTokenSilent(scopes, account).ExecuteAsync(ct);
return result.AccessToken;
}
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var account = (await app.GetAccountsAsync()).FirstOrDefault();
var result = await app.AcquireTokenSilent(scopes, account)
.WithForceRefresh(true)
.ExecuteAsync(ct);
return result.AccessToken;
}
public async Task SignOutAsync(CancellationToken ct = default)
{
foreach (var account in await app.GetAccountsAsync())
{
await app.RemoveAsync(account);
}
}
}
public interface ICredentialStore
{
Task<string?> GetRefreshTokenAsync(CancellationToken ct = default);
Task SaveRefreshTokenAsync(string? refreshToken, CancellationToken ct = default);
Task DeleteRefreshTokenAsync(CancellationToken ct = default);
}
public sealed class OidcTokenProvider(OidcClient client, ICredentialStore store) : ITokenProvider
{
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private string? _accessToken;
public async Task<string> GetAccessTokenAsync(CancellationToken ct = default) =>
_accessToken ?? await RefreshAccessTokenAsync(ct);
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var beforeLock = _accessToken;
await _refreshLock.WaitAsync(ct);
try
{
// Another caller already rotated the refresh token while this one
// waited on the lock; reusing its result instead of sending the
// now-stale refresh token to the server a second time.
if (_accessToken is not null && _accessToken != beforeLock)
{
return _accessToken;
}
var refreshToken = await store.GetRefreshTokenAsync(ct);
var result = await client.RefreshTokenAsync(refreshToken, cancellationToken: ct);
if (result.IsError)
{
throw new InvalidOperationException(result.Error);
}
await store.SaveRefreshTokenAsync(result.RefreshToken, ct);
return _accessToken = result.AccessToken;
}
finally
{
_refreshLock.Release();
}
}
public async Task SignOutAsync(CancellationToken ct = default)
{
_accessToken = null;
await store.DeleteRefreshTokenAsync(ct);
}
}
sealed class TestTokenProvider : ITokenProvider
{
public int Refreshes { get; private set; }
public Task<string> GetAccessTokenAsync(CancellationToken ct = default) => Task.FromResult("token-1");
public Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
Refreshes++;
return Task.FromResult("token-2");
}
public Task SignOutAsync(CancellationToken ct = default) => Task.CompletedTask;
}
sealed class SequencedHandler(params HttpStatusCode[] statuses) : HttpMessageHandler
{
private int _next;
public List<string?> SeenAuthorization { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
SeenAuthorization.Add(request.Headers.Authorization?.Parameter);
var status = statuses[Math.Min(_next++, statuses.Length - 1)];
return Task.FromResult(new HttpResponseMessage(status));
}
}
sealed class FakeCredentialStore(string? refreshToken) : ICredentialStore
{
public string? Saved { get; private set; }
public Task<string?> GetRefreshTokenAsync(CancellationToken ct = default) => Task.FromResult(refreshToken);
public Task SaveRefreshTokenAsync(string? token, CancellationToken ct = default)
{
Saved = token;
return Task.CompletedTask;
}
public Task DeleteRefreshTokenAsync(CancellationToken ct = default)
{
Saved = null;
return Task.CompletedTask;
}
}
sealed class TokenEndpointStub(string body) : HttpMessageHandler
{
public const string Success = """{"access_token":"fresh-access-token","refresh_token":"fresh-refresh-token","expires_in":3600,"token_type":"Bearer"}""";
public const string InvalidGrant = """{"error":"invalid_grant","error_description":"refresh token expired or revoked"}""";
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") });
}
// Holds its one call open until the test releases it, so a second caller has
// time to queue on OidcTokenProvider's lock instead of racing it by luck.
sealed class SlowTokenEndpointStub(TaskCompletionSource release) : HttpMessageHandler
{
public int Calls;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref Calls);
await release.Task;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(TokenEndpointStub.Success, System.Text.Encoding.UTF8, "application/json"),
};
}
}

An MSAL-backed implementation calls AcquireTokenSilent for both GetAccessTokenAsync and RefreshAccessTokenAsync, since MSAL’s own cache already knows whether the cached access token has expired and trades the refresh token for a new one behind the scenes; RefreshAccessTokenAsync only needs WithForceRefresh(true) to skip trusting a cached token the API just rejected with a 401 even though it looks unexpired to MSAL:

examples/dotnet/jwt-authentication-desktop-client.cs
public sealed class MsalTokenProvider(IPublicClientApplication app, string[] scopes) : ITokenProvider
{
public async Task<string> GetAccessTokenAsync(CancellationToken ct = default)
{
var account = (await app.GetAccountsAsync()).FirstOrDefault();
var result = await app.AcquireTokenSilent(scopes, account).ExecuteAsync(ct);
return result.AccessToken;
}
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var account = (await app.GetAccountsAsync()).FirstOrDefault();
var result = await app.AcquireTokenSilent(scopes, account)
.WithForceRefresh(true)
.ExecuteAsync(ct);
return result.AccessToken;
}
public async Task SignOutAsync(CancellationToken ct = default)
{
foreach (var account in await app.GetAccountsAsync())
{
await app.RemoveAsync(account);
}
}
}

compiled and run by the build

Show the whole file
examples/dotnet/jwt-authentication-desktop-client.cs
#:package Microsoft.Identity.Client@4.90.0
#:package Duende.IdentityModel.OidcClient@7.1.0
#:package Microsoft.Extensions.Hosting@10.0.12
#:package Microsoft.Extensions.Http@10.0.12
#:package xunit.assert@2.9.3
#:project shared/WidgetApi
// PKCE sign-in, token storage and the 401 retry for a desktop client. The
// marked regions are shown on the page; the rest proves each claim without
// a real browser or a real identity provider: MSAL's own local cache proves
// the silent-then-interactive fallback is genuinely needed, and a stub
// token endpoint stands in for the identity provider's token route.
// Interactive sign-in itself - AcquireTokenInteractive, OidcClient.LoginAsync
// - needs a real browser a headless run has none of, so those two calls are
// shown and compiled here but never invoked; everything reachable without
// one is exercised. A failed Assert throws, and the run exits non-zero.
using System.Net;
using System.Net.Http.Headers;
using Duende.IdentityModel.OidcClient;
using Duende.IdentityModel.OidcClient.Browser;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Client;
using JsonWebKeySet = Duende.IdentityModel.Jwk.JsonWebKeySet;
using WidgetApi;
using Xunit;
await Claims.SilentAcquireNeedsACachedAccountBeforeItTriesTheNetwork();
await Claims.SignOutHasNothingToRemoveOnAFreshCache();
await Claims.HandlerAttachesTheTokenAndRetriesOnceOn401();
await Claims.HandlerDoesNotRetryASecondTime();
await Claims.OidcRefreshTradesTheRefreshTokenForAnAccessToken();
await Claims.OidcRefreshThrowsWhenTheProviderRejectsIt();
await Claims.RefreshLockSerializesConcurrentCallers();
await Claims.RegistrationWiresTheHandlerToTheProvider();
Console.WriteLine("jwt-authentication-desktop-client: all claims hold");
static class Claims
{
// A cached-account-less PublicClientApplication never reaches the
// network: AcquireTokenSilent fails locally the moment it has no
// account to look up, which is exactly the signal the catch below
// exists to react to.
public static async Task SilentAcquireNeedsACachedAccountBeforeItTriesTheNetwork()
{
var app = PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
.WithDefaultRedirectUri()
.Build();
Assert.Empty(await app.GetAccountsAsync());
var provider = new MsalTokenProvider(app, ["api://your-api/access_as_user"]);
await Assert.ThrowsAsync<MsalUiRequiredException>(() => provider.GetAccessTokenAsync());
await Assert.ThrowsAsync<MsalUiRequiredException>(() => provider.RefreshAccessTokenAsync());
}
public static async Task SignOutHasNothingToRemoveOnAFreshCache()
{
var app = PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
.WithDefaultRedirectUri()
.Build();
var provider = new MsalTokenProvider(app, ["api://your-api/access_as_user"]);
await provider.SignOutAsync(); // loops over zero cached accounts; throws nothing
Assert.Empty(await app.GetAccountsAsync());
}
public static async Task HandlerAttachesTheTokenAndRetriesOnceOn401()
{
var tokens = new TestTokenProvider();
var stub = new SequencedHandler(HttpStatusCode.Unauthorized, HttpStatusCode.OK);
var handler = new BearerTokenHandler(tokens) { InnerHandler = stub };
using var client = new HttpClient(handler);
var response = await client.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(["token-1", "token-2"], stub.SeenAuthorization);
Assert.Equal(1, tokens.Refreshes);
}
public static async Task HandlerDoesNotRetryASecondTime()
{
var tokens = new TestTokenProvider();
var stub = new SequencedHandler(HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized);
var handler = new BearerTokenHandler(tokens) { InnerHandler = stub };
using var client = new HttpClient(handler);
var response = await client.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); // still 401: no second retry
Assert.Equal(2, stub.SeenAuthorization.Count);
Assert.Equal(1, tokens.Refreshes);
}
public static async Task OidcRefreshTradesTheRefreshTokenForAnAccessToken()
{
var endpoint = new TokenEndpointStub(TokenEndpointStub.Success);
var client = OidcClientAgainst(endpoint);
var store = new FakeCredentialStore("refresh-1");
var provider = new OidcTokenProvider(client, store);
var accessToken = await provider.GetAccessTokenAsync();
Assert.Equal("fresh-access-token", accessToken);
Assert.Equal("fresh-refresh-token", store.Saved); // rotated refresh token is persisted
}
public static async Task OidcRefreshThrowsWhenTheProviderRejectsIt()
{
var endpoint = new TokenEndpointStub(TokenEndpointStub.InvalidGrant);
var client = OidcClientAgainst(endpoint);
var store = new FakeCredentialStore("refresh-expired");
var provider = new OidcTokenProvider(client, store);
// Duende's RefreshTokenAsync returns IsError rather than throwing;
// the provider is what turns that into the exception the handler's
// uncaught RefreshAccessTokenAsync call needs to fall back on.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => provider.GetAccessTokenAsync());
Assert.Contains("invalid_grant", ex.Message);
}
// Two 401s arriving close together both call RefreshAccessTokenAsync with
// the same stale refresh token; a rotating provider accepts that token
// exactly once, so an unguarded second call would fail even though the
// first one succeeded. The stub blocks the first call's network request
// open until the second call is queued on the lock, forcing the overlap
// this test exists to catch rather than leaving it to scheduling luck.
public static async Task RefreshLockSerializesConcurrentCallers()
{
var release = new TaskCompletionSource();
var endpoint = new SlowTokenEndpointStub(release);
var client = OidcClientAgainst(endpoint);
var store = new FakeCredentialStore("refresh-1");
var provider = new OidcTokenProvider(client, store);
var first = provider.RefreshAccessTokenAsync();
var second = provider.RefreshAccessTokenAsync();
await Task.Delay(20); // let the second call reach the lock before releasing the first
release.SetResult();
var tokens = await Task.WhenAll(first, second);
Assert.Equal(1, endpoint.Calls); // the lock reused the first refresh instead of racing a second
Assert.Equal(["fresh-access-token", "fresh-access-token"], tokens);
}
public static async Task RegistrationWiresTheHandlerToTheProvider()
{
var builder = Host.CreateApplicationBuilder();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["ApiBaseUrl"] = "https://api.example.invalid/" });
builder.Logging.ClearProviders();
Register(builder);
builder.Services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new SequencedHandler(HttpStatusCode.OK)));
using var host = builder.Build();
// Nothing here has ever signed in, so the chain the registration
// wires up - typed client, BearerTokenHandler, MsalTokenProvider -
// reaches the same local, network-free failure the first claim
// above proves directly; a wiring mistake would fail differently,
// with a DI resolution error instead of this one.
var apiClient = host.Services.GetRequiredService<IWidgetApiClient>();
await Assert.ThrowsAsync<MsalUiRequiredException>(() => apiClient.GetWidgetsAsync());
}
static void Register(HostApplicationBuilder builder)
{
builder.Services.AddSingleton<IPublicClientApplication>(_ =>
PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
.WithDefaultRedirectUri()
.Build());
builder.Services.AddSingleton<ITokenProvider>(sp => new MsalTokenProvider(
sp.GetRequiredService<IPublicClientApplication>(),
scopes: ["api://your-api/access_as_user"]));
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>();
}
// A discovery-free OidcClient: ProviderInformation is supplied directly
// instead of fetched from Authority/.well-known, and BackchannelHandler
// is the stub, so RefreshTokenAsync's only HTTP call goes there.
static OidcClient OidcClientAgainst(HttpMessageHandler tokenEndpoint) => new(new OidcClientOptions
{
Authority = "https://your-authority",
ClientId = "your-client-id",
RedirectUri = "http://127.0.0.1:7890/",
Scope = "api", // no "openid": no id_token comes back, so there is no signature to validate
Browser = new SystemBrowser(),
BackchannelHandler = tokenEndpoint,
ProviderInformation = new ProviderInformation
{
IssuerName = "https://your-authority",
TokenEndpoint = "https://your-authority/connect/token",
AuthorizeEndpoint = "https://your-authority/connect/authorize",
KeySet = new JsonWebKeySet(), // no "openid" scope: no id_token comes back to check it against
},
});
}
static class MsalSignIn
{
// app is the one long-lived PublicClientApplication built once at
// startup ("Registering the pieces in DI" below), not a new one per
// call: a second instance starts with an empty cache of its own.
public static async Task<string> AcquireTokenAsync(IPublicClientApplication app)
{
string[] scopes = ["api://your-api/access_as_user"];
var accounts = await app.GetAccountsAsync();
AuthenticationResult result;
try
{
result = await app.AcquireTokenSilent(scopes, accounts.FirstOrDefault()).ExecuteAsync();
}
catch (MsalUiRequiredException)
{
result = await app.AcquireTokenInteractive(scopes).ExecuteAsync();
}
return result.AccessToken;
}
}
static class OidcSignIn
{
public static async Task<string?> LoginAsync()
{
var options = new OidcClientOptions
{
Authority = "https://your-authority",
ClientId = "your-client-id",
RedirectUri = "http://127.0.0.1:7890/",
Scope = "openid profile api offline_access", // offline_access: without it most providers issue no refresh token
Browser = new SystemBrowser(), // loopback HttpListener browser
};
var client = new OidcClient(options);
var result = await client.LoginAsync();
return result.IsError ? null : result.AccessToken;
}
}
// A minimal stand-in for the loopback-listener IBrowser a real desktop app
// registers: enough for the sign-in snippets above to compile, never
// exercised, since invoking it would open a real OS browser.
sealed class SystemBrowser : IBrowser
{
public Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("opens the OS browser and listens on the loopback port; not invoked by this file");
}
public sealed class BearerTokenHandler(ITokenProvider tokens) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Authorization = new("Bearer", await tokens.GetAccessTokenAsync(cancellationToken));
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose(); // replaced below; leaving it undisposed leaks its connection
request.Headers.Authorization = new("Bearer", await tokens.RefreshAccessTokenAsync(cancellationToken));
response = await base.SendAsync(request, cancellationToken);
}
return response;
}
}
public interface ITokenProvider
{
Task<string> GetAccessTokenAsync(CancellationToken ct = default);
Task<string> RefreshAccessTokenAsync(CancellationToken ct = default);
Task SignOutAsync(CancellationToken ct = default);
}
public sealed class MsalTokenProvider(IPublicClientApplication app, string[] scopes) : ITokenProvider
{
public async Task<string> GetAccessTokenAsync(CancellationToken ct = default)
{
var account = (await app.GetAccountsAsync()).FirstOrDefault();
var result = await app.AcquireTokenSilent(scopes, account).ExecuteAsync(ct);
return result.AccessToken;
}
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var account = (await app.GetAccountsAsync()).FirstOrDefault();
var result = await app.AcquireTokenSilent(scopes, account)
.WithForceRefresh(true)
.ExecuteAsync(ct);
return result.AccessToken;
}
public async Task SignOutAsync(CancellationToken ct = default)
{
foreach (var account in await app.GetAccountsAsync())
{
await app.RemoveAsync(account);
}
}
}
public interface ICredentialStore
{
Task<string?> GetRefreshTokenAsync(CancellationToken ct = default);
Task SaveRefreshTokenAsync(string? refreshToken, CancellationToken ct = default);
Task DeleteRefreshTokenAsync(CancellationToken ct = default);
}
public sealed class OidcTokenProvider(OidcClient client, ICredentialStore store) : ITokenProvider
{
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private string? _accessToken;
public async Task<string> GetAccessTokenAsync(CancellationToken ct = default) =>
_accessToken ?? await RefreshAccessTokenAsync(ct);
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var beforeLock = _accessToken;
await _refreshLock.WaitAsync(ct);
try
{
// Another caller already rotated the refresh token while this one
// waited on the lock; reusing its result instead of sending the
// now-stale refresh token to the server a second time.
if (_accessToken is not null && _accessToken != beforeLock)
{
return _accessToken;
}
var refreshToken = await store.GetRefreshTokenAsync(ct);
var result = await client.RefreshTokenAsync(refreshToken, cancellationToken: ct);
if (result.IsError)
{
throw new InvalidOperationException(result.Error);
}
await store.SaveRefreshTokenAsync(result.RefreshToken, ct);
return _accessToken = result.AccessToken;
}
finally
{
_refreshLock.Release();
}
}
public async Task SignOutAsync(CancellationToken ct = default)
{
_accessToken = null;
await store.DeleteRefreshTokenAsync(ct);
}
}
sealed class TestTokenProvider : ITokenProvider
{
public int Refreshes { get; private set; }
public Task<string> GetAccessTokenAsync(CancellationToken ct = default) => Task.FromResult("token-1");
public Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
Refreshes++;
return Task.FromResult("token-2");
}
public Task SignOutAsync(CancellationToken ct = default) => Task.CompletedTask;
}
sealed class SequencedHandler(params HttpStatusCode[] statuses) : HttpMessageHandler
{
private int _next;
public List<string?> SeenAuthorization { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
SeenAuthorization.Add(request.Headers.Authorization?.Parameter);
var status = statuses[Math.Min(_next++, statuses.Length - 1)];
return Task.FromResult(new HttpResponseMessage(status));
}
}
sealed class FakeCredentialStore(string? refreshToken) : ICredentialStore
{
public string? Saved { get; private set; }
public Task<string?> GetRefreshTokenAsync(CancellationToken ct = default) => Task.FromResult(refreshToken);
public Task SaveRefreshTokenAsync(string? token, CancellationToken ct = default)
{
Saved = token;
return Task.CompletedTask;
}
public Task DeleteRefreshTokenAsync(CancellationToken ct = default)
{
Saved = null;
return Task.CompletedTask;
}
}
sealed class TokenEndpointStub(string body) : HttpMessageHandler
{
public const string Success = """{"access_token":"fresh-access-token","refresh_token":"fresh-refresh-token","expires_in":3600,"token_type":"Bearer"}""";
public const string InvalidGrant = """{"error":"invalid_grant","error_description":"refresh token expired or revoked"}""";
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") });
}
// Holds its one call open until the test releases it, so a second caller has
// time to queue on OidcTokenProvider's lock instead of racing it by luck.
sealed class SlowTokenEndpointStub(TaskCompletionSource release) : HttpMessageHandler
{
public int Calls;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref Calls);
await release.Task;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(TokenEndpointStub.Success, System.Text.Encoding.UTF8, "application/json"),
};
}
}

An OidcClient-backed implementation has no equivalent silent cache to call into, so it keeps the refresh token itself (from the OS credential store, “Where the tokens live between runs” above) and exchanges it explicitly:

examples/dotnet/jwt-authentication-desktop-client.cs
public sealed class OidcTokenProvider(OidcClient client, ICredentialStore store) : ITokenProvider
{
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private string? _accessToken;
public async Task<string> GetAccessTokenAsync(CancellationToken ct = default) =>
_accessToken ?? await RefreshAccessTokenAsync(ct);
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var beforeLock = _accessToken;
await _refreshLock.WaitAsync(ct);
try
{
// Another caller already rotated the refresh token while this one
// waited on the lock; reusing its result instead of sending the
// now-stale refresh token to the server a second time.
if (_accessToken is not null && _accessToken != beforeLock)
{
return _accessToken;
}
var refreshToken = await store.GetRefreshTokenAsync(ct);
var result = await client.RefreshTokenAsync(refreshToken, cancellationToken: ct);
if (result.IsError)
{
throw new InvalidOperationException(result.Error);
}
await store.SaveRefreshTokenAsync(result.RefreshToken, ct);
return _accessToken = result.AccessToken;
}
finally
{
_refreshLock.Release();
}
}
public async Task SignOutAsync(CancellationToken ct = default)
{
_accessToken = null;
await store.DeleteRefreshTokenAsync(ct);
}
}

compiled and run by the build

Show the whole file
examples/dotnet/jwt-authentication-desktop-client.cs
#:package Microsoft.Identity.Client@4.90.0
#:package Duende.IdentityModel.OidcClient@7.1.0
#:package Microsoft.Extensions.Hosting@10.0.12
#:package Microsoft.Extensions.Http@10.0.12
#:package xunit.assert@2.9.3
#:project shared/WidgetApi
// PKCE sign-in, token storage and the 401 retry for a desktop client. The
// marked regions are shown on the page; the rest proves each claim without
// a real browser or a real identity provider: MSAL's own local cache proves
// the silent-then-interactive fallback is genuinely needed, and a stub
// token endpoint stands in for the identity provider's token route.
// Interactive sign-in itself - AcquireTokenInteractive, OidcClient.LoginAsync
// - needs a real browser a headless run has none of, so those two calls are
// shown and compiled here but never invoked; everything reachable without
// one is exercised. A failed Assert throws, and the run exits non-zero.
using System.Net;
using System.Net.Http.Headers;
using Duende.IdentityModel.OidcClient;
using Duende.IdentityModel.OidcClient.Browser;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Client;
using JsonWebKeySet = Duende.IdentityModel.Jwk.JsonWebKeySet;
using WidgetApi;
using Xunit;
await Claims.SilentAcquireNeedsACachedAccountBeforeItTriesTheNetwork();
await Claims.SignOutHasNothingToRemoveOnAFreshCache();
await Claims.HandlerAttachesTheTokenAndRetriesOnceOn401();
await Claims.HandlerDoesNotRetryASecondTime();
await Claims.OidcRefreshTradesTheRefreshTokenForAnAccessToken();
await Claims.OidcRefreshThrowsWhenTheProviderRejectsIt();
await Claims.RefreshLockSerializesConcurrentCallers();
await Claims.RegistrationWiresTheHandlerToTheProvider();
Console.WriteLine("jwt-authentication-desktop-client: all claims hold");
static class Claims
{
// A cached-account-less PublicClientApplication never reaches the
// network: AcquireTokenSilent fails locally the moment it has no
// account to look up, which is exactly the signal the catch below
// exists to react to.
public static async Task SilentAcquireNeedsACachedAccountBeforeItTriesTheNetwork()
{
var app = PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
.WithDefaultRedirectUri()
.Build();
Assert.Empty(await app.GetAccountsAsync());
var provider = new MsalTokenProvider(app, ["api://your-api/access_as_user"]);
await Assert.ThrowsAsync<MsalUiRequiredException>(() => provider.GetAccessTokenAsync());
await Assert.ThrowsAsync<MsalUiRequiredException>(() => provider.RefreshAccessTokenAsync());
}
public static async Task SignOutHasNothingToRemoveOnAFreshCache()
{
var app = PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
.WithDefaultRedirectUri()
.Build();
var provider = new MsalTokenProvider(app, ["api://your-api/access_as_user"]);
await provider.SignOutAsync(); // loops over zero cached accounts; throws nothing
Assert.Empty(await app.GetAccountsAsync());
}
public static async Task HandlerAttachesTheTokenAndRetriesOnceOn401()
{
var tokens = new TestTokenProvider();
var stub = new SequencedHandler(HttpStatusCode.Unauthorized, HttpStatusCode.OK);
var handler = new BearerTokenHandler(tokens) { InnerHandler = stub };
using var client = new HttpClient(handler);
var response = await client.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(["token-1", "token-2"], stub.SeenAuthorization);
Assert.Equal(1, tokens.Refreshes);
}
public static async Task HandlerDoesNotRetryASecondTime()
{
var tokens = new TestTokenProvider();
var stub = new SequencedHandler(HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized);
var handler = new BearerTokenHandler(tokens) { InnerHandler = stub };
using var client = new HttpClient(handler);
var response = await client.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); // still 401: no second retry
Assert.Equal(2, stub.SeenAuthorization.Count);
Assert.Equal(1, tokens.Refreshes);
}
public static async Task OidcRefreshTradesTheRefreshTokenForAnAccessToken()
{
var endpoint = new TokenEndpointStub(TokenEndpointStub.Success);
var client = OidcClientAgainst(endpoint);
var store = new FakeCredentialStore("refresh-1");
var provider = new OidcTokenProvider(client, store);
var accessToken = await provider.GetAccessTokenAsync();
Assert.Equal("fresh-access-token", accessToken);
Assert.Equal("fresh-refresh-token", store.Saved); // rotated refresh token is persisted
}
public static async Task OidcRefreshThrowsWhenTheProviderRejectsIt()
{
var endpoint = new TokenEndpointStub(TokenEndpointStub.InvalidGrant);
var client = OidcClientAgainst(endpoint);
var store = new FakeCredentialStore("refresh-expired");
var provider = new OidcTokenProvider(client, store);
// Duende's RefreshTokenAsync returns IsError rather than throwing;
// the provider is what turns that into the exception the handler's
// uncaught RefreshAccessTokenAsync call needs to fall back on.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => provider.GetAccessTokenAsync());
Assert.Contains("invalid_grant", ex.Message);
}
// Two 401s arriving close together both call RefreshAccessTokenAsync with
// the same stale refresh token; a rotating provider accepts that token
// exactly once, so an unguarded second call would fail even though the
// first one succeeded. The stub blocks the first call's network request
// open until the second call is queued on the lock, forcing the overlap
// this test exists to catch rather than leaving it to scheduling luck.
public static async Task RefreshLockSerializesConcurrentCallers()
{
var release = new TaskCompletionSource();
var endpoint = new SlowTokenEndpointStub(release);
var client = OidcClientAgainst(endpoint);
var store = new FakeCredentialStore("refresh-1");
var provider = new OidcTokenProvider(client, store);
var first = provider.RefreshAccessTokenAsync();
var second = provider.RefreshAccessTokenAsync();
await Task.Delay(20); // let the second call reach the lock before releasing the first
release.SetResult();
var tokens = await Task.WhenAll(first, second);
Assert.Equal(1, endpoint.Calls); // the lock reused the first refresh instead of racing a second
Assert.Equal(["fresh-access-token", "fresh-access-token"], tokens);
}
public static async Task RegistrationWiresTheHandlerToTheProvider()
{
var builder = Host.CreateApplicationBuilder();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["ApiBaseUrl"] = "https://api.example.invalid/" });
builder.Logging.ClearProviders();
Register(builder);
builder.Services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new SequencedHandler(HttpStatusCode.OK)));
using var host = builder.Build();
// Nothing here has ever signed in, so the chain the registration
// wires up - typed client, BearerTokenHandler, MsalTokenProvider -
// reaches the same local, network-free failure the first claim
// above proves directly; a wiring mistake would fail differently,
// with a DI resolution error instead of this one.
var apiClient = host.Services.GetRequiredService<IWidgetApiClient>();
await Assert.ThrowsAsync<MsalUiRequiredException>(() => apiClient.GetWidgetsAsync());
}
static void Register(HostApplicationBuilder builder)
{
builder.Services.AddSingleton<IPublicClientApplication>(_ =>
PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
.WithDefaultRedirectUri()
.Build());
builder.Services.AddSingleton<ITokenProvider>(sp => new MsalTokenProvider(
sp.GetRequiredService<IPublicClientApplication>(),
scopes: ["api://your-api/access_as_user"]));
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>();
}
// A discovery-free OidcClient: ProviderInformation is supplied directly
// instead of fetched from Authority/.well-known, and BackchannelHandler
// is the stub, so RefreshTokenAsync's only HTTP call goes there.
static OidcClient OidcClientAgainst(HttpMessageHandler tokenEndpoint) => new(new OidcClientOptions
{
Authority = "https://your-authority",
ClientId = "your-client-id",
RedirectUri = "http://127.0.0.1:7890/",
Scope = "api", // no "openid": no id_token comes back, so there is no signature to validate
Browser = new SystemBrowser(),
BackchannelHandler = tokenEndpoint,
ProviderInformation = new ProviderInformation
{
IssuerName = "https://your-authority",
TokenEndpoint = "https://your-authority/connect/token",
AuthorizeEndpoint = "https://your-authority/connect/authorize",
KeySet = new JsonWebKeySet(), // no "openid" scope: no id_token comes back to check it against
},
});
}
static class MsalSignIn
{
// app is the one long-lived PublicClientApplication built once at
// startup ("Registering the pieces in DI" below), not a new one per
// call: a second instance starts with an empty cache of its own.
public static async Task<string> AcquireTokenAsync(IPublicClientApplication app)
{
string[] scopes = ["api://your-api/access_as_user"];
var accounts = await app.GetAccountsAsync();
AuthenticationResult result;
try
{
result = await app.AcquireTokenSilent(scopes, accounts.FirstOrDefault()).ExecuteAsync();
}
catch (MsalUiRequiredException)
{
result = await app.AcquireTokenInteractive(scopes).ExecuteAsync();
}
return result.AccessToken;
}
}
static class OidcSignIn
{
public static async Task<string?> LoginAsync()
{
var options = new OidcClientOptions
{
Authority = "https://your-authority",
ClientId = "your-client-id",
RedirectUri = "http://127.0.0.1:7890/",
Scope = "openid profile api offline_access", // offline_access: without it most providers issue no refresh token
Browser = new SystemBrowser(), // loopback HttpListener browser
};
var client = new OidcClient(options);
var result = await client.LoginAsync();
return result.IsError ? null : result.AccessToken;
}
}
// A minimal stand-in for the loopback-listener IBrowser a real desktop app
// registers: enough for the sign-in snippets above to compile, never
// exercised, since invoking it would open a real OS browser.
sealed class SystemBrowser : IBrowser
{
public Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("opens the OS browser and listens on the loopback port; not invoked by this file");
}
public sealed class BearerTokenHandler(ITokenProvider tokens) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Authorization = new("Bearer", await tokens.GetAccessTokenAsync(cancellationToken));
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose(); // replaced below; leaving it undisposed leaks its connection
request.Headers.Authorization = new("Bearer", await tokens.RefreshAccessTokenAsync(cancellationToken));
response = await base.SendAsync(request, cancellationToken);
}
return response;
}
}
public interface ITokenProvider
{
Task<string> GetAccessTokenAsync(CancellationToken ct = default);
Task<string> RefreshAccessTokenAsync(CancellationToken ct = default);
Task SignOutAsync(CancellationToken ct = default);
}
public sealed class MsalTokenProvider(IPublicClientApplication app, string[] scopes) : ITokenProvider
{
public async Task<string> GetAccessTokenAsync(CancellationToken ct = default)
{
var account = (await app.GetAccountsAsync()).FirstOrDefault();
var result = await app.AcquireTokenSilent(scopes, account).ExecuteAsync(ct);
return result.AccessToken;
}
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var account = (await app.GetAccountsAsync()).FirstOrDefault();
var result = await app.AcquireTokenSilent(scopes, account)
.WithForceRefresh(true)
.ExecuteAsync(ct);
return result.AccessToken;
}
public async Task SignOutAsync(CancellationToken ct = default)
{
foreach (var account in await app.GetAccountsAsync())
{
await app.RemoveAsync(account);
}
}
}
public interface ICredentialStore
{
Task<string?> GetRefreshTokenAsync(CancellationToken ct = default);
Task SaveRefreshTokenAsync(string? refreshToken, CancellationToken ct = default);
Task DeleteRefreshTokenAsync(CancellationToken ct = default);
}
public sealed class OidcTokenProvider(OidcClient client, ICredentialStore store) : ITokenProvider
{
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private string? _accessToken;
public async Task<string> GetAccessTokenAsync(CancellationToken ct = default) =>
_accessToken ?? await RefreshAccessTokenAsync(ct);
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var beforeLock = _accessToken;
await _refreshLock.WaitAsync(ct);
try
{
// Another caller already rotated the refresh token while this one
// waited on the lock; reusing its result instead of sending the
// now-stale refresh token to the server a second time.
if (_accessToken is not null && _accessToken != beforeLock)
{
return _accessToken;
}
var refreshToken = await store.GetRefreshTokenAsync(ct);
var result = await client.RefreshTokenAsync(refreshToken, cancellationToken: ct);
if (result.IsError)
{
throw new InvalidOperationException(result.Error);
}
await store.SaveRefreshTokenAsync(result.RefreshToken, ct);
return _accessToken = result.AccessToken;
}
finally
{
_refreshLock.Release();
}
}
public async Task SignOutAsync(CancellationToken ct = default)
{
_accessToken = null;
await store.DeleteRefreshTokenAsync(ct);
}
}
sealed class TestTokenProvider : ITokenProvider
{
public int Refreshes { get; private set; }
public Task<string> GetAccessTokenAsync(CancellationToken ct = default) => Task.FromResult("token-1");
public Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
Refreshes++;
return Task.FromResult("token-2");
}
public Task SignOutAsync(CancellationToken ct = default) => Task.CompletedTask;
}
sealed class SequencedHandler(params HttpStatusCode[] statuses) : HttpMessageHandler
{
private int _next;
public List<string?> SeenAuthorization { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
SeenAuthorization.Add(request.Headers.Authorization?.Parameter);
var status = statuses[Math.Min(_next++, statuses.Length - 1)];
return Task.FromResult(new HttpResponseMessage(status));
}
}
sealed class FakeCredentialStore(string? refreshToken) : ICredentialStore
{
public string? Saved { get; private set; }
public Task<string?> GetRefreshTokenAsync(CancellationToken ct = default) => Task.FromResult(refreshToken);
public Task SaveRefreshTokenAsync(string? token, CancellationToken ct = default)
{
Saved = token;
return Task.CompletedTask;
}
public Task DeleteRefreshTokenAsync(CancellationToken ct = default)
{
Saved = null;
return Task.CompletedTask;
}
}
sealed class TokenEndpointStub(string body) : HttpMessageHandler
{
public const string Success = """{"access_token":"fresh-access-token","refresh_token":"fresh-refresh-token","expires_in":3600,"token_type":"Bearer"}""";
public const string InvalidGrant = """{"error":"invalid_grant","error_description":"refresh token expired or revoked"}""";
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") });
}
// Holds its one call open until the test releases it, so a second caller has
// time to queue on OidcTokenProvider's lock instead of racing it by luck.
sealed class SlowTokenEndpointStub(TaskCompletionSource release) : HttpMessageHandler
{
public int Calls;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref Calls);
await release.Task;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(TokenEndpointStub.Success, System.Text.Encoding.UTF8, "application/json"),
};
}
}

OidcTokenProvider is registered Singleton (“Registering the pieces in DI” below), so two API calls that both hit a 401 at once both call RefreshAccessTokenAsync on the same instance. Most providers rotate the refresh token on every use and accept the old one exactly once, so an unguarded second call reads the same now-superseded token from the store the first call already replaced and gets invalid_grant back even though the first refresh succeeded a moment earlier - a transient failure that looks identical to a genuinely dead refresh token and sends a signed-in user back to the browser for no reason. The SemaphoreSlim above serializes the two: the second caller blocks until the first finishes, then notices _accessToken already moved past the value it read before waiting and returns that instead of spending the refresh token a second time. Duende.IdentityModel.OidcClient ships an alternative that folds BearerTokenHandler and OidcTokenProvider into one type, RefreshTokenDelegatingHandler: it attaches the access token, sends the request, and on a 401 refreshes and retries once, the same shape this entry hand-rolls, already guarded by its own SemaphoreSlim so two overlapping 401s serialize the same way. It is the better fit for an app with no OS credential store to persist the refresh token to - the tokens it manages live only in memory for the process’s life - but this entry’s tokens survive a restart precisely because OidcTokenProvider.RefreshAccessTokenAsync persists the rotated refresh token to ICredentialStore on every call, which is the one step that handler does not take on the caller’s behalf.

MSAL’s AcquireTokenSilent throws MsalUiRequiredException (or MsalServiceException with an invalid_grant error) directly once the refresh token itself is expired or revoked. OidcClient’s RefreshTokenAsync doesn’t throw on that same failure - it returns a result with IsError set and AccessToken unset - so the provider above has to turn that into an exception itself instead of silently returning a null token. Either way, that exception is the RefreshAccessTokenAsync call the BearerTokenHandler above doesn’t catch: it is the signal to fall back to the interactive browser flow and sign the user in again, not a bug in the provider.

Strung together - the sign-in above, where the tokens land, and the retry a 401 triggers - the whole flow looks like this:

sequenceDiagram
    participant App as Desktop app
    participant Browser as System browser
    participant IdP as Identity provider
    participant Store as OS token store
    participant API as API

    App->>App: create code_verifier, hash to code_challenge
    App->>Browser: open /authorize?code_challenge
    Browser->>IdP: GET /authorize
    IdP-->>Browser: sign-in page
    Browser->>IdP: credentials
    IdP-->>Browser: redirect, code
    Browser->>App: GET 127.0.0.1:port/callback?code
    App->>IdP: POST /token, code + code_verifier
    IdP-->>App: access token, refresh token
    App->>Store: save tokens (DPAPI-encrypted)
    App->>API: BearerTokenHandler: GET /widgets
    API-->>App: 401
    App->>Store: ITokenProvider: read refresh token
    App->>IdP: ITokenProvider: silent refresh
    alt refresh token still valid
        IdP-->>App: new access token
        App->>API: retry GET /widgets
        API-->>App: 200
    else refresh token expired or revoked
        IdP-->>App: invalid_grant
        App->>Browser: fall back: open /authorize again
    end

The retry is the only place the API itself ever appears in the flow: everything left of it is between the app and the identity provider, or the app and its own token store, and the API only ever sees a bearer token, never the code, the verifier or either kind of token exchange that produced it.

Wiring MsalTokenProvider, BearerTokenHandler and the typed HttpClient together follows the same Generic Host that registers the API client itself (dotnet/wpf-rest-api-project-structure), with one rule specific to the handler (dotnet/delegatinghandler-bearer-token explains why): the handler stays Transient so IHttpClientFactory gets a fresh instance every time it recycles the pipeline, but IPublicClientApplication and MsalTokenProvider are Singleton, because neither holds anything scoped to one request.

examples/dotnet/jwt-authentication-desktop-client.cs
static void Register(HostApplicationBuilder builder)
{
builder.Services.AddSingleton<IPublicClientApplication>(_ =>
PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
.WithDefaultRedirectUri()
.Build());
builder.Services.AddSingleton<ITokenProvider>(sp => new MsalTokenProvider(
sp.GetRequiredService<IPublicClientApplication>(),
scopes: ["api://your-api/access_as_user"]));
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>();
}

compiled and run by the build

Show the whole file
examples/dotnet/jwt-authentication-desktop-client.cs
#:package Microsoft.Identity.Client@4.90.0
#:package Duende.IdentityModel.OidcClient@7.1.0
#:package Microsoft.Extensions.Hosting@10.0.12
#:package Microsoft.Extensions.Http@10.0.12
#:package xunit.assert@2.9.3
#:project shared/WidgetApi
// PKCE sign-in, token storage and the 401 retry for a desktop client. The
// marked regions are shown on the page; the rest proves each claim without
// a real browser or a real identity provider: MSAL's own local cache proves
// the silent-then-interactive fallback is genuinely needed, and a stub
// token endpoint stands in for the identity provider's token route.
// Interactive sign-in itself - AcquireTokenInteractive, OidcClient.LoginAsync
// - needs a real browser a headless run has none of, so those two calls are
// shown and compiled here but never invoked; everything reachable without
// one is exercised. A failed Assert throws, and the run exits non-zero.
using System.Net;
using System.Net.Http.Headers;
using Duende.IdentityModel.OidcClient;
using Duende.IdentityModel.OidcClient.Browser;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Client;
using JsonWebKeySet = Duende.IdentityModel.Jwk.JsonWebKeySet;
using WidgetApi;
using Xunit;
await Claims.SilentAcquireNeedsACachedAccountBeforeItTriesTheNetwork();
await Claims.SignOutHasNothingToRemoveOnAFreshCache();
await Claims.HandlerAttachesTheTokenAndRetriesOnceOn401();
await Claims.HandlerDoesNotRetryASecondTime();
await Claims.OidcRefreshTradesTheRefreshTokenForAnAccessToken();
await Claims.OidcRefreshThrowsWhenTheProviderRejectsIt();
await Claims.RefreshLockSerializesConcurrentCallers();
await Claims.RegistrationWiresTheHandlerToTheProvider();
Console.WriteLine("jwt-authentication-desktop-client: all claims hold");
static class Claims
{
// A cached-account-less PublicClientApplication never reaches the
// network: AcquireTokenSilent fails locally the moment it has no
// account to look up, which is exactly the signal the catch below
// exists to react to.
public static async Task SilentAcquireNeedsACachedAccountBeforeItTriesTheNetwork()
{
var app = PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
.WithDefaultRedirectUri()
.Build();
Assert.Empty(await app.GetAccountsAsync());
var provider = new MsalTokenProvider(app, ["api://your-api/access_as_user"]);
await Assert.ThrowsAsync<MsalUiRequiredException>(() => provider.GetAccessTokenAsync());
await Assert.ThrowsAsync<MsalUiRequiredException>(() => provider.RefreshAccessTokenAsync());
}
public static async Task SignOutHasNothingToRemoveOnAFreshCache()
{
var app = PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
.WithDefaultRedirectUri()
.Build();
var provider = new MsalTokenProvider(app, ["api://your-api/access_as_user"]);
await provider.SignOutAsync(); // loops over zero cached accounts; throws nothing
Assert.Empty(await app.GetAccountsAsync());
}
public static async Task HandlerAttachesTheTokenAndRetriesOnceOn401()
{
var tokens = new TestTokenProvider();
var stub = new SequencedHandler(HttpStatusCode.Unauthorized, HttpStatusCode.OK);
var handler = new BearerTokenHandler(tokens) { InnerHandler = stub };
using var client = new HttpClient(handler);
var response = await client.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(["token-1", "token-2"], stub.SeenAuthorization);
Assert.Equal(1, tokens.Refreshes);
}
public static async Task HandlerDoesNotRetryASecondTime()
{
var tokens = new TestTokenProvider();
var stub = new SequencedHandler(HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized);
var handler = new BearerTokenHandler(tokens) { InnerHandler = stub };
using var client = new HttpClient(handler);
var response = await client.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); // still 401: no second retry
Assert.Equal(2, stub.SeenAuthorization.Count);
Assert.Equal(1, tokens.Refreshes);
}
public static async Task OidcRefreshTradesTheRefreshTokenForAnAccessToken()
{
var endpoint = new TokenEndpointStub(TokenEndpointStub.Success);
var client = OidcClientAgainst(endpoint);
var store = new FakeCredentialStore("refresh-1");
var provider = new OidcTokenProvider(client, store);
var accessToken = await provider.GetAccessTokenAsync();
Assert.Equal("fresh-access-token", accessToken);
Assert.Equal("fresh-refresh-token", store.Saved); // rotated refresh token is persisted
}
public static async Task OidcRefreshThrowsWhenTheProviderRejectsIt()
{
var endpoint = new TokenEndpointStub(TokenEndpointStub.InvalidGrant);
var client = OidcClientAgainst(endpoint);
var store = new FakeCredentialStore("refresh-expired");
var provider = new OidcTokenProvider(client, store);
// Duende's RefreshTokenAsync returns IsError rather than throwing;
// the provider is what turns that into the exception the handler's
// uncaught RefreshAccessTokenAsync call needs to fall back on.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => provider.GetAccessTokenAsync());
Assert.Contains("invalid_grant", ex.Message);
}
// Two 401s arriving close together both call RefreshAccessTokenAsync with
// the same stale refresh token; a rotating provider accepts that token
// exactly once, so an unguarded second call would fail even though the
// first one succeeded. The stub blocks the first call's network request
// open until the second call is queued on the lock, forcing the overlap
// this test exists to catch rather than leaving it to scheduling luck.
public static async Task RefreshLockSerializesConcurrentCallers()
{
var release = new TaskCompletionSource();
var endpoint = new SlowTokenEndpointStub(release);
var client = OidcClientAgainst(endpoint);
var store = new FakeCredentialStore("refresh-1");
var provider = new OidcTokenProvider(client, store);
var first = provider.RefreshAccessTokenAsync();
var second = provider.RefreshAccessTokenAsync();
await Task.Delay(20); // let the second call reach the lock before releasing the first
release.SetResult();
var tokens = await Task.WhenAll(first, second);
Assert.Equal(1, endpoint.Calls); // the lock reused the first refresh instead of racing a second
Assert.Equal(["fresh-access-token", "fresh-access-token"], tokens);
}
public static async Task RegistrationWiresTheHandlerToTheProvider()
{
var builder = Host.CreateApplicationBuilder();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["ApiBaseUrl"] = "https://api.example.invalid/" });
builder.Logging.ClearProviders();
Register(builder);
builder.Services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new SequencedHandler(HttpStatusCode.OK)));
using var host = builder.Build();
// Nothing here has ever signed in, so the chain the registration
// wires up - typed client, BearerTokenHandler, MsalTokenProvider -
// reaches the same local, network-free failure the first claim
// above proves directly; a wiring mistake would fail differently,
// with a DI resolution error instead of this one.
var apiClient = host.Services.GetRequiredService<IWidgetApiClient>();
await Assert.ThrowsAsync<MsalUiRequiredException>(() => apiClient.GetWidgetsAsync());
}
static void Register(HostApplicationBuilder builder)
{
builder.Services.AddSingleton<IPublicClientApplication>(_ =>
PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
.WithDefaultRedirectUri()
.Build());
builder.Services.AddSingleton<ITokenProvider>(sp => new MsalTokenProvider(
sp.GetRequiredService<IPublicClientApplication>(),
scopes: ["api://your-api/access_as_user"]));
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>();
}
// A discovery-free OidcClient: ProviderInformation is supplied directly
// instead of fetched from Authority/.well-known, and BackchannelHandler
// is the stub, so RefreshTokenAsync's only HTTP call goes there.
static OidcClient OidcClientAgainst(HttpMessageHandler tokenEndpoint) => new(new OidcClientOptions
{
Authority = "https://your-authority",
ClientId = "your-client-id",
RedirectUri = "http://127.0.0.1:7890/",
Scope = "api", // no "openid": no id_token comes back, so there is no signature to validate
Browser = new SystemBrowser(),
BackchannelHandler = tokenEndpoint,
ProviderInformation = new ProviderInformation
{
IssuerName = "https://your-authority",
TokenEndpoint = "https://your-authority/connect/token",
AuthorizeEndpoint = "https://your-authority/connect/authorize",
KeySet = new JsonWebKeySet(), // no "openid" scope: no id_token comes back to check it against
},
});
}
static class MsalSignIn
{
// app is the one long-lived PublicClientApplication built once at
// startup ("Registering the pieces in DI" below), not a new one per
// call: a second instance starts with an empty cache of its own.
public static async Task<string> AcquireTokenAsync(IPublicClientApplication app)
{
string[] scopes = ["api://your-api/access_as_user"];
var accounts = await app.GetAccountsAsync();
AuthenticationResult result;
try
{
result = await app.AcquireTokenSilent(scopes, accounts.FirstOrDefault()).ExecuteAsync();
}
catch (MsalUiRequiredException)
{
result = await app.AcquireTokenInteractive(scopes).ExecuteAsync();
}
return result.AccessToken;
}
}
static class OidcSignIn
{
public static async Task<string?> LoginAsync()
{
var options = new OidcClientOptions
{
Authority = "https://your-authority",
ClientId = "your-client-id",
RedirectUri = "http://127.0.0.1:7890/",
Scope = "openid profile api offline_access", // offline_access: without it most providers issue no refresh token
Browser = new SystemBrowser(), // loopback HttpListener browser
};
var client = new OidcClient(options);
var result = await client.LoginAsync();
return result.IsError ? null : result.AccessToken;
}
}
// A minimal stand-in for the loopback-listener IBrowser a real desktop app
// registers: enough for the sign-in snippets above to compile, never
// exercised, since invoking it would open a real OS browser.
sealed class SystemBrowser : IBrowser
{
public Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("opens the OS browser and listens on the loopback port; not invoked by this file");
}
public sealed class BearerTokenHandler(ITokenProvider tokens) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Authorization = new("Bearer", await tokens.GetAccessTokenAsync(cancellationToken));
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose(); // replaced below; leaving it undisposed leaks its connection
request.Headers.Authorization = new("Bearer", await tokens.RefreshAccessTokenAsync(cancellationToken));
response = await base.SendAsync(request, cancellationToken);
}
return response;
}
}
public interface ITokenProvider
{
Task<string> GetAccessTokenAsync(CancellationToken ct = default);
Task<string> RefreshAccessTokenAsync(CancellationToken ct = default);
Task SignOutAsync(CancellationToken ct = default);
}
public sealed class MsalTokenProvider(IPublicClientApplication app, string[] scopes) : ITokenProvider
{
public async Task<string> GetAccessTokenAsync(CancellationToken ct = default)
{
var account = (await app.GetAccountsAsync()).FirstOrDefault();
var result = await app.AcquireTokenSilent(scopes, account).ExecuteAsync(ct);
return result.AccessToken;
}
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var account = (await app.GetAccountsAsync()).FirstOrDefault();
var result = await app.AcquireTokenSilent(scopes, account)
.WithForceRefresh(true)
.ExecuteAsync(ct);
return result.AccessToken;
}
public async Task SignOutAsync(CancellationToken ct = default)
{
foreach (var account in await app.GetAccountsAsync())
{
await app.RemoveAsync(account);
}
}
}
public interface ICredentialStore
{
Task<string?> GetRefreshTokenAsync(CancellationToken ct = default);
Task SaveRefreshTokenAsync(string? refreshToken, CancellationToken ct = default);
Task DeleteRefreshTokenAsync(CancellationToken ct = default);
}
public sealed class OidcTokenProvider(OidcClient client, ICredentialStore store) : ITokenProvider
{
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private string? _accessToken;
public async Task<string> GetAccessTokenAsync(CancellationToken ct = default) =>
_accessToken ?? await RefreshAccessTokenAsync(ct);
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var beforeLock = _accessToken;
await _refreshLock.WaitAsync(ct);
try
{
// Another caller already rotated the refresh token while this one
// waited on the lock; reusing its result instead of sending the
// now-stale refresh token to the server a second time.
if (_accessToken is not null && _accessToken != beforeLock)
{
return _accessToken;
}
var refreshToken = await store.GetRefreshTokenAsync(ct);
var result = await client.RefreshTokenAsync(refreshToken, cancellationToken: ct);
if (result.IsError)
{
throw new InvalidOperationException(result.Error);
}
await store.SaveRefreshTokenAsync(result.RefreshToken, ct);
return _accessToken = result.AccessToken;
}
finally
{
_refreshLock.Release();
}
}
public async Task SignOutAsync(CancellationToken ct = default)
{
_accessToken = null;
await store.DeleteRefreshTokenAsync(ct);
}
}
sealed class TestTokenProvider : ITokenProvider
{
public int Refreshes { get; private set; }
public Task<string> GetAccessTokenAsync(CancellationToken ct = default) => Task.FromResult("token-1");
public Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
Refreshes++;
return Task.FromResult("token-2");
}
public Task SignOutAsync(CancellationToken ct = default) => Task.CompletedTask;
}
sealed class SequencedHandler(params HttpStatusCode[] statuses) : HttpMessageHandler
{
private int _next;
public List<string?> SeenAuthorization { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
SeenAuthorization.Add(request.Headers.Authorization?.Parameter);
var status = statuses[Math.Min(_next++, statuses.Length - 1)];
return Task.FromResult(new HttpResponseMessage(status));
}
}
sealed class FakeCredentialStore(string? refreshToken) : ICredentialStore
{
public string? Saved { get; private set; }
public Task<string?> GetRefreshTokenAsync(CancellationToken ct = default) => Task.FromResult(refreshToken);
public Task SaveRefreshTokenAsync(string? token, CancellationToken ct = default)
{
Saved = token;
return Task.CompletedTask;
}
public Task DeleteRefreshTokenAsync(CancellationToken ct = default)
{
Saved = null;
return Task.CompletedTask;
}
}
sealed class TokenEndpointStub(string body) : HttpMessageHandler
{
public const string Success = """{"access_token":"fresh-access-token","refresh_token":"fresh-refresh-token","expires_in":3600,"token_type":"Bearer"}""";
public const string InvalidGrant = """{"error":"invalid_grant","error_description":"refresh token expired or revoked"}""";
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") });
}
// Holds its one call open until the test releases it, so a second caller has
// time to queue on OidcTokenProvider's lock instead of racing it by luck.
sealed class SlowTokenEndpointStub(TaskCompletionSource release) : HttpMessageHandler
{
public int Calls;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref Calls);
await release.Task;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(TokenEndpointStub.Success, System.Text.Encoding.UTF8, "application/json"),
};
}
}

IPublicClientApplication is registered through a factory delegate rather than AddSingleton<IPublicClientApplication, PublicClientApplication>() because it has no public constructor to call - PublicClientApplicationBuilder.Build() is the only way to produce one. Keeping it to that one instance for the app’s whole life is not just avoiding waste: it is the home of the in-memory cache AcquireTokenSilent reads from, so a second instance built later starts with an empty cache of its own and can pop up a second sign-in prompt on top of a session the first instance already established. MsalTokenProvider is a thin wrapper with no state of its own beyond that shared instance, so it can be Singleton too, while BearerTokenHandler still needs the Transient registration dotnet/delegatinghandler-bearer-token covers, because it sits inside the pipeline IHttpClientFactory rebuilds on a timer, not inside MSAL’s own cache.

OidcTokenProvider above registers the same way - Singleton, alongside the OidcClient and ICredentialStore it takes - since neither holds per-request state either; only the DelegatingHandler that calls into whichever provider is ever Transient.

ITokenProvider above already carries a SignOutAsync next to GetAccessTokenAsync, so ending the session needs to know only that one method, not which library issued the token. That call belongs to whatever in the app already knows who is signed in - an app-wide session service, if the UI shows the signed-in user’s name anywhere outside this one button - rather than to a “Sign out” menu item calling ITokenProvider straight from its view model: the session service calls SignOutAsync as one step of ending the session everywhere it is shown, and everything that reads “who is signed in” finds out through that service, not by asking the token provider itself.

For MSAL there is no separate “log out” call: MsalTokenProvider.SignOutAsync (shown with the rest of the class above) signs out by removing every account its cache knows about, since a public client never held a server-side session to end in the first place. RemoveAsync deletes that account’s tokens from MSAL’s own cache - the same persisted store “Where the tokens live between runs” above describes - so the next AcquireTokenSilent call has nothing to find, throws MsalUiRequiredException, and the existing catch sends the app back to AcquireTokenInteractive; no extra state needs to be tracked to make “sign back in” work after a sign-out. Looping over every account, not just GetAccountsAsync().FirstOrDefault() as AcquireTokenSilent does, matters because a user who signed into more than one account over the app’s life - a work and a personal Microsoft account, say - leaves the others behind otherwise, still cached and still eligible for silent sign-in.

For OidcClient, which has no cache to enumerate, OidcTokenProvider.SignOutAsync above deletes the refresh token from wherever it put it, so the next GetAccessTokenAsync call has nothing to refresh and falls through to the interactive flow instead. A generic OpenID Connect provider, unlike Entra ID’s public clients, commonly implements RFC 7009 token revocation and advertises a revocation_endpoint in its discovery document: where it does, call it with the refresh token (Duende.IdentityModel.Client’s HttpClient.RevokeTokenAsync, taking that endpoint address, the client ID and the token) before deleting the token locally, so the authorization server invalidates it immediately instead of only forgetting where the app kept it. Skipping that call is not a bug where the provider has no such endpoint - it is what MSAL’s side of this section already does for Entra ID - but where one exists, deleting the local copy alone leaves a refresh token that is still valid sitting wherever a network capture or a compromised backup might have already copied it.

A token stored in PasswordVault or through CredentialManagement instead of MSAL’s own cache signs out the same way: delete the one credential entry (PasswordVault.Remove, or that package’s own Delete) rather than clearing the whole vault, which would also remove credentials unrelated apps stored there.

The trap: RemoveAsync doesn’t sign the account out of Windows or Entra ID

Section titled “The trap: RemoveAsync doesn’t sign the account out of Windows or Entra ID”

RemoveAsync only touches the app’s own cache. It is not a server-side revocation - Microsoft Entra ID’s public-client tokens have no revoke endpoint - so a refresh token issued before sign-out stays valid until it expires on its own; it just no longer sits anywhere the app will look. It is also not an OS-level sign-out: when MSAL used the Windows broker (WAM, .WithBroker()) to sign the account in, the account can still be registered with WAM for every other app on the machine, including Windows itself, and AcquireTokenSilent(scopes, PublicClientApplication.OperatingSystemAccount) - the fallback the WAM sample uses when GetAccountsAsync() comes back empty - can silently sign the same account straight back in on the next launch, undoing the sign-out from the user’s point of view. An app that lets the user pick a different account, or confirm which one they’re leaving, needs the next interactive call to show the picker rather than trust a silent fallback: AcquireTokenInteractive(scopes).WithPrompt(Prompt.SelectAccount). Only the signed-in Windows user can remove an account from WAM itself (Windows Settings > Accounts > Access work or school), the same way only the identity provider’s own end-session endpoint, not the app, can end a browser-based SSO session a web login left behind.

The trap: the Resource Owner Password Credentials grant

Section titled “The trap: the Resource Owner Password Credentials grant”

Some authorization servers still expose ROPC, where the app collects the username and password in its own form and posts them straight to the token endpoint. It looks simpler because there is no browser to launch, but it is exactly the pattern the rest of this entry avoids: the app now handles the password directly, it cannot participate in single sign-on or a redirect to multi-factor authentication, and OAuth 2.1 removes the grant entirely. Microsoft Entra ID already refuses it for any account enrolled in MFA. Reach for it only against a first-party token endpoint that owner and API both control end to end, and even there prefer Authorization Code with PKCE against it if the endpoint supports OpenID Connect at all.

Sign in once, close the app without signing out, and reopen it: a correctly wired silent-refresh path shows the signed-in state immediately, with no browser popping up, because the cached refresh token minted a new access token in the background. Revoke the session (or wait out the refresh token’s lifetime) and repeat: the app should fall back to the interactive browser flow cleanly, not throw an unhandled exception from a 401 the DelegatingHandler didn’t expect. Deleting MSAL’s cache file (the path passed to StorageCreationPropertiesBuilder, under %LOCALAPPDATA% by convention) is the way to confirm the cache is actually persisting rather than starting empty every run: the app should behave exactly like a first run afterward. Storing through PasswordVault or CredentialManagement instead has the equivalent check in Control Panel > Credential Manager (or cmdkey /list).

Sign out, then relaunch without touching the OS account: a correctly wired sign-out goes straight to the interactive flow again, the same as a first run. app.GetAccountsAsync() returning empty right after SignOutAsync confirms MSAL’s cache was actually cleared rather than only the in-memory list; if the app still signs back in silently and it uses .WithBroker(), that is WAM’s own account list resurfacing the sign-in (“The trap: RemoveAsync doesn’t sign the account out of Windows or Entra ID” above), not a bug in SignOutAsync itself.

A desktop app that only ever talks to its own first-party API, with no third-party identity provider and no need for single sign-on, can issue and store JWTs the same way without a full OAuth authorization server in front - the interactive-browser and PKCE machinery above exists to protect a redirect through a separate authorization server’s login page, which a direct, TLS-only call to one’s own token endpoint doesn’t have. The secure-storage and short-lived-access-token points still apply either way: the difference is only how the first token pair is obtained, not how it is kept afterward.

Example files

All the files of examples/dotnet/jwt-authentication-desktop-client.cs, where the code above comes from, and the shared code it compiles in: examples/dotnet/shared/WidgetApi/.

shared/WidgetApi/WidgetApi.csproj
examples/dotnet/shared/WidgetApi/WidgetApi.csproj
<Project Sdk="Microsoft.NET.Sdk">
<!-- The API client the bearer-token examples register with AddHttpClient,
written once and referenced by each (#:project in a file-based app).
The gate compiles it on its own and with every example that
references it. -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

compiled and run by the build

shared/WidgetApi/WidgetApiClient.cs
examples/dotnet/shared/WidgetApi/WidgetApiClient.cs
namespace WidgetApi;
// A typed client for an API that wants a bearer token, which knows nothing
// about tokens: a request it makes carries one only when a handler in its
// HttpClient's pipeline adds it.
public interface IWidgetApiClient
{
Task GetWidgetsAsync();
}
public sealed class WidgetApiClient(HttpClient http) : IWidgetApiClient
{
public async Task GetWidgetsAsync() => (await http.GetAsync("widgets")).EnsureSuccessStatusCode();
}

compiled and run by the build

jwt-authentication-desktop-client.cs
examples/dotnet/jwt-authentication-desktop-client.cs
#:package Microsoft.Identity.Client@4.90.0
#:package Duende.IdentityModel.OidcClient@7.1.0
#:package Microsoft.Extensions.Hosting@10.0.12
#:package Microsoft.Extensions.Http@10.0.12
#:package xunit.assert@2.9.3
#:project shared/WidgetApi
// PKCE sign-in, token storage and the 401 retry for a desktop client. The
// marked regions are shown on the page; the rest proves each claim without
// a real browser or a real identity provider: MSAL's own local cache proves
// the silent-then-interactive fallback is genuinely needed, and a stub
// token endpoint stands in for the identity provider's token route.
// Interactive sign-in itself - AcquireTokenInteractive, OidcClient.LoginAsync
// - needs a real browser a headless run has none of, so those two calls are
// shown and compiled here but never invoked; everything reachable without
// one is exercised. A failed Assert throws, and the run exits non-zero.
using System.Net;
using System.Net.Http.Headers;
using Duende.IdentityModel.OidcClient;
using Duende.IdentityModel.OidcClient.Browser;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Identity.Client;
using JsonWebKeySet = Duende.IdentityModel.Jwk.JsonWebKeySet;
using WidgetApi;
using Xunit;
await Claims.SilentAcquireNeedsACachedAccountBeforeItTriesTheNetwork();
await Claims.SignOutHasNothingToRemoveOnAFreshCache();
await Claims.HandlerAttachesTheTokenAndRetriesOnceOn401();
await Claims.HandlerDoesNotRetryASecondTime();
await Claims.OidcRefreshTradesTheRefreshTokenForAnAccessToken();
await Claims.OidcRefreshThrowsWhenTheProviderRejectsIt();
await Claims.RefreshLockSerializesConcurrentCallers();
await Claims.RegistrationWiresTheHandlerToTheProvider();
Console.WriteLine("jwt-authentication-desktop-client: all claims hold");
static class Claims
{
// A cached-account-less PublicClientApplication never reaches the
// network: AcquireTokenSilent fails locally the moment it has no
// account to look up, which is exactly the signal the catch below
// exists to react to.
public static async Task SilentAcquireNeedsACachedAccountBeforeItTriesTheNetwork()
{
var app = PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
.WithDefaultRedirectUri()
.Build();
Assert.Empty(await app.GetAccountsAsync());
var provider = new MsalTokenProvider(app, ["api://your-api/access_as_user"]);
await Assert.ThrowsAsync<MsalUiRequiredException>(() => provider.GetAccessTokenAsync());
await Assert.ThrowsAsync<MsalUiRequiredException>(() => provider.RefreshAccessTokenAsync());
}
public static async Task SignOutHasNothingToRemoveOnAFreshCache()
{
var app = PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
.WithDefaultRedirectUri()
.Build();
var provider = new MsalTokenProvider(app, ["api://your-api/access_as_user"]);
await provider.SignOutAsync(); // loops over zero cached accounts; throws nothing
Assert.Empty(await app.GetAccountsAsync());
}
public static async Task HandlerAttachesTheTokenAndRetriesOnceOn401()
{
var tokens = new TestTokenProvider();
var stub = new SequencedHandler(HttpStatusCode.Unauthorized, HttpStatusCode.OK);
var handler = new BearerTokenHandler(tokens) { InnerHandler = stub };
using var client = new HttpClient(handler);
var response = await client.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(["token-1", "token-2"], stub.SeenAuthorization);
Assert.Equal(1, tokens.Refreshes);
}
public static async Task HandlerDoesNotRetryASecondTime()
{
var tokens = new TestTokenProvider();
var stub = new SequencedHandler(HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized);
var handler = new BearerTokenHandler(tokens) { InnerHandler = stub };
using var client = new HttpClient(handler);
var response = await client.GetAsync("https://api.example.invalid/widgets");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); // still 401: no second retry
Assert.Equal(2, stub.SeenAuthorization.Count);
Assert.Equal(1, tokens.Refreshes);
}
public static async Task OidcRefreshTradesTheRefreshTokenForAnAccessToken()
{
var endpoint = new TokenEndpointStub(TokenEndpointStub.Success);
var client = OidcClientAgainst(endpoint);
var store = new FakeCredentialStore("refresh-1");
var provider = new OidcTokenProvider(client, store);
var accessToken = await provider.GetAccessTokenAsync();
Assert.Equal("fresh-access-token", accessToken);
Assert.Equal("fresh-refresh-token", store.Saved); // rotated refresh token is persisted
}
public static async Task OidcRefreshThrowsWhenTheProviderRejectsIt()
{
var endpoint = new TokenEndpointStub(TokenEndpointStub.InvalidGrant);
var client = OidcClientAgainst(endpoint);
var store = new FakeCredentialStore("refresh-expired");
var provider = new OidcTokenProvider(client, store);
// Duende's RefreshTokenAsync returns IsError rather than throwing;
// the provider is what turns that into the exception the handler's
// uncaught RefreshAccessTokenAsync call needs to fall back on.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => provider.GetAccessTokenAsync());
Assert.Contains("invalid_grant", ex.Message);
}
// Two 401s arriving close together both call RefreshAccessTokenAsync with
// the same stale refresh token; a rotating provider accepts that token
// exactly once, so an unguarded second call would fail even though the
// first one succeeded. The stub blocks the first call's network request
// open until the second call is queued on the lock, forcing the overlap
// this test exists to catch rather than leaving it to scheduling luck.
public static async Task RefreshLockSerializesConcurrentCallers()
{
var release = new TaskCompletionSource();
var endpoint = new SlowTokenEndpointStub(release);
var client = OidcClientAgainst(endpoint);
var store = new FakeCredentialStore("refresh-1");
var provider = new OidcTokenProvider(client, store);
var first = provider.RefreshAccessTokenAsync();
var second = provider.RefreshAccessTokenAsync();
await Task.Delay(20); // let the second call reach the lock before releasing the first
release.SetResult();
var tokens = await Task.WhenAll(first, second);
Assert.Equal(1, endpoint.Calls); // the lock reused the first refresh instead of racing a second
Assert.Equal(["fresh-access-token", "fresh-access-token"], tokens);
}
public static async Task RegistrationWiresTheHandlerToTheProvider()
{
var builder = Host.CreateApplicationBuilder();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["ApiBaseUrl"] = "https://api.example.invalid/" });
builder.Logging.ClearProviders();
Register(builder);
builder.Services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => new SequencedHandler(HttpStatusCode.OK)));
using var host = builder.Build();
// Nothing here has ever signed in, so the chain the registration
// wires up - typed client, BearerTokenHandler, MsalTokenProvider -
// reaches the same local, network-free failure the first claim
// above proves directly; a wiring mistake would fail differently,
// with a DI resolution error instead of this one.
var apiClient = host.Services.GetRequiredService<IWidgetApiClient>();
await Assert.ThrowsAsync<MsalUiRequiredException>(() => apiClient.GetWidgetsAsync());
}
static void Register(HostApplicationBuilder builder)
{
builder.Services.AddSingleton<IPublicClientApplication>(_ =>
PublicClientApplicationBuilder.Create("YOUR_CLIENT_ID")
.WithDefaultRedirectUri()
.Build());
builder.Services.AddSingleton<ITokenProvider>(sp => new MsalTokenProvider(
sp.GetRequiredService<IPublicClientApplication>(),
scopes: ["api://your-api/access_as_user"]));
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddHttpClient<IWidgetApiClient, WidgetApiClient>(client =>
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]!))
.AddHttpMessageHandler<BearerTokenHandler>();
}
// A discovery-free OidcClient: ProviderInformation is supplied directly
// instead of fetched from Authority/.well-known, and BackchannelHandler
// is the stub, so RefreshTokenAsync's only HTTP call goes there.
static OidcClient OidcClientAgainst(HttpMessageHandler tokenEndpoint) => new(new OidcClientOptions
{
Authority = "https://your-authority",
ClientId = "your-client-id",
RedirectUri = "http://127.0.0.1:7890/",
Scope = "api", // no "openid": no id_token comes back, so there is no signature to validate
Browser = new SystemBrowser(),
BackchannelHandler = tokenEndpoint,
ProviderInformation = new ProviderInformation
{
IssuerName = "https://your-authority",
TokenEndpoint = "https://your-authority/connect/token",
AuthorizeEndpoint = "https://your-authority/connect/authorize",
KeySet = new JsonWebKeySet(), // no "openid" scope: no id_token comes back to check it against
},
});
}
static class MsalSignIn
{
// app is the one long-lived PublicClientApplication built once at
// startup ("Registering the pieces in DI" below), not a new one per
// call: a second instance starts with an empty cache of its own.
public static async Task<string> AcquireTokenAsync(IPublicClientApplication app)
{
string[] scopes = ["api://your-api/access_as_user"];
var accounts = await app.GetAccountsAsync();
AuthenticationResult result;
try
{
result = await app.AcquireTokenSilent(scopes, accounts.FirstOrDefault()).ExecuteAsync();
}
catch (MsalUiRequiredException)
{
result = await app.AcquireTokenInteractive(scopes).ExecuteAsync();
}
return result.AccessToken;
}
}
static class OidcSignIn
{
public static async Task<string?> LoginAsync()
{
var options = new OidcClientOptions
{
Authority = "https://your-authority",
ClientId = "your-client-id",
RedirectUri = "http://127.0.0.1:7890/",
Scope = "openid profile api offline_access", // offline_access: without it most providers issue no refresh token
Browser = new SystemBrowser(), // loopback HttpListener browser
};
var client = new OidcClient(options);
var result = await client.LoginAsync();
return result.IsError ? null : result.AccessToken;
}
}
// A minimal stand-in for the loopback-listener IBrowser a real desktop app
// registers: enough for the sign-in snippets above to compile, never
// exercised, since invoking it would open a real OS browser.
sealed class SystemBrowser : IBrowser
{
public Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("opens the OS browser and listens on the loopback port; not invoked by this file");
}
public sealed class BearerTokenHandler(ITokenProvider tokens) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Authorization = new("Bearer", await tokens.GetAccessTokenAsync(cancellationToken));
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose(); // replaced below; leaving it undisposed leaks its connection
request.Headers.Authorization = new("Bearer", await tokens.RefreshAccessTokenAsync(cancellationToken));
response = await base.SendAsync(request, cancellationToken);
}
return response;
}
}
public interface ITokenProvider
{
Task<string> GetAccessTokenAsync(CancellationToken ct = default);
Task<string> RefreshAccessTokenAsync(CancellationToken ct = default);
Task SignOutAsync(CancellationToken ct = default);
}
public sealed class MsalTokenProvider(IPublicClientApplication app, string[] scopes) : ITokenProvider
{
public async Task<string> GetAccessTokenAsync(CancellationToken ct = default)
{
var account = (await app.GetAccountsAsync()).FirstOrDefault();
var result = await app.AcquireTokenSilent(scopes, account).ExecuteAsync(ct);
return result.AccessToken;
}
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var account = (await app.GetAccountsAsync()).FirstOrDefault();
var result = await app.AcquireTokenSilent(scopes, account)
.WithForceRefresh(true)
.ExecuteAsync(ct);
return result.AccessToken;
}
public async Task SignOutAsync(CancellationToken ct = default)
{
foreach (var account in await app.GetAccountsAsync())
{
await app.RemoveAsync(account);
}
}
}
public interface ICredentialStore
{
Task<string?> GetRefreshTokenAsync(CancellationToken ct = default);
Task SaveRefreshTokenAsync(string? refreshToken, CancellationToken ct = default);
Task DeleteRefreshTokenAsync(CancellationToken ct = default);
}
public sealed class OidcTokenProvider(OidcClient client, ICredentialStore store) : ITokenProvider
{
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private string? _accessToken;
public async Task<string> GetAccessTokenAsync(CancellationToken ct = default) =>
_accessToken ?? await RefreshAccessTokenAsync(ct);
public async Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
var beforeLock = _accessToken;
await _refreshLock.WaitAsync(ct);
try
{
// Another caller already rotated the refresh token while this one
// waited on the lock; reusing its result instead of sending the
// now-stale refresh token to the server a second time.
if (_accessToken is not null && _accessToken != beforeLock)
{
return _accessToken;
}
var refreshToken = await store.GetRefreshTokenAsync(ct);
var result = await client.RefreshTokenAsync(refreshToken, cancellationToken: ct);
if (result.IsError)
{
throw new InvalidOperationException(result.Error);
}
await store.SaveRefreshTokenAsync(result.RefreshToken, ct);
return _accessToken = result.AccessToken;
}
finally
{
_refreshLock.Release();
}
}
public async Task SignOutAsync(CancellationToken ct = default)
{
_accessToken = null;
await store.DeleteRefreshTokenAsync(ct);
}
}
sealed class TestTokenProvider : ITokenProvider
{
public int Refreshes { get; private set; }
public Task<string> GetAccessTokenAsync(CancellationToken ct = default) => Task.FromResult("token-1");
public Task<string> RefreshAccessTokenAsync(CancellationToken ct = default)
{
Refreshes++;
return Task.FromResult("token-2");
}
public Task SignOutAsync(CancellationToken ct = default) => Task.CompletedTask;
}
sealed class SequencedHandler(params HttpStatusCode[] statuses) : HttpMessageHandler
{
private int _next;
public List<string?> SeenAuthorization { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
SeenAuthorization.Add(request.Headers.Authorization?.Parameter);
var status = statuses[Math.Min(_next++, statuses.Length - 1)];
return Task.FromResult(new HttpResponseMessage(status));
}
}
sealed class FakeCredentialStore(string? refreshToken) : ICredentialStore
{
public string? Saved { get; private set; }
public Task<string?> GetRefreshTokenAsync(CancellationToken ct = default) => Task.FromResult(refreshToken);
public Task SaveRefreshTokenAsync(string? token, CancellationToken ct = default)
{
Saved = token;
return Task.CompletedTask;
}
public Task DeleteRefreshTokenAsync(CancellationToken ct = default)
{
Saved = null;
return Task.CompletedTask;
}
}
sealed class TokenEndpointStub(string body) : HttpMessageHandler
{
public const string Success = """{"access_token":"fresh-access-token","refresh_token":"fresh-refresh-token","expires_in":3600,"token_type":"Bearer"}""";
public const string InvalidGrant = """{"error":"invalid_grant","error_description":"refresh token expired or revoked"}""";
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") });
}
// Holds its one call open until the test releases it, so a second caller has
// time to queue on OidcTokenProvider's lock instead of racing it by luck.
sealed class SlowTokenEndpointStub(TaskCompletionSource release) : HttpMessageHandler
{
public int Calls;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref Calls);
await release.Task;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(TokenEndpointStub.Success, System.Text.Encoding.UTF8, "application/json"),
};
}
}

compiled and run by the build

How do I attach a bearer token to every HttpClient request with a DelegatingHandler? (dotnet, howto)How do I attach a bearer tokento every HttpClient request…What project structure fits a WPF app that calls a REST API with CommunityToolkit.Mvvm? (dotnet, howto)What project structure fits aWPF app that calls a REST API…How do I add Microsoft Entra ID sign-in to a web app or single-page app? (identity, howto)How do I add Microsoft Entra IDsign-in to a web app or…What is single sign-on and how do OAuth 2.0 and OpenID Connect implement it with Microsoft Entra ID? (identity, howto)What is single sign-on and howdo OAuth 2.0 and OpenID Connect…How do I validate Microsoft Entra ID access tokens on the server to protect an API? (identity, howto)How do I validate MicrosoftEntra ID access tokens on the…How do I coordinate Microsoft Entra ID sign-in between a single-page app and a separately hosted backend API? (identity, howto)How do I coordinate MicrosoftEntra ID sign-in between a…
Open in the graph

Source: https://www.rfc-editor.org/rfc/rfc8252.html

History: 11 changes
  1. Example changed
  2. Body changed; example changed
  3. Example changed
  4. Body changed; example changed
  5. Body changed
  6. Made public
  7. Body changed
  8. Body changed
  9. Body changed
  10. Body changed
  11. Created