Skip to content

Commit 47ca314

Browse files
Add OAuth2 token lifecycle authenticators (#2361) (#2362)
* Add design doc for OAuth2 token lifecycle authenticators (#2101) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add implementation plan for OAuth2 token lifecycle authenticators Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add OAuth2 token data models (RFC 6749) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add OAuth2 client credentials authenticator with token lifecycle Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add tests for OAuth2 client credentials authenticator Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add OAuth2 refresh token authenticator with token lifecycle Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add tests for OAuth2 refresh token authenticator Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add generic OAuth2 token authenticator with delegate provider Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add tests for generic OAuth2 token authenticator Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add documentation for OAuth2 token lifecycle authenticators Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract shared token endpoint logic into base class OAuth2ClientCredentialsAuthenticator and OAuth2RefreshTokenAuthenticator shared ~60 lines of identical code for HttpClient management, locking, token parsing, error handling, and disposal. Extract into OAuth2EndpointAuthenticatorBase. Subclasses now only provide grant-specific parameters and post-response hooks. Fixes SonarCloud duplication gate (4.5% > 3% threshold). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address Qodo code review: CancellationToken, nullable ExpiresIn, scope on refresh - Add optional CancellationToken to IAuthenticator.Authenticate and propagate it through all authenticators to SemaphoreSlim.WaitAsync, HttpClient.PostAsync, and the user delegate in OAuth2TokenAuthenticator - Make OAuth2TokenResponse.ExpiresIn nullable (int?) so missing expires_in from the server is treated as non-expiring instead of causing a refresh storm - Send scope parameter in OAuth2RefreshTokenAuthenticator when configured Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8cafdd1 commit 47ca314

19 files changed

+2135
-69
lines changed

docs/docs/advanced/authenticators.md

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,95 @@ var authenticator = OAuth1Authenticator.ForAccessToken(
128128

129129
## OAuth2
130130

131-
RestSharp has two very simple authenticators to send the access token as part of the request.
131+
RestSharp provides OAuth2 authenticators at two levels: **token lifecycle authenticators** that handle the full flow (obtaining, caching, and refreshing tokens automatically), and **simple authenticators** that just stamp a pre-obtained token onto requests.
132+
133+
### Token lifecycle authenticators
134+
135+
These authenticators manage tokens end-to-end. They use their own internal `HttpClient` for token endpoint calls, so there's no circular dependency with the `RestClient` they're attached to. All are thread-safe for concurrent use.
136+
137+
#### Client credentials
138+
139+
Use `OAuth2ClientCredentialsAuthenticator` for machine-to-machine flows. It POSTs `grant_type=client_credentials` to your token endpoint, caches the token, and refreshes it automatically before it expires.
140+
141+
```csharp
142+
var request = new OAuth2TokenRequest(
143+
"https://auth.example.com/oauth2/token",
144+
"my-client-id",
145+
"my-client-secret"
146+
) {
147+
Scope = "api.read api.write"
148+
};
149+
150+
var options = new RestClientOptions("https://api.example.com") {
151+
Authenticator = new OAuth2ClientCredentialsAuthenticator(request)
152+
};
153+
using var client = new RestClient(options);
154+
```
155+
156+
The authenticator will obtain a token on the first request and reuse it until it expires. The `ExpiryBuffer` property (default 30 seconds) controls how far in advance of actual expiry the token is considered stale.
157+
158+
#### Refresh token
159+
160+
Use `OAuth2RefreshTokenAuthenticator` when you already have an access token and refresh token (e.g., from an authorization code flow). It uses the initial access token until it expires, then automatically refreshes using the `refresh_token` grant type.
161+
162+
```csharp
163+
var request = new OAuth2TokenRequest(
164+
"https://auth.example.com/oauth2/token",
165+
"my-client-id",
166+
"my-client-secret"
167+
) {
168+
OnTokenRefreshed = response => {
169+
// Persist the new tokens to your storage
170+
SaveTokens(response.AccessToken, response.RefreshToken);
171+
}
172+
};
173+
174+
var options = new RestClientOptions("https://api.example.com") {
175+
Authenticator = new OAuth2RefreshTokenAuthenticator(
176+
request,
177+
accessToken: "current-access-token",
178+
refreshToken: "current-refresh-token",
179+
expiresAt: DateTimeOffset.UtcNow.AddMinutes(30)
180+
)
181+
};
182+
using var client = new RestClient(options);
183+
```
184+
185+
If the server rotates refresh tokens, the authenticator will automatically use the new refresh token for subsequent refreshes. The `OnTokenRefreshed` callback fires every time a new token is obtained, so you can persist the updated tokens.
186+
187+
#### Custom token provider
188+
189+
Use `OAuth2TokenAuthenticator` when you have a non-standard token flow or want full control over how tokens are obtained. Provide an async delegate that returns an `OAuth2Token`:
190+
191+
```csharp
192+
var options = new RestClientOptions("https://api.example.com") {
193+
Authenticator = new OAuth2TokenAuthenticator(async cancellationToken => {
194+
var token = await myCustomTokenService.GetTokenAsync(cancellationToken);
195+
return new OAuth2Token(token.Value, token.ExpiresAt);
196+
})
197+
};
198+
using var client = new RestClient(options);
199+
```
200+
201+
The authenticator caches the result and re-invokes your delegate when the token expires.
202+
203+
#### Bringing your own HttpClient
204+
205+
By default, the token lifecycle authenticators create their own `HttpClient` for token endpoint calls (and dispose it when the authenticator is disposed). If you need to customize it (e.g., for proxy settings or mTLS), pass your own:
206+
207+
```csharp
208+
var request = new OAuth2TokenRequest(
209+
"https://auth.example.com/oauth2/token",
210+
"my-client-id",
211+
"my-client-secret"
212+
) {
213+
HttpClient = myCustomHttpClient // not disposed by the authenticator
214+
};
215+
```
216+
217+
### Simple authenticators
218+
219+
If you manage tokens yourself and just need to stamp them onto requests, use these simpler authenticators.
132220

133221
`OAuth2UriQueryParameterAuthenticator` accepts the access token as the only constructor argument, and it will send the provided token as a query parameter `oauth_token`.
134222

@@ -148,8 +236,6 @@ var client = new RestClient(options);
148236

149237
The code above will tell RestSharp to send the bearer token with each request as a header. Essentially, the code above does the same as the sample for `JwtAuthenticator` below.
150238

151-
As those authenticators don't do much to get the token itself, you might be interested in looking at our [sample OAuth2 authenticator](../usage/example.md#authenticator), which requests the token on its own.
152-
153239
## JWT
154240

155241
The JWT authentication can be supported by using `JwtAuthenticator`. It is a very simple class that can be constructed like this:
@@ -182,4 +268,4 @@ var client = new RestClient(options);
182268
The `Authenticate` method is the very first thing called upon calling `RestClient.Execute` or `RestClient.Execute<T>`.
183269
It gets the `RestRequest` currently being executed giving you access to every part of the request data (headers, parameters, etc.)
184270

185-
You can find an example of a custom authenticator that fetches and uses an OAuth2 bearer token [here](../usage/example.md#authenticator).
271+
You can find an example of using the built-in OAuth2 authenticator in a typed API client [here](../usage/example.md#authenticator).

docs/docs/usage/example.md

Lines changed: 21 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,14 @@ public class TwitterClient : ITwitterClient, IDisposable {
4545
readonly RestClient _client;
4646

4747
public TwitterClient(string apiKey, string apiKeySecret) {
48-
var options = new RestClientOptions("https://api.twitter.com/2");
48+
var tokenRequest = new OAuth2TokenRequest(
49+
"https://api.twitter.com/oauth2/token",
50+
apiKey,
51+
apiKeySecret
52+
);
53+
var options = new RestClientOptions("https://api.twitter.com/2") {
54+
Authenticator = new OAuth2ClientCredentialsAuthenticator(tokenRequest)
55+
};
4956
_client = new RestClient(options);
5057
}
5158

@@ -79,73 +86,27 @@ public TwitterClient(IOptions<TwitterClientOptions> options) {
7986

8087
Then, you can register and configure the client using ASP.NET Core dependency injection container.
8188

82-
Right now, the client won't really work as Twitter API requires authentication. It's covered in the next section.
89+
Notice the client constructor already configures the `OAuth2ClientCredentialsAuthenticator`. The authenticator setup is described in the next section.
8390

8491
## Authenticator
8592

86-
Before we can call the API itself, we need to get a bearer token. Twitter exposes an endpoint `https://api.twitter.com/oauth2/token`. As it follows the OAuth2 conventions, the code can be used to create an authenticator for some other vendors.
87-
88-
First, we need a model for deserializing the token endpoint response. OAuth2 uses snake case for property naming, so we need to decorate model properties with `JsonPropertyName` attribute:
89-
90-
```csharp
91-
record TokenResponse {
92-
[JsonPropertyName("token_type")]
93-
public string TokenType { get; init; }
94-
[JsonPropertyName("access_token")]
95-
public string AccessToken { get; init; }
96-
}
97-
```
98-
99-
Next, we create the authenticator itself. It needs the API key and API key secret to call the token endpoint using basic HTTP authentication. In addition, we can extend the list of parameters with the base URL to convert it to a more generic OAuth2 authenticator.
100-
101-
The easiest way to create an authenticator is to inherit from the `AuthenticatorBase` base class:
102-
103-
```csharp
104-
public class TwitterAuthenticator : AuthenticatorBase {
105-
readonly string _baseUrl;
106-
readonly string _clientId;
107-
readonly string _clientSecret;
108-
109-
public TwitterAuthenticator(string baseUrl, string clientId, string clientSecret) : base("") {
110-
_baseUrl = baseUrl;
111-
_clientId = clientId;
112-
_clientSecret = clientSecret;
113-
}
114-
115-
protected override async ValueTask<Parameter> GetAuthenticationParameter(string accessToken) {
116-
Token = string.IsNullOrEmpty(Token) ? await GetToken() : Token;
117-
return new HeaderParameter(KnownHeaders.Authorization, Token);
118-
}
119-
}
120-
```
121-
122-
During the first call made by the client using the authenticator, it will find out that the `Token` property is empty. It will then call the `GetToken` function to get the token once and reuse the token going forward.
123-
124-
Now, we need to implement the `GetToken` function in the class:
93+
Before we can call the API itself, we need to get a bearer token. Twitter exposes an endpoint `https://api.twitter.com/oauth2/token`. As it follows the standard OAuth2 client credentials convention, we can use the built-in `OAuth2ClientCredentialsAuthenticator`:
12594

12695
```csharp
127-
async Task<string> GetToken() {
128-
var options = new RestClientOptions(_baseUrl){
129-
Authenticator = new HttpBasicAuthenticator(_clientId, _clientSecret),
130-
};
131-
using var client = new RestClient(options);
132-
133-
var request = new RestRequest("oauth2/token")
134-
.AddParameter("grant_type", "client_credentials");
135-
var response = await client.PostAsync<TokenResponse>(request);
136-
return $"{response!.TokenType} {response!.AccessToken}";
137-
}
96+
var tokenRequest = new OAuth2TokenRequest(
97+
"https://api.twitter.com/oauth2/token",
98+
apiKey,
99+
apiKeySecret
100+
);
101+
102+
var options = new RestClientOptions("https://api.twitter.com/2") {
103+
Authenticator = new OAuth2ClientCredentialsAuthenticator(tokenRequest)
104+
};
138105
```
139106

140-
As we need to make a call to the token endpoint, we need our own short-lived instance of `RestClient`. Unlike the actual Twitter client, it will use the `HttpBasicAuthenticator` to send the API key and secret as the username and password. The client then gets disposed as we only use it once.
141-
142-
Here we add a POST parameter `grant_type` with `client_credentials` as its value. At the moment, it's the only supported value.
143-
144-
The POST request will use the `application/x-www-form-urlencoded` content type by default.
107+
The authenticator will automatically obtain a token on the first request, cache it, and refresh it when it expires. It uses its own `HttpClient` internally for token endpoint calls, so there's no circular dependency with the `RestClient`.
145108

146-
::: note
147-
Sample code provided on this page is a production code. For example, the authenticator might produce undesired side effect when multiple requests are made at the same time when the token hasn't been obtained yet. It can be solved rather than simply using semaphores or synchronized invocation.
148-
:::
109+
For more details on the available OAuth2 authenticators (including refresh token flows and custom token providers), see [Authenticators](../advanced/authenticators.md#oauth2).
149110

150111
## Final words
151112

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# OAuth2 Token Lifecycle Authenticators
2+
3+
**Issue:** [#2101](https://github.com/restsharp/RestSharp/issues/2101)
4+
**Date:** 2026-03-01
5+
6+
## Problem
7+
8+
The existing OAuth2 authenticators are static token stampers — they take a pre-obtained token and add it to requests. Users who need automatic token acquisition, caching, and refresh hit a circular dependency: the authenticator needs an HttpClient to call the token endpoint, but it lives inside the RestClient it's attached to.
9+
10+
## Solution
11+
12+
Self-contained OAuth2 authenticators that manage the full token lifecycle using their own internal HttpClient for token endpoint calls.
13+
14+
## Components
15+
16+
### OAuth2TokenResponse
17+
18+
RFC 6749 Section 5.1 token response model. Used for deserializing token endpoint responses.
19+
20+
Fields: `AccessToken`, `TokenType`, `ExpiresIn`, `RefreshToken` (optional), `Scope` (optional). Deserialized with `System.Text.Json` using `JsonPropertyName` attributes for snake_case mapping.
21+
22+
### OAuth2TokenRequest
23+
24+
Shared configuration for token endpoint calls.
25+
26+
- `TokenEndpointUrl` (required) — URL of the OAuth2 token endpoint
27+
- `ClientId` (required) — OAuth2 client ID
28+
- `ClientSecret` (required) — OAuth2 client secret
29+
- `Scope` (optional) — requested scope
30+
- `ExtraParameters` (optional) — additional form parameters
31+
- `HttpClient` (optional) — bring your own HttpClient for token calls
32+
- `ExpiryBuffer` — refresh before actual expiry (default 30s)
33+
- `OnTokenRefreshed` — callback fired when a new token is obtained
34+
35+
### OAuth2Token
36+
37+
Simple record `(string AccessToken, DateTimeOffset ExpiresAt)` for the generic authenticator's delegate return type.
38+
39+
### OAuth2ClientCredentialsAuthenticator
40+
41+
Machine-to-machine flow. POSTs `grant_type=client_credentials` to the token endpoint. Caches the token and refreshes when expired. Thread-safe via SemaphoreSlim with double-check pattern. Implements IDisposable to clean up owned HttpClient.
42+
43+
### OAuth2RefreshTokenAuthenticator
44+
45+
User token flow. Takes initial access + refresh tokens. When the access token expires, POSTs `grant_type=refresh_token`. Updates the cached refresh token if the server rotates it. Fires `OnTokenRefreshed` callback so callers can persist new tokens.
46+
47+
### OAuth2TokenAuthenticator
48+
49+
Generic/delegate-based. Takes `Func<CancellationToken, Task<OAuth2Token>>`. For non-standard flows where users provide their own token acquisition logic. Caches the result and re-invokes the delegate on expiry.
50+
51+
## Data Flow
52+
53+
```
54+
Request → Authenticate()
55+
→ cached token valid? → stamp Authorization header
56+
→ expired? → acquire SemaphoreSlim
57+
→ double-check still expired
58+
→ POST to token endpoint (own HttpClient)
59+
→ parse OAuth2TokenResponse
60+
→ cache token, compute expiry (ExpiresIn - ExpiryBuffer)
61+
→ fire OnTokenRefreshed callback
62+
→ stamp Authorization header
63+
```
64+
65+
## Error Handling
66+
67+
- Non-2xx from token endpoint: throw HttpRequestException with status and body
68+
- Missing access_token in response: throw InvalidOperationException
69+
- No retry logic — callers control retries at RestClient level
70+
71+
## Thread Safety
72+
73+
SemaphoreSlim(1, 1) with double-check pattern. One thread refreshes; concurrent callers wait and reuse the new token.
74+
75+
## IDisposable
76+
77+
Authenticators that create their own HttpClient dispose it. User-provided HttpClient is not disposed. Same pattern as RestClient itself.
78+
79+
## Multi-targeting
80+
81+
System.Text.Json for deserialization — NuGet package on netstandard2.0/net471/net48, built-in on net8.0+. No conditional compilation needed.
82+
83+
## Files
84+
85+
```
86+
src/RestSharp/Authenticators/OAuth2/
87+
OAuth2TokenResponse.cs (new)
88+
OAuth2TokenRequest.cs (new)
89+
OAuth2Token.cs (new)
90+
OAuth2ClientCredentialsAuthenticator.cs (new)
91+
OAuth2RefreshTokenAuthenticator.cs (new)
92+
OAuth2TokenAuthenticator.cs (new)
93+
94+
test/RestSharp.Tests/Auth/
95+
OAuth2ClientCredentialsAuthenticatorTests.cs (new)
96+
OAuth2RefreshTokenAuthenticatorTests.cs (new)
97+
OAuth2TokenAuthenticatorTests.cs (new)
98+
```
99+
100+
No changes to existing files. No API breaks.

0 commit comments

Comments
 (0)