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 aPublicClientApplication. CallAcquireTokenSilentfirst and only fall back toAcquireTokenInteractivewhen it throwsMsalUiRequiredException, 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 theMicrosoft.Identity.Client.Brokerpackage 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 nothingAssert.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 retryAssert.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 firstrelease.SetResult();var tokens = await Task.WhenAll(first, second);Assert.Equal(1, endpoint.Calls); // the lock reused the first refresh instead of racing a secondAssert.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 validateBrowser = 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 tokenBrowser = 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 connectionrequest.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"),};}}AcquireTokenSilentonly 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 thecatchand reprompts.apphere is the onePublicClientApplicationthe 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.OidcClientimplements the same Authorization Code + PKCE flow against a generic authority, with a pluggable browser abstraction (aHttpListener-based loopback browser for WPF/WinForms/console, MAUI’s ownWebAuthenticatorfor 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 tokenBrowser = 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 nothingAssert.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 retryAssert.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 firstrelease.SetResult();var tokens = await Task.WhenAll(first, second);Assert.Equal(1, endpoint.Calls); // the lock reused the first refresh instead of racing a secondAssert.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 validateBrowser = 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 tokenBrowser = 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 connectionrequest.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"),};}}Scopeincludesoffline_accessalongsideopenid 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 forOidcTokenProviderbelow 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 requestsopenid,profileandoffline_accesson every call regardless of what its caller passes; a client that talks to Entra ID’s token endpoint directly, the wayOidcClienthere does, gets no such help and has to ask foroffline_accessitself 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.
Where the tokens live between runs
Section titled “Where the tokens live between runs”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.
Attaching the token to API calls
Section titled “Attaching the token to API calls”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.
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
#: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:
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
#: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:
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
#: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:
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
#: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.
The flow, start to finish
Section titled “The flow, start to finish”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
endThe 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.
Registering the pieces in DI
Section titled “Registering the pieces in DI”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.
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
#: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.
How to let the user log out
Section titled “How to let the user log out”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.
How to verify it worked
Section titled “How to verify it worked”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.
When this does not apply
Section titled “When this does not apply”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
<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
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
#: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
Source: https://www.rfc-editor.org/rfc/rfc8252.html
History: 11 changes
- Example changed
- Body changed; example changed
- Example changed
- Body changed; example changed
- Body changed
- Made public
- Body changed
- Body changed
- Body changed
- Body changed
- Created