From cee2cd6d604172373fafadf9608d6310ba7dad73 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Sun, 1 Mar 2026 16:14:46 +0100 Subject: [PATCH 01/12] Add design doc for OAuth2 token lifecycle authenticators (#2101) Co-Authored-By: Claude Opus 4.6 --- ...026-03-01-oauth2-token-lifecycle-design.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/plans/2026-03-01-oauth2-token-lifecycle-design.md diff --git a/docs/plans/2026-03-01-oauth2-token-lifecycle-design.md b/docs/plans/2026-03-01-oauth2-token-lifecycle-design.md new file mode 100644 index 000000000..e43141153 --- /dev/null +++ b/docs/plans/2026-03-01-oauth2-token-lifecycle-design.md @@ -0,0 +1,100 @@ +# OAuth2 Token Lifecycle Authenticators + +**Issue:** [#2101](https://github.com/restsharp/RestSharp/issues/2101) +**Date:** 2026-03-01 + +## Problem + +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. + +## Solution + +Self-contained OAuth2 authenticators that manage the full token lifecycle using their own internal HttpClient for token endpoint calls. + +## Components + +### OAuth2TokenResponse + +RFC 6749 Section 5.1 token response model. Used for deserializing token endpoint responses. + +Fields: `AccessToken`, `TokenType`, `ExpiresIn`, `RefreshToken` (optional), `Scope` (optional). Deserialized with `System.Text.Json` using `JsonPropertyName` attributes for snake_case mapping. + +### OAuth2TokenRequest + +Shared configuration for token endpoint calls. + +- `TokenEndpointUrl` (required) — URL of the OAuth2 token endpoint +- `ClientId` (required) — OAuth2 client ID +- `ClientSecret` (required) — OAuth2 client secret +- `Scope` (optional) — requested scope +- `ExtraParameters` (optional) — additional form parameters +- `HttpClient` (optional) — bring your own HttpClient for token calls +- `ExpiryBuffer` — refresh before actual expiry (default 30s) +- `OnTokenRefreshed` — callback fired when a new token is obtained + +### OAuth2Token + +Simple record `(string AccessToken, DateTimeOffset ExpiresAt)` for the generic authenticator's delegate return type. + +### OAuth2ClientCredentialsAuthenticator + +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. + +### OAuth2RefreshTokenAuthenticator + +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. + +### OAuth2TokenAuthenticator + +Generic/delegate-based. Takes `Func>`. For non-standard flows where users provide their own token acquisition logic. Caches the result and re-invokes the delegate on expiry. + +## Data Flow + +``` +Request → Authenticate() + → cached token valid? → stamp Authorization header + → expired? → acquire SemaphoreSlim + → double-check still expired + → POST to token endpoint (own HttpClient) + → parse OAuth2TokenResponse + → cache token, compute expiry (ExpiresIn - ExpiryBuffer) + → fire OnTokenRefreshed callback + → stamp Authorization header +``` + +## Error Handling + +- Non-2xx from token endpoint: throw HttpRequestException with status and body +- Missing access_token in response: throw InvalidOperationException +- No retry logic — callers control retries at RestClient level + +## Thread Safety + +SemaphoreSlim(1, 1) with double-check pattern. One thread refreshes; concurrent callers wait and reuse the new token. + +## IDisposable + +Authenticators that create their own HttpClient dispose it. User-provided HttpClient is not disposed. Same pattern as RestClient itself. + +## Multi-targeting + +System.Text.Json for deserialization — NuGet package on netstandard2.0/net471/net48, built-in on net8.0+. No conditional compilation needed. + +## Files + +``` +src/RestSharp/Authenticators/OAuth2/ + OAuth2TokenResponse.cs (new) + OAuth2TokenRequest.cs (new) + OAuth2Token.cs (new) + OAuth2ClientCredentialsAuthenticator.cs (new) + OAuth2RefreshTokenAuthenticator.cs (new) + OAuth2TokenAuthenticator.cs (new) + +test/RestSharp.Tests/Auth/ + OAuth2ClientCredentialsAuthenticatorTests.cs (new) + OAuth2RefreshTokenAuthenticatorTests.cs (new) + OAuth2TokenAuthenticatorTests.cs (new) +``` + +No changes to existing files. No API breaks. From 9994554262ea8423565a07aac9fb3357349e132d Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Sun, 1 Mar 2026 16:17:45 +0100 Subject: [PATCH 02/12] Add implementation plan for OAuth2 token lifecycle authenticators Co-Authored-By: Claude Opus 4.6 --- .../2026-03-01-oauth2-token-lifecycle-plan.md | 994 ++++++++++++++++++ 1 file changed, 994 insertions(+) create mode 100644 docs/plans/2026-03-01-oauth2-token-lifecycle-plan.md diff --git a/docs/plans/2026-03-01-oauth2-token-lifecycle-plan.md b/docs/plans/2026-03-01-oauth2-token-lifecycle-plan.md new file mode 100644 index 000000000..9e93b95c5 --- /dev/null +++ b/docs/plans/2026-03-01-oauth2-token-lifecycle-plan.md @@ -0,0 +1,994 @@ +# OAuth2 Token Lifecycle Authenticators — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add self-contained OAuth2 authenticators that handle token acquisition, caching, expiry, and refresh internally — fixing issue #2101. + +**Architecture:** Three new authenticators (`OAuth2ClientCredentialsAuthenticator`, `OAuth2RefreshTokenAuthenticator`, `OAuth2TokenAuthenticator`) that own their own `HttpClient` for token endpoint calls, avoiding the circular dependency. They share a common `OAuth2TokenResponse` model (RFC 6749) and `OAuth2TokenRequest` config class. Thread-safe via `SemaphoreSlim`. + +**Tech Stack:** C# preview, System.Text.Json, xUnit + FluentAssertions + RichardSzalay.MockHttp + +**Conventions:** +- All `/src` files need the Apache-2.0 license header (see existing files for exact text) +- All public types get `[PublicAPI]` attribute (from JetBrains.Annotations, auto-imported via `src/Directory.Build.props`) +- Namespace: `RestSharp.Authenticators.OAuth2` (matches existing OAuth2 authenticators) +- Tests: nullable disabled, global usings for `Xunit`, `FluentAssertions`, `AutoFixture` already configured +- Build: `dotnet build RestSharp.slnx -c Debug` +- Test: `dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj -f net9.0` + +--- + +### Task 1: Data models — OAuth2TokenResponse, OAuth2Token, OAuth2TokenRequest + +**Files:** +- Create: `src/RestSharp/Authenticators/OAuth2/OAuth2TokenResponse.cs` +- Create: `src/RestSharp/Authenticators/OAuth2/OAuth2Token.cs` +- Create: `src/RestSharp/Authenticators/OAuth2/OAuth2TokenRequest.cs` + +**Step 1: Create OAuth2TokenResponse** + +```csharp +// Copyright (c) .NET Foundation and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text.Json.Serialization; + +namespace RestSharp.Authenticators.OAuth2; + +/// +/// OAuth 2.0 token endpoint response as defined in RFC 6749 Section 5.1. +/// +[PublicAPI] +public record OAuth2TokenResponse { + [JsonPropertyName("access_token")] + public string AccessToken { get; init; } = ""; + + [JsonPropertyName("token_type")] + public string TokenType { get; init; } = ""; + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; init; } + + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; init; } + + [JsonPropertyName("scope")] + public string? Scope { get; init; } +} +``` + +**Step 2: Create OAuth2Token** + +```csharp +// Copyright (c) .NET Foundation and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace RestSharp.Authenticators.OAuth2; + +/// +/// Represents an access token with its expiration time. Used as the return type +/// for the custom token provider delegate in . +/// +[PublicAPI] +public record OAuth2Token(string AccessToken, DateTimeOffset ExpiresAt); +``` + +**Step 3: Create OAuth2TokenRequest** + +```csharp +// Copyright (c) .NET Foundation and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace RestSharp.Authenticators.OAuth2; + +/// +/// Configuration for OAuth 2.0 token endpoint requests. Shared by +/// and . +/// +[PublicAPI] +public class OAuth2TokenRequest { + /// + /// The URL of the OAuth 2.0 token endpoint. + /// + public required string TokenEndpointUrl { get; init; } + + /// + /// The OAuth 2.0 client identifier. + /// + public required string ClientId { get; init; } + + /// + /// The OAuth 2.0 client secret. + /// + public required string ClientSecret { get; init; } + + /// + /// Optional scope to request. + /// + public string? Scope { get; init; } + + /// + /// Additional form parameters to include in the token request. + /// + public Dictionary? ExtraParameters { get; init; } + + /// + /// Optional HttpClient to use for token endpoint calls. When provided, the authenticator + /// will not create or dispose its own HttpClient. + /// + public HttpClient? HttpClient { get; init; } + + /// + /// How long before actual token expiry to consider it expired. Defaults to 30 seconds. + /// + public TimeSpan ExpiryBuffer { get; init; } = TimeSpan.FromSeconds(30); + + /// + /// Callback invoked when a new token is obtained. Use this to persist tokens to storage. + /// + public Action? OnTokenRefreshed { get; init; } +} +``` + +**Step 4: Build to verify compilation** + +Run: `dotnet build RestSharp.slnx -c Debug` +Expected: BUILD SUCCEEDED + +**Step 5: Commit** + +```bash +git add src/RestSharp/Authenticators/OAuth2/OAuth2TokenResponse.cs \ + src/RestSharp/Authenticators/OAuth2/OAuth2Token.cs \ + src/RestSharp/Authenticators/OAuth2/OAuth2TokenRequest.cs +git commit -m "feat: add OAuth2 token data models (RFC 6749)" +``` + +--- + +### Task 2: OAuth2ClientCredentialsAuthenticator + +**Files:** +- Create: `src/RestSharp/Authenticators/OAuth2/OAuth2ClientCredentialsAuthenticator.cs` + +**Step 1: Create the authenticator** + +```csharp +// Copyright (c) .NET Foundation and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text.Json; + +namespace RestSharp.Authenticators.OAuth2; + +/// +/// OAuth 2.0 Client Credentials authenticator. Automatically obtains and caches access tokens +/// from the token endpoint using the client_credentials grant type. +/// Uses its own HttpClient for token endpoint calls, avoiding circular dependencies with RestClient. +/// Thread-safe for concurrent request usage. +/// +[PublicAPI] +public class OAuth2ClientCredentialsAuthenticator : IAuthenticator, IDisposable { + readonly OAuth2TokenRequest _request; + readonly HttpClient _tokenClient; + readonly bool _disposeClient; + readonly SemaphoreSlim _lock = new(1, 1); + + string? _accessToken; + DateTimeOffset _tokenExpiry = DateTimeOffset.MinValue; + + public OAuth2ClientCredentialsAuthenticator(OAuth2TokenRequest request) { + _request = request; + + if (request.HttpClient != null) { + _tokenClient = request.HttpClient; + _disposeClient = false; + } + else { + _tokenClient = new HttpClient(); + _disposeClient = true; + } + } + + public async ValueTask Authenticate(IRestClient client, RestRequest request) { + var token = await GetOrRefreshTokenAsync().ConfigureAwait(false); + request.AddOrUpdateParameter(new HeaderParameter(KnownHeaders.Authorization, $"Bearer {token}")); + } + + async Task GetOrRefreshTokenAsync() { + if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + await _lock.WaitAsync().ConfigureAwait(false); + + try { + // Double-check after acquiring lock + if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + var parameters = new Dictionary { + ["grant_type"] = "client_credentials", + ["client_id"] = _request.ClientId, + ["client_secret"] = _request.ClientSecret + }; + + if (_request.Scope != null) + parameters["scope"] = _request.Scope; + + if (_request.ExtraParameters != null) { + foreach (var (key, value) in _request.ExtraParameters) + parameters[key] = value; + } + + using var content = new FormUrlEncodedContent(parameters); + using var response = await _tokenClient.PostAsync(_request.TokenEndpointUrl, content).ConfigureAwait(false); + + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + throw new HttpRequestException($"Token request failed with status {response.StatusCode}: {body}"); + + var tokenResponse = JsonSerializer.Deserialize(body); + + if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.AccessToken)) + throw new InvalidOperationException($"Token endpoint returned an invalid response: {body}"); + + _accessToken = tokenResponse.AccessToken; + _tokenExpiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn) - _request.ExpiryBuffer; + + _request.OnTokenRefreshed?.Invoke(tokenResponse); + + return _accessToken; + } + finally { + _lock.Release(); + } + } + + public void Dispose() { + if (_disposeClient) _tokenClient.Dispose(); + _lock.Dispose(); + } +} +``` + +**Step 2: Build to verify compilation** + +Run: `dotnet build RestSharp.slnx -c Debug` +Expected: BUILD SUCCEEDED + +**Step 3: Commit** + +```bash +git add src/RestSharp/Authenticators/OAuth2/OAuth2ClientCredentialsAuthenticator.cs +git commit -m "feat: add OAuth2 client credentials authenticator with token lifecycle" +``` + +--- + +### Task 3: Tests for OAuth2ClientCredentialsAuthenticator + +**Files:** +- Create: `test/RestSharp.Tests/Auth/OAuth2ClientCredentialsAuthenticatorTests.cs` + +The tests use `MockHttpMessageHandler` from `RichardSzalay.MockHttp` to simulate the token endpoint. We inject the mock handler into the authenticator's `HttpClient` via `OAuth2TokenRequest.HttpClient`. + +**Step 1: Write the test class** + +```csharp +using System.Net; +using RestSharp.Authenticators.OAuth2; +using RichardSzalay.MockHttp; + +namespace RestSharp.Tests.Auth; + +public class OAuth2ClientCredentialsAuthenticatorTests : IDisposable { + const string TokenEndpoint = "https://auth.example.com/token"; + + static string TokenJson(int expiresIn = 3600, string accessToken = "test-access-token") + => $$"""{"access_token":"{{accessToken}}","token_type":"Bearer","expires_in":{{expiresIn}}}"""; + + readonly MockHttpMessageHandler _mockHttp = new(); + + OAuth2ClientCredentialsAuthenticator CreateAuthenticator( + Action? onRefreshed = null, + TimeSpan? expiryBuffer = null + ) { + var request = new OAuth2TokenRequest { + TokenEndpointUrl = TokenEndpoint, + ClientId = "my-client", + ClientSecret = "my-secret", + HttpClient = new HttpClient(_mockHttp), + OnTokenRefreshed = onRefreshed, + ExpiryBuffer = expiryBuffer ?? TimeSpan.Zero + }; + return new OAuth2ClientCredentialsAuthenticator(request); + } + + [Fact] + public async Task Should_obtain_token_and_set_authorization_header() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", TokenJson()); + + using var auth = CreateAuthenticator(); + var restRequest = new RestRequest(); + await auth.Authenticate(null!, restRequest); + + var header = restRequest.Parameters.FirstOrDefault( + p => p.Name == KnownHeaders.Authorization + ); + header.Should().NotBeNull(); + header!.Value.Should().Be("Bearer test-access-token"); + } + + [Fact] + public async Task Should_cache_token_across_multiple_calls() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", TokenJson()); + + using var auth = CreateAuthenticator(); + await auth.Authenticate(null!, new RestRequest()); + await auth.Authenticate(null!, new RestRequest()); + + // MockHttp was set up with When (not Expect), so count calls manually + // The second call should reuse the cached token + _mockHttp.GetMatchCount(_mockHttp.When(HttpMethod.Post, TokenEndpoint)).Should().BeLessOrEqual(1); + } + + [Fact] + public async Task Should_refresh_expired_token() { + var callCount = 0; + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(_ => { + callCount++; + var json = callCount == 1 + ? TokenJson(expiresIn: 0, accessToken: "token-1") + : TokenJson(expiresIn: 3600, accessToken: "token-2"); + return new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + }); + + using var auth = CreateAuthenticator(expiryBuffer: TimeSpan.Zero); + + var req1 = new RestRequest(); + await auth.Authenticate(null!, req1); + req1.Parameters.First(p => p.Name == KnownHeaders.Authorization) + .Value.Should().Be("Bearer token-1"); + + // expires_in was 0 and buffer is 0, so token is already expired + var req2 = new RestRequest(); + await auth.Authenticate(null!, req2); + req2.Parameters.First(p => p.Name == KnownHeaders.Authorization) + .Value.Should().Be("Bearer token-2"); + + callCount.Should().Be(2); + } + + [Fact] + public async Task Should_invoke_callback_on_token_refresh() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", TokenJson()); + + OAuth2TokenResponse captured = null; + using var auth = CreateAuthenticator(onRefreshed: t => captured = t); + + await auth.Authenticate(null!, new RestRequest()); + + captured.Should().NotBeNull(); + captured!.AccessToken.Should().Be("test-access-token"); + captured.TokenType.Should().Be("Bearer"); + captured.ExpiresIn.Should().Be(3600); + } + + [Fact] + public async Task Should_throw_on_error_response() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(HttpStatusCode.BadRequest, "application/json", """{"error":"invalid_client"}"""); + + using var auth = CreateAuthenticator(); + + var act = () => auth.Authenticate(null!, new RestRequest()).AsTask(); + await act.Should().ThrowAsync() + .WithMessage("*400*invalid_client*"); + } + + [Fact] + public async Task Should_throw_on_empty_access_token() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", """{"access_token":"","token_type":"Bearer","expires_in":3600}"""); + + using var auth = CreateAuthenticator(); + + var act = () => auth.Authenticate(null!, new RestRequest()).AsTask(); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Should_send_scope_when_configured() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .WithFormData("scope", "read write") + .Respond("application/json", TokenJson()); + + var request = new OAuth2TokenRequest { + TokenEndpointUrl = TokenEndpoint, + ClientId = "my-client", + ClientSecret = "my-secret", + Scope = "read write", + HttpClient = new HttpClient(_mockHttp) + }; + using var auth = new OAuth2ClientCredentialsAuthenticator(request); + + await auth.Authenticate(null!, new RestRequest()); + // If scope wasn't sent, the mock would not match and the request would fail + } + + public void Dispose() => _mockHttp.Dispose(); +} +``` + +**Step 2: Run tests to verify they pass** + +Run: `dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj --filter "FullyQualifiedName~OAuth2ClientCredentialsAuthenticator" -f net9.0` +Expected: All tests PASS + +**Step 3: Commit** + +```bash +git add test/RestSharp.Tests/Auth/OAuth2ClientCredentialsAuthenticatorTests.cs +git commit -m "test: add tests for OAuth2 client credentials authenticator" +``` + +--- + +### Task 4: OAuth2RefreshTokenAuthenticator + +**Files:** +- Create: `src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs` + +**Step 1: Create the authenticator** + +```csharp +// Copyright (c) .NET Foundation and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text.Json; + +namespace RestSharp.Authenticators.OAuth2; + +/// +/// OAuth 2.0 Refresh Token authenticator. Uses an initial access token and refresh token pair, +/// automatically refreshing the access token when it expires using the refresh_token grant type. +/// Uses its own HttpClient for token endpoint calls, avoiding circular dependencies with RestClient. +/// Thread-safe for concurrent request usage. +/// +[PublicAPI] +public class OAuth2RefreshTokenAuthenticator : IAuthenticator, IDisposable { + readonly OAuth2TokenRequest _request; + readonly HttpClient _tokenClient; + readonly bool _disposeClient; + readonly SemaphoreSlim _lock = new(1, 1); + + string _accessToken; + string _refreshToken; + DateTimeOffset _tokenExpiry; + + /// Token endpoint configuration. + /// The initial access token. + /// The initial refresh token. + /// When the initial access token expires. Pass to force an immediate refresh. + public OAuth2RefreshTokenAuthenticator( + OAuth2TokenRequest request, + string accessToken, + string refreshToken, + DateTimeOffset expiresAt + ) { + _request = request; + _accessToken = accessToken; + _refreshToken = refreshToken; + _tokenExpiry = expiresAt; + + if (request.HttpClient != null) { + _tokenClient = request.HttpClient; + _disposeClient = false; + } + else { + _tokenClient = new HttpClient(); + _disposeClient = true; + } + } + + public async ValueTask Authenticate(IRestClient client, RestRequest request) { + var token = await GetOrRefreshTokenAsync().ConfigureAwait(false); + request.AddOrUpdateParameter(new HeaderParameter(KnownHeaders.Authorization, $"Bearer {token}")); + } + + async Task GetOrRefreshTokenAsync() { + if (DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + await _lock.WaitAsync().ConfigureAwait(false); + + try { + if (DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + var parameters = new Dictionary { + ["grant_type"] = "refresh_token", + ["client_id"] = _request.ClientId, + ["client_secret"] = _request.ClientSecret, + ["refresh_token"] = _refreshToken + }; + + if (_request.ExtraParameters != null) { + foreach (var (key, value) in _request.ExtraParameters) + parameters[key] = value; + } + + using var content = new FormUrlEncodedContent(parameters); + using var response = await _tokenClient.PostAsync(_request.TokenEndpointUrl, content).ConfigureAwait(false); + + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + throw new HttpRequestException($"Token refresh failed with status {response.StatusCode}: {body}"); + + var tokenResponse = JsonSerializer.Deserialize(body); + + if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.AccessToken)) + throw new InvalidOperationException($"Token endpoint returned an invalid response: {body}"); + + _accessToken = tokenResponse.AccessToken; + _tokenExpiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn) - _request.ExpiryBuffer; + + // Update refresh token if server rotates it + if (!string.IsNullOrEmpty(tokenResponse.RefreshToken)) + _refreshToken = tokenResponse.RefreshToken; + + _request.OnTokenRefreshed?.Invoke(tokenResponse); + + return _accessToken; + } + finally { + _lock.Release(); + } + } + + public void Dispose() { + if (_disposeClient) _tokenClient.Dispose(); + _lock.Dispose(); + } +} +``` + +**Step 2: Build to verify compilation** + +Run: `dotnet build RestSharp.slnx -c Debug` +Expected: BUILD SUCCEEDED + +**Step 3: Commit** + +```bash +git add src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs +git commit -m "feat: add OAuth2 refresh token authenticator with token lifecycle" +``` + +--- + +### Task 5: Tests for OAuth2RefreshTokenAuthenticator + +**Files:** +- Create: `test/RestSharp.Tests/Auth/OAuth2RefreshTokenAuthenticatorTests.cs` + +**Step 1: Write the test class** + +```csharp +using System.Net; +using RestSharp.Authenticators.OAuth2; +using RichardSzalay.MockHttp; + +namespace RestSharp.Tests.Auth; + +public class OAuth2RefreshTokenAuthenticatorTests : IDisposable { + const string TokenEndpoint = "https://auth.example.com/token"; + + static string TokenJson( + string accessToken = "new-access-token", + int expiresIn = 3600, + string refreshToken = null + ) { + var refresh = refreshToken != null ? $""","refresh_token":"{refreshToken}"""" : ""; + return $$"""{"access_token":"{{accessToken}}","token_type":"Bearer","expires_in":{{expiresIn}}{{refresh}}}"""; + } + + readonly MockHttpMessageHandler _mockHttp = new(); + + OAuth2RefreshTokenAuthenticator CreateAuthenticator( + string accessToken = "initial-access", + string refreshToken = "initial-refresh", + DateTimeOffset? expiresAt = null, + Action onRefreshed = null + ) { + var request = new OAuth2TokenRequest { + TokenEndpointUrl = TokenEndpoint, + ClientId = "my-client", + ClientSecret = "my-secret", + HttpClient = new HttpClient(_mockHttp), + OnTokenRefreshed = onRefreshed, + ExpiryBuffer = TimeSpan.Zero + }; + return new OAuth2RefreshTokenAuthenticator( + request, + accessToken, + refreshToken, + expiresAt ?? DateTimeOffset.MinValue + ); + } + + [Fact] + public async Task Should_use_initial_token_when_not_expired() { + using var auth = CreateAuthenticator(expiresAt: DateTimeOffset.UtcNow.AddHours(1)); + + var req = new RestRequest(); + await auth.Authenticate(null!, req); + + req.Parameters.First(p => p.Name == KnownHeaders.Authorization) + .Value.Should().Be("Bearer initial-access"); + } + + [Fact] + public async Task Should_refresh_when_token_expired() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", TokenJson()); + + using var auth = CreateAuthenticator(expiresAt: DateTimeOffset.MinValue); + + var req = new RestRequest(); + await auth.Authenticate(null!, req); + + req.Parameters.First(p => p.Name == KnownHeaders.Authorization) + .Value.Should().Be("Bearer new-access-token"); + } + + [Fact] + public async Task Should_send_refresh_token_in_request() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .WithFormData("refresh_token", "initial-refresh") + .WithFormData("grant_type", "refresh_token") + .Respond("application/json", TokenJson()); + + using var auth = CreateAuthenticator(); + await auth.Authenticate(null!, new RestRequest()); + // If refresh_token or grant_type weren't sent, mock wouldn't match + } + + [Fact] + public async Task Should_update_refresh_token_when_rotated() { + var callCount = 0; + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(_ => { + callCount++; + var json = callCount == 1 + ? TokenJson(accessToken: "token-1", expiresIn: 0, refreshToken: "rotated-refresh") + : TokenJson(accessToken: "token-2", expiresIn: 3600); + return new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + }); + + using var auth = CreateAuthenticator(); + + // First call: gets token-1 with rotated refresh token + await auth.Authenticate(null!, new RestRequest()); + // Second call: token-1 is expired (expiresIn=0), should use rotated-refresh + await auth.Authenticate(null!, new RestRequest()); + + callCount.Should().Be(2); + } + + [Fact] + public async Task Should_invoke_callback_on_refresh() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", TokenJson(refreshToken: "new-refresh")); + + OAuth2TokenResponse captured = null; + using var auth = CreateAuthenticator(onRefreshed: t => captured = t); + + await auth.Authenticate(null!, new RestRequest()); + + captured.Should().NotBeNull(); + captured!.AccessToken.Should().Be("new-access-token"); + captured.RefreshToken.Should().Be("new-refresh"); + } + + [Fact] + public async Task Should_throw_on_error_response() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(HttpStatusCode.Unauthorized, "application/json", """{"error":"invalid_grant"}"""); + + using var auth = CreateAuthenticator(); + + var act = () => auth.Authenticate(null!, new RestRequest()).AsTask(); + await act.Should().ThrowAsync() + .WithMessage("*401*invalid_grant*"); + } + + public void Dispose() => _mockHttp.Dispose(); +} +``` + +**Step 2: Run tests** + +Run: `dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj --filter "FullyQualifiedName~OAuth2RefreshTokenAuthenticator" -f net9.0` +Expected: All tests PASS + +**Step 3: Commit** + +```bash +git add test/RestSharp.Tests/Auth/OAuth2RefreshTokenAuthenticatorTests.cs +git commit -m "test: add tests for OAuth2 refresh token authenticator" +``` + +--- + +### Task 6: OAuth2TokenAuthenticator (generic/delegate-based) + +**Files:** +- Create: `src/RestSharp/Authenticators/OAuth2/OAuth2TokenAuthenticator.cs` + +**Step 1: Create the authenticator** + +```csharp +// Copyright (c) .NET Foundation and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace RestSharp.Authenticators.OAuth2; + +/// +/// Generic OAuth 2.0 authenticator that delegates token acquisition to a user-provided function. +/// Caches the token and re-invokes the delegate when the token expires. +/// Use this for non-standard OAuth2 flows or custom token providers. +/// Thread-safe for concurrent request usage. +/// +[PublicAPI] +public class OAuth2TokenAuthenticator : IAuthenticator, IDisposable { + readonly Func> _getToken; + readonly string _tokenType; + readonly SemaphoreSlim _lock = new(1, 1); + + string? _accessToken; + DateTimeOffset _tokenExpiry = DateTimeOffset.MinValue; + + /// Async delegate that returns an access token and its expiration time. + /// The token type for the Authorization header. Defaults to "Bearer". + public OAuth2TokenAuthenticator(Func> getToken, string tokenType = "Bearer") { + _getToken = getToken; + _tokenType = tokenType; + } + + public async ValueTask Authenticate(IRestClient client, RestRequest request) { + var token = await GetOrRefreshTokenAsync().ConfigureAwait(false); + request.AddOrUpdateParameter(new HeaderParameter(KnownHeaders.Authorization, $"{_tokenType} {token}")); + } + + async Task GetOrRefreshTokenAsync() { + if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + await _lock.WaitAsync().ConfigureAwait(false); + + try { + if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + var result = await _getToken(CancellationToken.None).ConfigureAwait(false); + _accessToken = result.AccessToken; + _tokenExpiry = result.ExpiresAt; + + return _accessToken; + } + finally { + _lock.Release(); + } + } + + public void Dispose() => _lock.Dispose(); +} +``` + +**Step 2: Build to verify compilation** + +Run: `dotnet build RestSharp.slnx -c Debug` +Expected: BUILD SUCCEEDED + +**Step 3: Commit** + +```bash +git add src/RestSharp/Authenticators/OAuth2/OAuth2TokenAuthenticator.cs +git commit -m "feat: add generic OAuth2 token authenticator with delegate provider" +``` + +--- + +### Task 7: Tests for OAuth2TokenAuthenticator + +**Files:** +- Create: `test/RestSharp.Tests/Auth/OAuth2TokenAuthenticatorTests.cs` + +**Step 1: Write the test class** + +```csharp +using RestSharp.Authenticators.OAuth2; + +namespace RestSharp.Tests.Auth; + +public class OAuth2TokenAuthenticatorTests { + [Fact] + public async Task Should_call_delegate_and_set_authorization_header() { + var token = new OAuth2Token("my-token", DateTimeOffset.UtcNow.AddHours(1)); + + using var auth = new OAuth2TokenAuthenticator(_ => Task.FromResult(token)); + var req = new RestRequest(); + await auth.Authenticate(null!, req); + + req.Parameters.First(p => p.Name == KnownHeaders.Authorization) + .Value.Should().Be("Bearer my-token"); + } + + [Fact] + public async Task Should_cache_token_across_calls() { + var callCount = 0; + var token = new OAuth2Token("my-token", DateTimeOffset.UtcNow.AddHours(1)); + + using var auth = new OAuth2TokenAuthenticator(_ => { + callCount++; + return Task.FromResult(token); + }); + + await auth.Authenticate(null!, new RestRequest()); + await auth.Authenticate(null!, new RestRequest()); + + callCount.Should().Be(1); + } + + [Fact] + public async Task Should_re_invoke_delegate_when_token_expired() { + var callCount = 0; + + using var auth = new OAuth2TokenAuthenticator(_ => { + callCount++; + // Always return a token that's already expired + var t = callCount == 1 + ? new OAuth2Token("token-1", DateTimeOffset.UtcNow.AddSeconds(-1)) + : new OAuth2Token("token-2", DateTimeOffset.UtcNow.AddHours(1)); + return Task.FromResult(t); + }); + + var req1 = new RestRequest(); + await auth.Authenticate(null!, req1); + req1.Parameters.First(p => p.Name == KnownHeaders.Authorization) + .Value.Should().Be("Bearer token-1"); + + var req2 = new RestRequest(); + await auth.Authenticate(null!, req2); + req2.Parameters.First(p => p.Name == KnownHeaders.Authorization) + .Value.Should().Be("Bearer token-2"); + + callCount.Should().Be(2); + } + + [Fact] + public async Task Should_use_custom_token_type() { + var token = new OAuth2Token("my-token", DateTimeOffset.UtcNow.AddHours(1)); + + using var auth = new OAuth2TokenAuthenticator(_ => Task.FromResult(token), tokenType: "MAC"); + var req = new RestRequest(); + await auth.Authenticate(null!, req); + + req.Parameters.First(p => p.Name == KnownHeaders.Authorization) + .Value.Should().Be("MAC my-token"); + } +} +``` + +**Step 2: Run tests** + +Run: `dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj --filter "FullyQualifiedName~OAuth2TokenAuthenticator" -f net9.0` +Expected: All tests PASS + +**Step 3: Commit** + +```bash +git add test/RestSharp.Tests/Auth/OAuth2TokenAuthenticatorTests.cs +git commit -m "test: add tests for generic OAuth2 token authenticator" +``` + +--- + +### Task 8: Full build and test verification + +**Step 1: Run the full build** + +Run: `dotnet build RestSharp.slnx -c Debug` +Expected: BUILD SUCCEEDED + +**Step 2: Run all unit tests** + +Run: `dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj -f net9.0` +Expected: All tests PASS (existing + new) + +**Step 3: Run tests on net8.0** + +Run: `dotnet test test/RestSharp.Tests/RestSharp.Tests.csproj -f net8.0` +Expected: All tests PASS + +**Step 4: Run the full solution tests** + +Run: `dotnet test RestSharp.slnx -c Debug` +Expected: All tests PASS From 680b3f702ceef5352cc123258230aade0a08d4b6 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Sun, 1 Mar 2026 16:21:43 +0100 Subject: [PATCH 03/12] feat: add OAuth2 token data models (RFC 6749) Co-Authored-By: Claude Opus 4.6 --- .../Authenticators/OAuth2/OAuth2Token.cs | 22 ++++++ .../OAuth2/OAuth2TokenRequest.cs | 75 +++++++++++++++++++ .../OAuth2/OAuth2TokenResponse.cs | 38 ++++++++++ 3 files changed, 135 insertions(+) create mode 100644 src/RestSharp/Authenticators/OAuth2/OAuth2Token.cs create mode 100644 src/RestSharp/Authenticators/OAuth2/OAuth2TokenRequest.cs create mode 100644 src/RestSharp/Authenticators/OAuth2/OAuth2TokenResponse.cs diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2Token.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2Token.cs new file mode 100644 index 000000000..fbf495af9 --- /dev/null +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2Token.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace RestSharp.Authenticators.OAuth2; + +/// +/// Represents an access token with its expiration time. Used as the return type +/// for the custom token provider delegate in . +/// +[PublicAPI] +public record OAuth2Token(string AccessToken, DateTimeOffset ExpiresAt); diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2TokenRequest.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2TokenRequest.cs new file mode 100644 index 000000000..73f5359d7 --- /dev/null +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2TokenRequest.cs @@ -0,0 +1,75 @@ +// Copyright (c) .NET Foundation and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace RestSharp.Authenticators.OAuth2; + +/// +/// Configuration for OAuth 2.0 token endpoint requests. Shared by +/// and . +/// +[PublicAPI] +public class OAuth2TokenRequest { + /// + /// Creates a new instance of . + /// + /// The URL of the OAuth 2.0 token endpoint. + /// The OAuth 2.0 client identifier. + /// The OAuth 2.0 client secret. + public OAuth2TokenRequest(string tokenEndpointUrl, string clientId, string clientSecret) { + TokenEndpointUrl = Ensure.NotNull(tokenEndpointUrl, nameof(tokenEndpointUrl)); + ClientId = Ensure.NotNull(clientId, nameof(clientId)); + ClientSecret = Ensure.NotNull(clientSecret, nameof(clientSecret)); + } + + /// + /// The URL of the OAuth 2.0 token endpoint. + /// + public string TokenEndpointUrl { get; } + + /// + /// The OAuth 2.0 client identifier. + /// + public string ClientId { get; } + + /// + /// The OAuth 2.0 client secret. + /// + public string ClientSecret { get; } + + /// + /// Optional scope to request. + /// + public string? Scope { get; init; } + + /// + /// Additional form parameters to include in the token request. + /// + public Dictionary? ExtraParameters { get; init; } + + /// + /// Optional HttpClient to use for token endpoint calls. When provided, the authenticator + /// will not create or dispose its own HttpClient. + /// + public HttpClient? HttpClient { get; init; } + + /// + /// How long before actual token expiry to consider it expired. Defaults to 30 seconds. + /// + public TimeSpan ExpiryBuffer { get; init; } = TimeSpan.FromSeconds(30); + + /// + /// Callback invoked when a new token is obtained. Use this to persist tokens to storage. + /// + public Action? OnTokenRefreshed { get; init; } +} diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2TokenResponse.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2TokenResponse.cs new file mode 100644 index 000000000..f3a745180 --- /dev/null +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2TokenResponse.cs @@ -0,0 +1,38 @@ +// Copyright (c) .NET Foundation and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text.Json.Serialization; + +namespace RestSharp.Authenticators.OAuth2; + +/// +/// OAuth 2.0 token endpoint response as defined in RFC 6749 Section 5.1. +/// +[PublicAPI] +public record OAuth2TokenResponse { + [JsonPropertyName("access_token")] + public string AccessToken { get; init; } = ""; + + [JsonPropertyName("token_type")] + public string TokenType { get; init; } = ""; + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; init; } + + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; init; } + + [JsonPropertyName("scope")] + public string? Scope { get; init; } +} From 520903d9b5039cb2bcd3368ab512f09d23f721f2 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Sun, 1 Mar 2026 16:26:42 +0100 Subject: [PATCH 04/12] feat: add OAuth2 client credentials authenticator with token lifecycle Co-Authored-By: Claude Opus 4.6 --- .../OAuth2ClientCredentialsAuthenticator.cs | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/RestSharp/Authenticators/OAuth2/OAuth2ClientCredentialsAuthenticator.cs diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2ClientCredentialsAuthenticator.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2ClientCredentialsAuthenticator.cs new file mode 100644 index 000000000..93392837d --- /dev/null +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2ClientCredentialsAuthenticator.cs @@ -0,0 +1,107 @@ +// Copyright (c) .NET Foundation and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text.Json; + +namespace RestSharp.Authenticators.OAuth2; + +/// +/// OAuth 2.0 Client Credentials authenticator. Automatically obtains and caches access tokens +/// from the token endpoint using the client_credentials grant type. +/// Uses its own HttpClient for token endpoint calls, avoiding circular dependencies with RestClient. +/// Thread-safe for concurrent request usage. +/// +[PublicAPI] +public class OAuth2ClientCredentialsAuthenticator : IAuthenticator, IDisposable { + readonly OAuth2TokenRequest _request; + readonly HttpClient _tokenClient; + readonly bool _disposeClient; + readonly SemaphoreSlim _lock = new(1, 1); + + string? _accessToken; + DateTimeOffset _tokenExpiry = DateTimeOffset.MinValue; + + public OAuth2ClientCredentialsAuthenticator(OAuth2TokenRequest request) { + _request = request; + + if (request.HttpClient != null) { + _tokenClient = request.HttpClient; + _disposeClient = false; + } + else { + _tokenClient = new HttpClient(); + _disposeClient = true; + } + } + + public async ValueTask Authenticate(IRestClient client, RestRequest request) { + var token = await GetOrRefreshTokenAsync().ConfigureAwait(false); + request.AddOrUpdateParameter(new HeaderParameter(KnownHeaders.Authorization, $"Bearer {token}")); + } + + async Task GetOrRefreshTokenAsync() { + if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + await _lock.WaitAsync().ConfigureAwait(false); + + try { + // Double-check after acquiring lock + if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + var parameters = new Dictionary { + ["grant_type"] = "client_credentials", + ["client_id"] = _request.ClientId, + ["client_secret"] = _request.ClientSecret + }; + + if (_request.Scope != null) + parameters["scope"] = _request.Scope; + + if (_request.ExtraParameters != null) { + foreach (var kvp in _request.ExtraParameters) + parameters[kvp.Key] = kvp.Value; + } + + using var content = new FormUrlEncodedContent(parameters); + using var response = await _tokenClient.PostAsync(_request.TokenEndpointUrl, content).ConfigureAwait(false); + + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + throw new HttpRequestException($"Token request failed with status {response.StatusCode}: {body}"); + + var tokenResponse = JsonSerializer.Deserialize(body); + + if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.AccessToken)) + throw new InvalidOperationException($"Token endpoint returned an invalid response: {body}"); + + _accessToken = tokenResponse.AccessToken; + _tokenExpiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn) - _request.ExpiryBuffer; + + _request.OnTokenRefreshed?.Invoke(tokenResponse); + + return _accessToken; + } + finally { + _lock.Release(); + } + } + + public void Dispose() { + if (_disposeClient) _tokenClient.Dispose(); + _lock.Dispose(); + } +} From 0e976f1c416f206cbafddae499c387f740571f0f Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Sun, 1 Mar 2026 16:27:16 +0100 Subject: [PATCH 05/12] test: add tests for OAuth2 client credentials authenticator Co-Authored-By: Claude Opus 4.6 --- ...uth2ClientCredentialsAuthenticatorTests.cs | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 test/RestSharp.Tests/Auth/OAuth2ClientCredentialsAuthenticatorTests.cs diff --git a/test/RestSharp.Tests/Auth/OAuth2ClientCredentialsAuthenticatorTests.cs b/test/RestSharp.Tests/Auth/OAuth2ClientCredentialsAuthenticatorTests.cs new file mode 100644 index 000000000..54060c346 --- /dev/null +++ b/test/RestSharp.Tests/Auth/OAuth2ClientCredentialsAuthenticatorTests.cs @@ -0,0 +1,175 @@ +using System.Net; +using RestSharp.Authenticators.OAuth2; +using RichardSzalay.MockHttp; + +namespace RestSharp.Tests.Auth; + +public class OAuth2ClientCredentialsAuthenticatorTests : IDisposable { + const string TokenEndpoint = "https://auth.example.com/token"; + const string ClientId = "my-client"; + const string ClientSecret = "my-secret"; + + readonly MockHttpMessageHandler _mockHttp = new(); + + static string TokenJson(string accessToken = "test-access-token", int expiresIn = 3600) + => $$"""{"access_token":"{{accessToken}}","token_type":"Bearer","expires_in":{{expiresIn}}}"""; + + OAuth2TokenRequest CreateRequest( + string scope = null, + TimeSpan? expiryBuffer = null, + Action onTokenRefreshed = null + ) { + var request = new OAuth2TokenRequest(TokenEndpoint, ClientId, ClientSecret) { + HttpClient = new HttpClient(_mockHttp), + ExpiryBuffer = expiryBuffer ?? TimeSpan.Zero + }; + + if (scope != null || onTokenRefreshed != null) { + return new OAuth2TokenRequest(TokenEndpoint, ClientId, ClientSecret) { + HttpClient = new HttpClient(_mockHttp), + ExpiryBuffer = expiryBuffer ?? TimeSpan.Zero, + Scope = scope, + OnTokenRefreshed = onTokenRefreshed + }; + } + + return request; + } + + [Fact] + public async Task Should_obtain_token_and_set_authorization_header() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", TokenJson()); + + using var authenticator = new OAuth2ClientCredentialsAuthenticator(CreateRequest()); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + var authHeader = request.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader.Should().NotBeNull(); + authHeader.Value.Should().Be("Bearer test-access-token"); + } + + [Fact] + public async Task Should_cache_token_across_multiple_calls() { + var callCount = 0; + + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(_ => { + Interlocked.Increment(ref callCount); + return new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent(TokenJson(), System.Text.Encoding.UTF8, "application/json") + }; + }); + + using var authenticator = new OAuth2ClientCredentialsAuthenticator(CreateRequest()); + + var request1 = new RestRequest(); + await authenticator.Authenticate(null!, request1); + + var request2 = new RestRequest(); + await authenticator.Authenticate(null!, request2); + + callCount.Should().Be(1); + } + + [Fact] + public async Task Should_refresh_expired_token() { + var callCount = 0; + + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(_ => { + var token = Interlocked.Increment(ref callCount) == 1 + ? TokenJson("first-token", 0) + : TokenJson("second-token", 3600); + + return new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent(token, System.Text.Encoding.UTF8, "application/json") + }; + }); + + using var authenticator = new OAuth2ClientCredentialsAuthenticator(CreateRequest()); + + var request1 = new RestRequest(); + await authenticator.Authenticate(null!, request1); + + var authHeader1 = request1.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader1.Value.Should().Be("Bearer first-token"); + + var request2 = new RestRequest(); + await authenticator.Authenticate(null!, request2); + + var authHeader2 = request2.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader2.Value.Should().Be("Bearer second-token"); + + callCount.Should().Be(2); + } + + [Fact] + public async Task Should_invoke_callback_on_token_refresh() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", TokenJson()); + + OAuth2TokenResponse capturedResponse = null; + using var authenticator = new OAuth2ClientCredentialsAuthenticator( + CreateRequest(onTokenRefreshed: r => capturedResponse = r) + ); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + capturedResponse.Should().NotBeNull(); + capturedResponse.AccessToken.Should().Be("test-access-token"); + capturedResponse.ExpiresIn.Should().Be(3600); + capturedResponse.TokenType.Should().Be("Bearer"); + } + + [Fact] + public async Task Should_throw_on_error_response() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(HttpStatusCode.BadRequest, "application/json", """{"error":"invalid_client"}"""); + + using var authenticator = new OAuth2ClientCredentialsAuthenticator(CreateRequest()); + + var request = new RestRequest(); + var act = () => authenticator.Authenticate(null!, request).AsTask(); + + await act.Should().ThrowAsync() + .WithMessage("*BadRequest*"); + } + + [Fact] + public async Task Should_throw_on_empty_access_token() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", """{"access_token":"","token_type":"Bearer","expires_in":3600}"""); + + using var authenticator = new OAuth2ClientCredentialsAuthenticator(CreateRequest()); + + var request = new RestRequest(); + var act = () => authenticator.Authenticate(null!, request).AsTask(); + + await act.Should().ThrowAsync() + .WithMessage("*invalid response*"); + } + + [Fact] + public async Task Should_send_scope_when_configured() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .WithFormData("scope", "read write") + .Respond("application/json", TokenJson()); + + using var authenticator = new OAuth2ClientCredentialsAuthenticator( + CreateRequest(scope: "read write") + ); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + var authHeader = request.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader.Should().NotBeNull(); + authHeader.Value.Should().Be("Bearer test-access-token"); + } + + public void Dispose() => _mockHttp.Dispose(); +} From c2883ba3f116472459b7dd554f4dca027b82d4ce Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Sun, 1 Mar 2026 16:51:37 +0100 Subject: [PATCH 06/12] feat: add OAuth2 refresh token authenticator with token lifecycle Co-Authored-By: Claude Opus 4.6 --- .../OAuth2/OAuth2RefreshTokenAuthenticator.cs | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs new file mode 100644 index 000000000..dc05cde3a --- /dev/null +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs @@ -0,0 +1,121 @@ +// Copyright (c) .NET Foundation and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text.Json; + +namespace RestSharp.Authenticators.OAuth2; + +/// +/// OAuth 2.0 Refresh Token authenticator. Uses an initial access token and refresh token pair, +/// automatically refreshing the access token when it expires using the refresh_token grant type. +/// Uses its own HttpClient for token endpoint calls, avoiding circular dependencies with RestClient. +/// Thread-safe for concurrent request usage. +/// +[PublicAPI] +public class OAuth2RefreshTokenAuthenticator : IAuthenticator, IDisposable { + readonly OAuth2TokenRequest _request; + readonly HttpClient _tokenClient; + readonly bool _disposeClient; + readonly SemaphoreSlim _lock = new(1, 1); + + string _accessToken; + string _refreshToken; + DateTimeOffset _tokenExpiry; + + /// Token endpoint configuration. + /// The initial access token. + /// The initial refresh token. + /// When the initial access token expires. Pass to force an immediate refresh. + public OAuth2RefreshTokenAuthenticator( + OAuth2TokenRequest request, + string accessToken, + string refreshToken, + DateTimeOffset expiresAt + ) { + _request = request; + _accessToken = accessToken; + _refreshToken = refreshToken; + _tokenExpiry = expiresAt; + + if (request.HttpClient != null) { + _tokenClient = request.HttpClient; + _disposeClient = false; + } + else { + _tokenClient = new HttpClient(); + _disposeClient = true; + } + } + + public async ValueTask Authenticate(IRestClient client, RestRequest request) { + var token = await GetOrRefreshTokenAsync().ConfigureAwait(false); + request.AddOrUpdateParameter(new HeaderParameter(KnownHeaders.Authorization, $"Bearer {token}")); + } + + async Task GetOrRefreshTokenAsync() { + if (DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + await _lock.WaitAsync().ConfigureAwait(false); + + try { + if (DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + var parameters = new Dictionary { + ["grant_type"] = "refresh_token", + ["client_id"] = _request.ClientId, + ["client_secret"] = _request.ClientSecret, + ["refresh_token"] = _refreshToken + }; + + if (_request.ExtraParameters != null) { + foreach (var kvp in _request.ExtraParameters) + parameters[kvp.Key] = kvp.Value; + } + + using var content = new FormUrlEncodedContent(parameters); + using var response = await _tokenClient.PostAsync(_request.TokenEndpointUrl, content).ConfigureAwait(false); + + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + throw new HttpRequestException($"Token refresh failed with status {response.StatusCode}: {body}"); + + var tokenResponse = JsonSerializer.Deserialize(body); + + if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.AccessToken)) + throw new InvalidOperationException($"Token endpoint returned an invalid response: {body}"); + + _accessToken = tokenResponse.AccessToken; + _tokenExpiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn) - _request.ExpiryBuffer; + + // Update refresh token if server rotates it + if (!string.IsNullOrEmpty(tokenResponse.RefreshToken)) + _refreshToken = tokenResponse.RefreshToken; + + _request.OnTokenRefreshed?.Invoke(tokenResponse); + + return _accessToken; + } + finally { + _lock.Release(); + } + } + + public void Dispose() { + if (_disposeClient) _tokenClient.Dispose(); + _lock.Dispose(); + } +} From 89de0f4a6bd6d47cdeb8b4fff00fedcf1b69b015 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Sun, 1 Mar 2026 16:52:27 +0100 Subject: [PATCH 07/12] test: add tests for OAuth2 refresh token authenticator Co-Authored-By: Claude Opus 4.6 --- .../OAuth2RefreshTokenAuthenticatorTests.cs | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 test/RestSharp.Tests/Auth/OAuth2RefreshTokenAuthenticatorTests.cs diff --git a/test/RestSharp.Tests/Auth/OAuth2RefreshTokenAuthenticatorTests.cs b/test/RestSharp.Tests/Auth/OAuth2RefreshTokenAuthenticatorTests.cs new file mode 100644 index 000000000..d13dc2315 --- /dev/null +++ b/test/RestSharp.Tests/Auth/OAuth2RefreshTokenAuthenticatorTests.cs @@ -0,0 +1,184 @@ +using System.Net; +using RestSharp.Authenticators.OAuth2; +using RichardSzalay.MockHttp; + +namespace RestSharp.Tests.Auth; + +public class OAuth2RefreshTokenAuthenticatorTests : IDisposable { + const string TokenEndpoint = "https://auth.example.com/token"; + const string ClientId = "my-client"; + const string ClientSecret = "my-secret"; + const string InitialAccess = "initial-access-token"; + const string InitialRefresh = "initial-refresh-token"; + + readonly MockHttpMessageHandler _mockHttp = new(); + + static string TokenJson( + string accessToken = "new-access-token", + int expiresIn = 3600, + string refreshToken = null + ) { + var refreshPart = refreshToken != null + ? $""","refresh_token":"{refreshToken}" """ + : ""; + return $$"""{"access_token":"{{accessToken}}","token_type":"Bearer","expires_in":{{expiresIn}}{{refreshPart}}}"""; + } + + OAuth2TokenRequest CreateRequest( + TimeSpan? expiryBuffer = null, + Action onTokenRefreshed = null + ) => + new(TokenEndpoint, ClientId, ClientSecret) { + HttpClient = new HttpClient(_mockHttp), + ExpiryBuffer = expiryBuffer ?? TimeSpan.Zero, + OnTokenRefreshed = onTokenRefreshed + }; + + [Fact] + public async Task Should_use_initial_token_when_not_expired() { + // No mock response needed — the token endpoint should not be called + using var authenticator = new OAuth2RefreshTokenAuthenticator( + CreateRequest(), + InitialAccess, + InitialRefresh, + DateTimeOffset.UtcNow.AddHours(1) + ); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + var authHeader = request.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader.Should().NotBeNull(); + authHeader.Value.Should().Be($"Bearer {InitialAccess}"); + } + + [Fact] + public async Task Should_refresh_when_token_expired() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", TokenJson()); + + using var authenticator = new OAuth2RefreshTokenAuthenticator( + CreateRequest(), + InitialAccess, + InitialRefresh, + DateTimeOffset.MinValue + ); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + var authHeader = request.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader.Should().NotBeNull(); + authHeader.Value.Should().Be("Bearer new-access-token"); + } + + [Fact] + public async Task Should_send_refresh_token_in_request() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .WithFormData("grant_type", "refresh_token") + .WithFormData("refresh_token", InitialRefresh) + .WithFormData("client_id", ClientId) + .WithFormData("client_secret", ClientSecret) + .Respond("application/json", TokenJson()); + + using var authenticator = new OAuth2RefreshTokenAuthenticator( + CreateRequest(), + InitialAccess, + InitialRefresh, + DateTimeOffset.MinValue + ); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + var authHeader = request.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader.Should().NotBeNull(); + authHeader.Value.Should().Be("Bearer new-access-token"); + } + + [Fact] + public async Task Should_update_refresh_token_when_rotated() { + var callCount = 0; + const string rotatedRefresh = "rotated-refresh-token"; + + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(_ => { + var count = Interlocked.Increment(ref callCount); + + var json = count == 1 + ? TokenJson("first-access", 0, rotatedRefresh) + : TokenJson("second-access", 3600); + + return new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + }); + + using var authenticator = new OAuth2RefreshTokenAuthenticator( + CreateRequest(), + InitialAccess, + InitialRefresh, + DateTimeOffset.MinValue + ); + + // First call: expires immediately (expiresIn=0), returns rotated refresh token + var request1 = new RestRequest(); + await authenticator.Authenticate(null!, request1); + + var authHeader1 = request1.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader1.Value.Should().Be("Bearer first-access"); + + // Second call: token expired (expiresIn was 0), should use rotated refresh token + var request2 = new RestRequest(); + await authenticator.Authenticate(null!, request2); + + var authHeader2 = request2.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader2.Value.Should().Be("Bearer second-access"); + + callCount.Should().Be(2); + } + + [Fact] + public async Task Should_invoke_callback_on_refresh() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond("application/json", TokenJson()); + + OAuth2TokenResponse capturedResponse = null; + + using var authenticator = new OAuth2RefreshTokenAuthenticator( + CreateRequest(onTokenRefreshed: r => capturedResponse = r), + InitialAccess, + InitialRefresh, + DateTimeOffset.MinValue + ); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + capturedResponse.Should().NotBeNull(); + capturedResponse.AccessToken.Should().Be("new-access-token"); + capturedResponse.ExpiresIn.Should().Be(3600); + capturedResponse.TokenType.Should().Be("Bearer"); + } + + [Fact] + public async Task Should_throw_on_error_response() { + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(HttpStatusCode.Unauthorized, "application/json", """{"error":"invalid_grant"}"""); + + using var authenticator = new OAuth2RefreshTokenAuthenticator( + CreateRequest(), + InitialAccess, + InitialRefresh, + DateTimeOffset.MinValue + ); + + var request = new RestRequest(); + var act = () => authenticator.Authenticate(null!, request).AsTask(); + + await act.Should().ThrowAsync() + .WithMessage("*Unauthorized*"); + } + + public void Dispose() => _mockHttp.Dispose(); +} From d53fa7eea5f965688f1fd96ee32908e9dde994a4 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Sun, 1 Mar 2026 16:56:59 +0100 Subject: [PATCH 08/12] feat: add generic OAuth2 token authenticator with delegate provider Co-Authored-By: Claude Opus 4.6 --- .../OAuth2/OAuth2TokenAuthenticator.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/RestSharp/Authenticators/OAuth2/OAuth2TokenAuthenticator.cs diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2TokenAuthenticator.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2TokenAuthenticator.cs new file mode 100644 index 000000000..ea1e84aa6 --- /dev/null +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2TokenAuthenticator.cs @@ -0,0 +1,66 @@ +// Copyright (c) .NET Foundation and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace RestSharp.Authenticators.OAuth2; + +/// +/// Generic OAuth 2.0 authenticator that delegates token acquisition to a user-provided function. +/// Caches the token and re-invokes the delegate when the token expires. +/// Use this for non-standard OAuth2 flows or custom token providers. +/// Thread-safe for concurrent request usage. +/// +[PublicAPI] +public class OAuth2TokenAuthenticator : IAuthenticator, IDisposable { + readonly Func> _getToken; + readonly string _tokenType; + readonly SemaphoreSlim _lock = new(1, 1); + + string? _accessToken; + DateTimeOffset _tokenExpiry = DateTimeOffset.MinValue; + + /// Async delegate that returns an access token and its expiration time. + /// The token type for the Authorization header. Defaults to "Bearer". + public OAuth2TokenAuthenticator(Func> getToken, string tokenType = "Bearer") { + _getToken = getToken; + _tokenType = tokenType; + } + + public async ValueTask Authenticate(IRestClient client, RestRequest request) { + var token = await GetOrRefreshTokenAsync().ConfigureAwait(false); + request.AddOrUpdateParameter(new HeaderParameter(KnownHeaders.Authorization, $"{_tokenType} {token}")); + } + + async Task GetOrRefreshTokenAsync() { + if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + await _lock.WaitAsync().ConfigureAwait(false); + + try { + if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + var result = await _getToken(CancellationToken.None).ConfigureAwait(false); + _accessToken = result.AccessToken; + _tokenExpiry = result.ExpiresAt; + + return _accessToken; + } + finally { + _lock.Release(); + } + } + + public void Dispose() => _lock.Dispose(); +} From 999b7e9d4787953ce8f6813b92cf9ca86b2ae932 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Sun, 1 Mar 2026 16:57:04 +0100 Subject: [PATCH 09/12] test: add tests for generic OAuth2 token authenticator Co-Authored-By: Claude Opus 4.6 --- .../Auth/OAuth2TokenAuthenticatorTests.cs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 test/RestSharp.Tests/Auth/OAuth2TokenAuthenticatorTests.cs diff --git a/test/RestSharp.Tests/Auth/OAuth2TokenAuthenticatorTests.cs b/test/RestSharp.Tests/Auth/OAuth2TokenAuthenticatorTests.cs new file mode 100644 index 000000000..cd7be1c89 --- /dev/null +++ b/test/RestSharp.Tests/Auth/OAuth2TokenAuthenticatorTests.cs @@ -0,0 +1,97 @@ +using RestSharp.Authenticators.OAuth2; + +namespace RestSharp.Tests.Auth; + +public class OAuth2TokenAuthenticatorTests : IDisposable { + readonly List _authenticators = new(); + + OAuth2TokenAuthenticator CreateAuthenticator( + Func> getToken, + string tokenType = "Bearer" + ) { + var auth = new OAuth2TokenAuthenticator(getToken, tokenType); + _authenticators.Add(auth); + return auth; + } + + [Fact] + public async Task Should_call_delegate_and_set_authorization_header() { + var authenticator = CreateAuthenticator( + _ => Task.FromResult(new OAuth2Token("my-token", DateTimeOffset.UtcNow.AddHours(1))) + ); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + var authHeader = request.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader.Should().NotBeNull(); + authHeader.Value.Should().Be("Bearer my-token"); + } + + [Fact] + public async Task Should_cache_token_across_calls() { + var callCount = 0; + + var authenticator = CreateAuthenticator(_ => { + Interlocked.Increment(ref callCount); + return Task.FromResult(new OAuth2Token("my-token", DateTimeOffset.UtcNow.AddHours(1))); + }); + + var request1 = new RestRequest(); + await authenticator.Authenticate(null!, request1); + + var request2 = new RestRequest(); + await authenticator.Authenticate(null!, request2); + + callCount.Should().Be(1); + } + + [Fact] + public async Task Should_re_invoke_delegate_when_token_expired() { + var callCount = 0; + + var authenticator = CreateAuthenticator(_ => { + var count = Interlocked.Increment(ref callCount); + + var token = count == 1 + ? new OAuth2Token("first-token", DateTimeOffset.UtcNow.AddSeconds(-1)) + : new OAuth2Token("second-token", DateTimeOffset.UtcNow.AddHours(1)); + + return Task.FromResult(token); + }); + + var request1 = new RestRequest(); + await authenticator.Authenticate(null!, request1); + + var authHeader1 = request1.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader1.Value.Should().Be("Bearer first-token"); + + var request2 = new RestRequest(); + await authenticator.Authenticate(null!, request2); + + var authHeader2 = request2.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader2.Value.Should().Be("Bearer second-token"); + + callCount.Should().Be(2); + } + + [Fact] + public async Task Should_use_custom_token_type() { + var authenticator = CreateAuthenticator( + _ => Task.FromResult(new OAuth2Token("my-token", DateTimeOffset.UtcNow.AddHours(1))), + tokenType: "MAC" + ); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + var authHeader = request.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader.Should().NotBeNull(); + authHeader.Value.Should().Be("MAC my-token"); + } + + public void Dispose() { + foreach (var auth in _authenticators) + auth.Dispose(); + } +} From 770b56402eca9ed82fe41042031fd559d2b593c5 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Sun, 1 Mar 2026 17:09:37 +0100 Subject: [PATCH 10/12] docs: add documentation for OAuth2 token lifecycle authenticators Co-Authored-By: Claude Opus 4.6 --- docs/docs/advanced/authenticators.md | 94 ++++++++++++++++++++++++++-- docs/docs/usage/example.md | 81 +++++++----------------- 2 files changed, 111 insertions(+), 64 deletions(-) diff --git a/docs/docs/advanced/authenticators.md b/docs/docs/advanced/authenticators.md index 14196d97c..c80deb891 100644 --- a/docs/docs/advanced/authenticators.md +++ b/docs/docs/advanced/authenticators.md @@ -128,7 +128,95 @@ var authenticator = OAuth1Authenticator.ForAccessToken( ## OAuth2 -RestSharp has two very simple authenticators to send the access token as part of the request. +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. + +### Token lifecycle authenticators + +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. + +#### Client credentials + +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. + +```csharp +var request = new OAuth2TokenRequest( + "https://auth.example.com/oauth2/token", + "my-client-id", + "my-client-secret" +) { + Scope = "api.read api.write" +}; + +var options = new RestClientOptions("https://api.example.com") { + Authenticator = new OAuth2ClientCredentialsAuthenticator(request) +}; +using var client = new RestClient(options); +``` + +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. + +#### Refresh token + +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. + +```csharp +var request = new OAuth2TokenRequest( + "https://auth.example.com/oauth2/token", + "my-client-id", + "my-client-secret" +) { + OnTokenRefreshed = response => { + // Persist the new tokens to your storage + SaveTokens(response.AccessToken, response.RefreshToken); + } +}; + +var options = new RestClientOptions("https://api.example.com") { + Authenticator = new OAuth2RefreshTokenAuthenticator( + request, + accessToken: "current-access-token", + refreshToken: "current-refresh-token", + expiresAt: DateTimeOffset.UtcNow.AddMinutes(30) + ) +}; +using var client = new RestClient(options); +``` + +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. + +#### Custom token provider + +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`: + +```csharp +var options = new RestClientOptions("https://api.example.com") { + Authenticator = new OAuth2TokenAuthenticator(async cancellationToken => { + var token = await myCustomTokenService.GetTokenAsync(cancellationToken); + return new OAuth2Token(token.Value, token.ExpiresAt); + }) +}; +using var client = new RestClient(options); +``` + +The authenticator caches the result and re-invokes your delegate when the token expires. + +#### Bringing your own HttpClient + +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: + +```csharp +var request = new OAuth2TokenRequest( + "https://auth.example.com/oauth2/token", + "my-client-id", + "my-client-secret" +) { + HttpClient = myCustomHttpClient // not disposed by the authenticator +}; +``` + +### Simple authenticators + +If you manage tokens yourself and just need to stamp them onto requests, use these simpler authenticators. `OAuth2UriQueryParameterAuthenticator` accepts the access token as the only constructor argument, and it will send the provided token as a query parameter `oauth_token`. @@ -148,8 +236,6 @@ var client = new RestClient(options); 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. -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. - ## JWT 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); The `Authenticate` method is the very first thing called upon calling `RestClient.Execute` or `RestClient.Execute`. It gets the `RestRequest` currently being executed giving you access to every part of the request data (headers, parameters, etc.) -You can find an example of a custom authenticator that fetches and uses an OAuth2 bearer token [here](../usage/example.md#authenticator). +You can find an example of using the built-in OAuth2 authenticator in a typed API client [here](../usage/example.md#authenticator). diff --git a/docs/docs/usage/example.md b/docs/docs/usage/example.md index 6182d8ff3..ed3b907bb 100644 --- a/docs/docs/usage/example.md +++ b/docs/docs/usage/example.md @@ -45,7 +45,14 @@ public class TwitterClient : ITwitterClient, IDisposable { readonly RestClient _client; public TwitterClient(string apiKey, string apiKeySecret) { - var options = new RestClientOptions("https://api.twitter.com/2"); + var tokenRequest = new OAuth2TokenRequest( + "https://api.twitter.com/oauth2/token", + apiKey, + apiKeySecret + ); + var options = new RestClientOptions("https://api.twitter.com/2") { + Authenticator = new OAuth2ClientCredentialsAuthenticator(tokenRequest) + }; _client = new RestClient(options); } @@ -79,73 +86,27 @@ public TwitterClient(IOptions options) { Then, you can register and configure the client using ASP.NET Core dependency injection container. -Right now, the client won't really work as Twitter API requires authentication. It's covered in the next section. +Notice the client constructor already configures the `OAuth2ClientCredentialsAuthenticator`. The authenticator setup is described in the next section. ## Authenticator -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. - -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: - -```csharp -record TokenResponse { - [JsonPropertyName("token_type")] - public string TokenType { get; init; } - [JsonPropertyName("access_token")] - public string AccessToken { get; init; } -} -``` - -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. - -The easiest way to create an authenticator is to inherit from the `AuthenticatorBase` base class: - -```csharp -public class TwitterAuthenticator : AuthenticatorBase { - readonly string _baseUrl; - readonly string _clientId; - readonly string _clientSecret; - - public TwitterAuthenticator(string baseUrl, string clientId, string clientSecret) : base("") { - _baseUrl = baseUrl; - _clientId = clientId; - _clientSecret = clientSecret; - } - - protected override async ValueTask GetAuthenticationParameter(string accessToken) { - Token = string.IsNullOrEmpty(Token) ? await GetToken() : Token; - return new HeaderParameter(KnownHeaders.Authorization, Token); - } -} -``` - -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. - -Now, we need to implement the `GetToken` function in the class: +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`: ```csharp -async Task GetToken() { - var options = new RestClientOptions(_baseUrl){ - Authenticator = new HttpBasicAuthenticator(_clientId, _clientSecret), - }; - using var client = new RestClient(options); - - var request = new RestRequest("oauth2/token") - .AddParameter("grant_type", "client_credentials"); - var response = await client.PostAsync(request); - return $"{response!.TokenType} {response!.AccessToken}"; -} +var tokenRequest = new OAuth2TokenRequest( + "https://api.twitter.com/oauth2/token", + apiKey, + apiKeySecret +); + +var options = new RestClientOptions("https://api.twitter.com/2") { + Authenticator = new OAuth2ClientCredentialsAuthenticator(tokenRequest) +}; ``` -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. - -Here we add a POST parameter `grant_type` with `client_credentials` as its value. At the moment, it's the only supported value. - -The POST request will use the `application/x-www-form-urlencoded` content type by default. +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`. -::: note -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. -::: +For more details on the available OAuth2 authenticators (including refresh token flows and custom token providers), see [Authenticators](../advanced/authenticators.md#oauth2). ## Final words From ae15387771c16daf3c10d904fb575d39aa959dc9 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Sun, 1 Mar 2026 17:11:38 +0100 Subject: [PATCH 11/12] 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 --- .../OAuth2ClientCredentialsAuthenticator.cs | 95 ++------------- .../OAuth2/OAuth2EndpointAuthenticatorBase.cs | 115 ++++++++++++++++++ .../OAuth2/OAuth2RefreshTokenAuthenticator.cs | 96 +++------------ 3 files changed, 142 insertions(+), 164 deletions(-) create mode 100644 src/RestSharp/Authenticators/OAuth2/OAuth2EndpointAuthenticatorBase.cs diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2ClientCredentialsAuthenticator.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2ClientCredentialsAuthenticator.cs index 93392837d..2c89fbe82 100644 --- a/src/RestSharp/Authenticators/OAuth2/OAuth2ClientCredentialsAuthenticator.cs +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2ClientCredentialsAuthenticator.cs @@ -12,8 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System.Text.Json; - namespace RestSharp.Authenticators.OAuth2; /// @@ -23,85 +21,18 @@ namespace RestSharp.Authenticators.OAuth2; /// Thread-safe for concurrent request usage. /// [PublicAPI] -public class OAuth2ClientCredentialsAuthenticator : IAuthenticator, IDisposable { - readonly OAuth2TokenRequest _request; - readonly HttpClient _tokenClient; - readonly bool _disposeClient; - readonly SemaphoreSlim _lock = new(1, 1); - - string? _accessToken; - DateTimeOffset _tokenExpiry = DateTimeOffset.MinValue; - - public OAuth2ClientCredentialsAuthenticator(OAuth2TokenRequest request) { - _request = request; - - if (request.HttpClient != null) { - _tokenClient = request.HttpClient; - _disposeClient = false; - } - else { - _tokenClient = new HttpClient(); - _disposeClient = true; - } - } - - public async ValueTask Authenticate(IRestClient client, RestRequest request) { - var token = await GetOrRefreshTokenAsync().ConfigureAwait(false); - request.AddOrUpdateParameter(new HeaderParameter(KnownHeaders.Authorization, $"Bearer {token}")); - } - - async Task GetOrRefreshTokenAsync() { - if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) - return _accessToken; - - await _lock.WaitAsync().ConfigureAwait(false); - - try { - // Double-check after acquiring lock - if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) - return _accessToken; - - var parameters = new Dictionary { - ["grant_type"] = "client_credentials", - ["client_id"] = _request.ClientId, - ["client_secret"] = _request.ClientSecret - }; - - if (_request.Scope != null) - parameters["scope"] = _request.Scope; - - if (_request.ExtraParameters != null) { - foreach (var kvp in _request.ExtraParameters) - parameters[kvp.Key] = kvp.Value; - } - - using var content = new FormUrlEncodedContent(parameters); - using var response = await _tokenClient.PostAsync(_request.TokenEndpointUrl, content).ConfigureAwait(false); - - var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - - if (!response.IsSuccessStatusCode) - throw new HttpRequestException($"Token request failed with status {response.StatusCode}: {body}"); - - var tokenResponse = JsonSerializer.Deserialize(body); - - if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.AccessToken)) - throw new InvalidOperationException($"Token endpoint returned an invalid response: {body}"); - - _accessToken = tokenResponse.AccessToken; - _tokenExpiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn) - _request.ExpiryBuffer; - - _request.OnTokenRefreshed?.Invoke(tokenResponse); - - return _accessToken; - } - finally { - _lock.Release(); - } - } - - public void Dispose() { - if (_disposeClient) _tokenClient.Dispose(); - _lock.Dispose(); +public class OAuth2ClientCredentialsAuthenticator(OAuth2TokenRequest request) + : OAuth2EndpointAuthenticatorBase(request) { + protected override Dictionary BuildRequestParameters() { + var parameters = new Dictionary { + ["grant_type"] = "client_credentials", + ["client_id"] = TokenRequest.ClientId, + ["client_secret"] = TokenRequest.ClientSecret + }; + + if (TokenRequest.Scope != null) + parameters["scope"] = TokenRequest.Scope; + + return parameters; } } diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2EndpointAuthenticatorBase.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2EndpointAuthenticatorBase.cs new file mode 100644 index 000000000..052b56462 --- /dev/null +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2EndpointAuthenticatorBase.cs @@ -0,0 +1,115 @@ +// Copyright (c) .NET Foundation and Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Text.Json; + +namespace RestSharp.Authenticators.OAuth2; + +/// +/// Base class for OAuth 2.0 authenticators that call a token endpoint. +/// Handles HttpClient lifecycle, thread-safe token caching with double-check locking, +/// token response parsing, error handling, and the OnTokenRefreshed callback. +/// +public abstract class OAuth2EndpointAuthenticatorBase : IAuthenticator, IDisposable { + readonly HttpClient _tokenClient; + readonly bool _disposeClient; + readonly SemaphoreSlim _lock = new(1, 1); + + protected OAuth2TokenRequest TokenRequest { get; } + + string? _accessToken; + DateTimeOffset _tokenExpiry = DateTimeOffset.MinValue; + + protected OAuth2EndpointAuthenticatorBase(OAuth2TokenRequest request) { + TokenRequest = request; + + if (request.HttpClient != null) { + _tokenClient = request.HttpClient; + _disposeClient = false; + } + else { + _tokenClient = new HttpClient(); + _disposeClient = true; + } + } + + protected void SetInitialToken(string accessToken, DateTimeOffset expiresAt) { + _accessToken = accessToken; + _tokenExpiry = expiresAt; + } + + public async ValueTask Authenticate(IRestClient client, RestRequest request) { + var token = await GetOrRefreshTokenAsync().ConfigureAwait(false); + request.AddOrUpdateParameter(new HeaderParameter(KnownHeaders.Authorization, $"Bearer {token}")); + } + + /// + /// Build the grant-specific form parameters for the token request. + /// + protected abstract Dictionary BuildRequestParameters(); + + /// + /// Called after a successful token response. Override to handle grant-specific + /// fields such as refresh token rotation. + /// + protected virtual void OnTokenResponse(OAuth2TokenResponse response) { } + + async Task GetOrRefreshTokenAsync() { + if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + await _lock.WaitAsync().ConfigureAwait(false); + + try { + if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) + return _accessToken; + + var parameters = BuildRequestParameters(); + + if (TokenRequest.ExtraParameters != null) { + foreach (var kvp in TokenRequest.ExtraParameters) + parameters[kvp.Key] = kvp.Value; + } + + using var content = new FormUrlEncodedContent(parameters); + using var response = await _tokenClient.PostAsync(TokenRequest.TokenEndpointUrl, content).ConfigureAwait(false); + + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + throw new HttpRequestException($"Token request failed with status {response.StatusCode}: {body}"); + + var tokenResponse = JsonSerializer.Deserialize(body); + + if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.AccessToken)) + throw new InvalidOperationException($"Token endpoint returned an invalid response: {body}"); + + _accessToken = tokenResponse.AccessToken; + _tokenExpiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn) - TokenRequest.ExpiryBuffer; + + OnTokenResponse(tokenResponse); + TokenRequest.OnTokenRefreshed?.Invoke(tokenResponse); + + return _accessToken; + } + finally { + _lock.Release(); + } + } + + public void Dispose() { + if (_disposeClient) _tokenClient.Dispose(); + _lock.Dispose(); + } +} diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs index dc05cde3a..0bd7ee526 100644 --- a/src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs @@ -12,8 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System.Text.Json; - namespace RestSharp.Authenticators.OAuth2; /// @@ -23,15 +21,8 @@ namespace RestSharp.Authenticators.OAuth2; /// Thread-safe for concurrent request usage. /// [PublicAPI] -public class OAuth2RefreshTokenAuthenticator : IAuthenticator, IDisposable { - readonly OAuth2TokenRequest _request; - readonly HttpClient _tokenClient; - readonly bool _disposeClient; - readonly SemaphoreSlim _lock = new(1, 1); - - string _accessToken; +public class OAuth2RefreshTokenAuthenticator : OAuth2EndpointAuthenticatorBase { string _refreshToken; - DateTimeOffset _tokenExpiry; /// Token endpoint configuration. /// The initial access token. @@ -42,80 +33,21 @@ public OAuth2RefreshTokenAuthenticator( string accessToken, string refreshToken, DateTimeOffset expiresAt - ) { - _request = request; - _accessToken = accessToken; + ) : base(request) { _refreshToken = refreshToken; - _tokenExpiry = expiresAt; - - if (request.HttpClient != null) { - _tokenClient = request.HttpClient; - _disposeClient = false; - } - else { - _tokenClient = new HttpClient(); - _disposeClient = true; - } - } - - public async ValueTask Authenticate(IRestClient client, RestRequest request) { - var token = await GetOrRefreshTokenAsync().ConfigureAwait(false); - request.AddOrUpdateParameter(new HeaderParameter(KnownHeaders.Authorization, $"Bearer {token}")); - } - - async Task GetOrRefreshTokenAsync() { - if (DateTimeOffset.UtcNow < _tokenExpiry) - return _accessToken; - - await _lock.WaitAsync().ConfigureAwait(false); - - try { - if (DateTimeOffset.UtcNow < _tokenExpiry) - return _accessToken; - - var parameters = new Dictionary { - ["grant_type"] = "refresh_token", - ["client_id"] = _request.ClientId, - ["client_secret"] = _request.ClientSecret, - ["refresh_token"] = _refreshToken - }; - - if (_request.ExtraParameters != null) { - foreach (var kvp in _request.ExtraParameters) - parameters[kvp.Key] = kvp.Value; - } - - using var content = new FormUrlEncodedContent(parameters); - using var response = await _tokenClient.PostAsync(_request.TokenEndpointUrl, content).ConfigureAwait(false); - - var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - - if (!response.IsSuccessStatusCode) - throw new HttpRequestException($"Token refresh failed with status {response.StatusCode}: {body}"); - - var tokenResponse = JsonSerializer.Deserialize(body); - - if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.AccessToken)) - throw new InvalidOperationException($"Token endpoint returned an invalid response: {body}"); - - _accessToken = tokenResponse.AccessToken; - _tokenExpiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn) - _request.ExpiryBuffer; - - // Update refresh token if server rotates it - if (!string.IsNullOrEmpty(tokenResponse.RefreshToken)) - _refreshToken = tokenResponse.RefreshToken; - - _request.OnTokenRefreshed?.Invoke(tokenResponse); - - return _accessToken; - } - finally { - _lock.Release(); - } + SetInitialToken(accessToken, expiresAt); } - public void Dispose() { - if (_disposeClient) _tokenClient.Dispose(); - _lock.Dispose(); + protected override Dictionary BuildRequestParameters() + => new() { + ["grant_type"] = "refresh_token", + ["client_id"] = TokenRequest.ClientId, + ["client_secret"] = TokenRequest.ClientSecret, + ["refresh_token"] = _refreshToken + }; + + protected override void OnTokenResponse(OAuth2TokenResponse response) { + if (!string.IsNullOrEmpty(response.RefreshToken)) + _refreshToken = response.RefreshToken; } } From 267475755aaf4b03c8a6e055ad4f7f5795c181ff Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Sun, 1 Mar 2026 17:22:07 +0100 Subject: [PATCH 12/12] 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 --- .../Authenticators/AuthenticatorBase.cs | 2 +- .../Authenticators/IAuthenticator.cs | 2 +- .../OAuth/OAuth1Authenticator.cs | 2 +- .../OAuth2/OAuth2EndpointAuthenticatorBase.cs | 14 +++++----- .../OAuth2/OAuth2RefreshTokenAuthenticator.cs | 10 +++++-- .../OAuth2/OAuth2TokenAuthenticator.cs | 10 +++---- .../OAuth2/OAuth2TokenResponse.cs | 2 +- src/RestSharp/RestClient.Async.cs | 2 +- .../Auth/AuthenticatorTests.cs | 2 +- ...uth2ClientCredentialsAuthenticatorTests.cs | 27 +++++++++++++++++++ .../OAuth2RefreshTokenAuthenticatorTests.cs | 27 +++++++++++++++++++ 11 files changed, 81 insertions(+), 19 deletions(-) diff --git a/src/RestSharp/Authenticators/AuthenticatorBase.cs b/src/RestSharp/Authenticators/AuthenticatorBase.cs index dc765e8c7..299b5f75e 100644 --- a/src/RestSharp/Authenticators/AuthenticatorBase.cs +++ b/src/RestSharp/Authenticators/AuthenticatorBase.cs @@ -19,6 +19,6 @@ public abstract class AuthenticatorBase(string token) : IAuthenticator { protected abstract ValueTask GetAuthenticationParameter(string accessToken); - public async ValueTask Authenticate(IRestClient client, RestRequest request) + public async ValueTask Authenticate(IRestClient client, RestRequest request, CancellationToken cancellationToken = default) => request.AddOrUpdateParameter(await GetAuthenticationParameter(Token).ConfigureAwait(false)); } \ No newline at end of file diff --git a/src/RestSharp/Authenticators/IAuthenticator.cs b/src/RestSharp/Authenticators/IAuthenticator.cs index b7cbe9606..8fa2998bd 100644 --- a/src/RestSharp/Authenticators/IAuthenticator.cs +++ b/src/RestSharp/Authenticators/IAuthenticator.cs @@ -15,5 +15,5 @@ namespace RestSharp.Authenticators; public interface IAuthenticator { - ValueTask Authenticate(IRestClient client, RestRequest request); + ValueTask Authenticate(IRestClient client, RestRequest request, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/RestSharp/Authenticators/OAuth/OAuth1Authenticator.cs b/src/RestSharp/Authenticators/OAuth/OAuth1Authenticator.cs index 56906120f..de9d61434 100644 --- a/src/RestSharp/Authenticators/OAuth/OAuth1Authenticator.cs +++ b/src/RestSharp/Authenticators/OAuth/OAuth1Authenticator.cs @@ -41,7 +41,7 @@ public class OAuth1Authenticator : IAuthenticator { public virtual string? ClientUsername { get; set; } public virtual string? ClientPassword { get; set; } - public ValueTask Authenticate(IRestClient client, RestRequest request) { + public ValueTask Authenticate(IRestClient client, RestRequest request, CancellationToken cancellationToken = default) { var workflow = new OAuthWorkflow { ConsumerKey = ConsumerKey, ConsumerSecret = ConsumerSecret, diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2EndpointAuthenticatorBase.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2EndpointAuthenticatorBase.cs index 052b56462..7480a2ac2 100644 --- a/src/RestSharp/Authenticators/OAuth2/OAuth2EndpointAuthenticatorBase.cs +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2EndpointAuthenticatorBase.cs @@ -49,8 +49,8 @@ protected void SetInitialToken(string accessToken, DateTimeOffset expiresAt) { _tokenExpiry = expiresAt; } - public async ValueTask Authenticate(IRestClient client, RestRequest request) { - var token = await GetOrRefreshTokenAsync().ConfigureAwait(false); + public async ValueTask Authenticate(IRestClient client, RestRequest request, CancellationToken cancellationToken = default) { + var token = await GetOrRefreshTokenAsync(cancellationToken).ConfigureAwait(false); request.AddOrUpdateParameter(new HeaderParameter(KnownHeaders.Authorization, $"Bearer {token}")); } @@ -65,11 +65,11 @@ public async ValueTask Authenticate(IRestClient client, RestRequest request) { /// protected virtual void OnTokenResponse(OAuth2TokenResponse response) { } - async Task GetOrRefreshTokenAsync() { + async Task GetOrRefreshTokenAsync(CancellationToken cancellationToken) { if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) return _accessToken; - await _lock.WaitAsync().ConfigureAwait(false); + await _lock.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) @@ -83,7 +83,7 @@ async Task GetOrRefreshTokenAsync() { } using var content = new FormUrlEncodedContent(parameters); - using var response = await _tokenClient.PostAsync(TokenRequest.TokenEndpointUrl, content).ConfigureAwait(false); + using var response = await _tokenClient.PostAsync(TokenRequest.TokenEndpointUrl, content, cancellationToken).ConfigureAwait(false); var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); @@ -96,7 +96,9 @@ async Task GetOrRefreshTokenAsync() { throw new InvalidOperationException($"Token endpoint returned an invalid response: {body}"); _accessToken = tokenResponse.AccessToken; - _tokenExpiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn) - TokenRequest.ExpiryBuffer; + _tokenExpiry = tokenResponse.ExpiresIn.HasValue + ? DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn.Value) - TokenRequest.ExpiryBuffer + : DateTimeOffset.MaxValue; OnTokenResponse(tokenResponse); TokenRequest.OnTokenRefreshed?.Invoke(tokenResponse); diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs index 0bd7ee526..a26cfe837 100644 --- a/src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2RefreshTokenAuthenticator.cs @@ -38,14 +38,20 @@ DateTimeOffset expiresAt SetInitialToken(accessToken, expiresAt); } - protected override Dictionary BuildRequestParameters() - => new() { + protected override Dictionary BuildRequestParameters() { + var parameters = new Dictionary { ["grant_type"] = "refresh_token", ["client_id"] = TokenRequest.ClientId, ["client_secret"] = TokenRequest.ClientSecret, ["refresh_token"] = _refreshToken }; + if (TokenRequest.Scope != null) + parameters["scope"] = TokenRequest.Scope; + + return parameters; + } + protected override void OnTokenResponse(OAuth2TokenResponse response) { if (!string.IsNullOrEmpty(response.RefreshToken)) _refreshToken = response.RefreshToken; diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2TokenAuthenticator.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2TokenAuthenticator.cs index ea1e84aa6..ce4320743 100644 --- a/src/RestSharp/Authenticators/OAuth2/OAuth2TokenAuthenticator.cs +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2TokenAuthenticator.cs @@ -36,22 +36,22 @@ public OAuth2TokenAuthenticator(Func> getTo _tokenType = tokenType; } - public async ValueTask Authenticate(IRestClient client, RestRequest request) { - var token = await GetOrRefreshTokenAsync().ConfigureAwait(false); + public async ValueTask Authenticate(IRestClient client, RestRequest request, CancellationToken cancellationToken = default) { + var token = await GetOrRefreshTokenAsync(cancellationToken).ConfigureAwait(false); request.AddOrUpdateParameter(new HeaderParameter(KnownHeaders.Authorization, $"{_tokenType} {token}")); } - async Task GetOrRefreshTokenAsync() { + async Task GetOrRefreshTokenAsync(CancellationToken cancellationToken) { if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) return _accessToken; - await _lock.WaitAsync().ConfigureAwait(false); + await _lock.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (_accessToken != null && DateTimeOffset.UtcNow < _tokenExpiry) return _accessToken; - var result = await _getToken(CancellationToken.None).ConfigureAwait(false); + var result = await _getToken(cancellationToken).ConfigureAwait(false); _accessToken = result.AccessToken; _tokenExpiry = result.ExpiresAt; diff --git a/src/RestSharp/Authenticators/OAuth2/OAuth2TokenResponse.cs b/src/RestSharp/Authenticators/OAuth2/OAuth2TokenResponse.cs index f3a745180..e49fe11a3 100644 --- a/src/RestSharp/Authenticators/OAuth2/OAuth2TokenResponse.cs +++ b/src/RestSharp/Authenticators/OAuth2/OAuth2TokenResponse.cs @@ -28,7 +28,7 @@ public record OAuth2TokenResponse { public string TokenType { get; init; } = ""; [JsonPropertyName("expires_in")] - public int ExpiresIn { get; init; } + public int? ExpiresIn { get; init; } [JsonPropertyName("refresh_token")] public string? RefreshToken { get; init; } diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index 73ed0410b..984b012dd 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -108,7 +108,7 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo var authenticator = request.Authenticator ?? Options.Authenticator; if (authenticator != null) { - await authenticator.Authenticate(this, request).ConfigureAwait(false); + await authenticator.Authenticate(this, request, cancellationToken).ConfigureAwait(false); } var contentToDispose = new List(); diff --git a/test/RestSharp.Tests/Auth/AuthenticatorTests.cs b/test/RestSharp.Tests/Auth/AuthenticatorTests.cs index 4129a76a6..550d14b64 100644 --- a/test/RestSharp.Tests/Auth/AuthenticatorTests.cs +++ b/test/RestSharp.Tests/Auth/AuthenticatorTests.cs @@ -34,7 +34,7 @@ public async Task Should_add_authorization_form_parameter() { } class TestAuthenticator(ParameterType type, string name, string value) : IAuthenticator { - public ValueTask Authenticate(IRestClient client, RestRequest request) { + public ValueTask Authenticate(IRestClient client, RestRequest request, CancellationToken cancellationToken = default) { request.AddParameter(name, value, type); return default; } diff --git a/test/RestSharp.Tests/Auth/OAuth2ClientCredentialsAuthenticatorTests.cs b/test/RestSharp.Tests/Auth/OAuth2ClientCredentialsAuthenticatorTests.cs index 54060c346..1a12f9957 100644 --- a/test/RestSharp.Tests/Auth/OAuth2ClientCredentialsAuthenticatorTests.cs +++ b/test/RestSharp.Tests/Auth/OAuth2ClientCredentialsAuthenticatorTests.cs @@ -153,6 +153,33 @@ await act.Should().ThrowAsync() .WithMessage("*invalid response*"); } + [Fact] + public async Task Should_treat_missing_expires_in_as_non_expiring() { + var callCount = 0; + + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .Respond(_ => { + Interlocked.Increment(ref callCount); + return new HttpResponseMessage(HttpStatusCode.OK) { + Content = new StringContent( + """{"access_token":"no-expiry-token","token_type":"Bearer"}""", + System.Text.Encoding.UTF8, + "application/json" + ) + }; + }); + + using var authenticator = new OAuth2ClientCredentialsAuthenticator(CreateRequest()); + + var request1 = new RestRequest(); + await authenticator.Authenticate(null!, request1); + + var request2 = new RestRequest(); + await authenticator.Authenticate(null!, request2); + + callCount.Should().Be(1, "token without expires_in should be cached indefinitely"); + } + [Fact] public async Task Should_send_scope_when_configured() { _mockHttp.When(HttpMethod.Post, TokenEndpoint) diff --git a/test/RestSharp.Tests/Auth/OAuth2RefreshTokenAuthenticatorTests.cs b/test/RestSharp.Tests/Auth/OAuth2RefreshTokenAuthenticatorTests.cs index d13dc2315..4ffad40e1 100644 --- a/test/RestSharp.Tests/Auth/OAuth2RefreshTokenAuthenticatorTests.cs +++ b/test/RestSharp.Tests/Auth/OAuth2RefreshTokenAuthenticatorTests.cs @@ -161,6 +161,33 @@ public async Task Should_invoke_callback_on_refresh() { capturedResponse.TokenType.Should().Be("Bearer"); } + [Fact] + public async Task Should_send_scope_when_configured() { + var request2 = new OAuth2TokenRequest(TokenEndpoint, ClientId, ClientSecret) { + HttpClient = new HttpClient(_mockHttp), + ExpiryBuffer = TimeSpan.Zero, + Scope = "api.read api.write" + }; + + _mockHttp.When(HttpMethod.Post, TokenEndpoint) + .WithFormData("scope", "api.read api.write") + .Respond("application/json", TokenJson()); + + using var authenticator = new OAuth2RefreshTokenAuthenticator( + request2, + InitialAccess, + InitialRefresh, + DateTimeOffset.MinValue + ); + + var request = new RestRequest(); + await authenticator.Authenticate(null!, request); + + var authHeader = request.Parameters.FirstOrDefault(p => p.Name == KnownHeaders.Authorization); + authHeader.Should().NotBeNull(); + authHeader.Value.Should().Be("Bearer new-access-token"); + } + [Fact] public async Task Should_throw_on_error_response() { _mockHttp.When(HttpMethod.Post, TokenEndpoint)