Skip to content

Commit 4de200b

Browse files
Merge pull request #247 from AdrianJSClark/oauth-authcode
Implement Callback to allow "Authorization Code Grant" Authentication
2 parents 115f11b + 0947a5e commit 4de200b

7 files changed

+243
-133
lines changed

src/Aydsko.iRacingData.UnitTests/OAuth/OAuthPasswordLimitedAuthenticatingHttpClientTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public async Task GivenAnUnauthenticatedClient_WhenARequestIsMade_ThenTheTokenIs
3535
"Secret-Client-Password");
3636

3737
var fakeTimeProvider = new FakeTimeProvider(new(2025, 09, 13, 1, 0, 0, TimeSpan.Zero));
38-
using var passwordLimitedClient = new OAuthPasswordLimitedAuthenticatingHttpClient(fakeHandler.GetClient(), options, fakeTimeProvider);
38+
using var passwordLimitedClient = new PasswordLimitedOAuthAuthenticatingHttpClient(fakeHandler.GetClient(), options, fakeTimeProvider);
3939

4040
using var testRequest = new HttpRequestMessage(HttpMethod.Get, new Uri("https://example.com/test-request"));
4141
var result = await passwordLimitedClient.SendAuthenticatedRequestAsync(testRequest).ConfigureAwait(false);
@@ -104,7 +104,7 @@ public async Task GivenAnAuthenticatedClient_WhenTimePasses_ThenTheRefreshTokenI
104104
"Secret-Client-Password");
105105

106106
var fakeTimeProvider = new FakeTimeProvider(new(2025, 09, 13, 1, 0, 0, TimeSpan.Zero));
107-
using var passwordLimitedClient = new OAuthPasswordLimitedAuthenticatingHttpClient(fakeHandler.GetClient(), options, fakeTimeProvider);
107+
using var passwordLimitedClient = new PasswordLimitedOAuthAuthenticatingHttpClient(fakeHandler.GetClient(), options, fakeTimeProvider);
108108

109109
using var testRequest1 = new HttpRequestMessage(HttpMethod.Get, new Uri("https://example.com/test-request"));
110110
var result1 = await passwordLimitedClient.SendAuthenticatedRequestAsync(testRequest1).ConfigureAwait(false);

src/Aydsko.iRacingData/OAuthPasswordLimitedAuthenticatingHttpClient.cs renamed to src/Aydsko.iRacingData/OAuthAuthenticatingHttpClientBase.cs

Lines changed: 63 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,48 @@
44

55
namespace Aydsko.iRacingData;
66

7-
public class OAuthPasswordLimitedAuthenticatingHttpClient(HttpClient httpClient,
8-
iRacingDataClientOptions options,
9-
TimeProvider timeProvider)
10-
: IDisposable, IAuthenticatingHttpClient
7+
public abstract class OAuthAuthenticatingHttpClientBase(HttpClient httpClient,
8+
iRacingDataClientOptions options,
9+
TimeProvider timeProvider)
10+
: IDisposable
1111
{
12+
protected HttpClient HttpClient { get; } = httpClient;
13+
protected iRacingDataClientOptions Options { get; } = options;
14+
protected TimeProvider TimeProvider { get; } = timeProvider;
15+
1216
private readonly SemaphoreSlim loginSemaphore = new(1, 1);
13-
private OAuthTokenResponse? tokenResponse;
17+
1418
private DateTimeOffset? accessTokenExpiryInstantUtc;
15-
private DateTimeOffset? refreshTokenExpiryInstantUtc;
1619
private bool disposedValue;
20+
private DateTimeOffset? refreshTokenExpiryInstantUtc;
21+
private OAuthTokenResponse? tokenResponse;
22+
23+
public void ClearLoggedInState()
24+
{
25+
tokenResponse = null;
26+
}
27+
28+
// // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
29+
// ~OAuthAuthenticatingHttpClientBase()
30+
// {
31+
// // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
32+
// Dispose(disposing: false);
33+
// }
34+
35+
public void Dispose()
36+
{
37+
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
38+
Dispose(disposing: true);
39+
GC.SuppressFinalize(this);
40+
}
41+
42+
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
43+
HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead,
44+
CancellationToken cancellationToken = default)
45+
{
46+
return await HttpClient.SendAsync(request, completionOption, cancellationToken)
47+
.ConfigureAwait(false);
48+
}
1749

1850
public async Task<HttpResponseMessage> SendAuthenticatedRequestAsync(HttpRequestMessage request,
1951
HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead,
@@ -34,17 +66,20 @@ public async Task<HttpResponseMessage> SendAuthenticatedRequestAsync(HttpRequest
3466
return await SendAsync(request, completionOption, cancellationToken).ConfigureAwait(false);
3567
}
3668

37-
public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
38-
HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead,
39-
CancellationToken cancellationToken = default)
69+
protected virtual void Dispose(bool disposing)
4070
{
41-
return await httpClient.SendAsync(request, completionOption, cancellationToken)
42-
.ConfigureAwait(false);
43-
}
71+
if (!disposedValue)
72+
{
73+
if (disposing)
74+
{
75+
// TODO: dispose managed state (managed objects)
76+
loginSemaphore.Dispose();
77+
}
4478

45-
public void ClearLoggedInState()
46-
{
47-
tokenResponse = null;
79+
// TODO: free unmanaged resources (unmanaged objects) and override finalizer
80+
// TODO: set large fields to null
81+
disposedValue = true;
82+
}
4883
}
4984

5085
private async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken = default)
@@ -70,7 +105,7 @@ await loginSemaphore.WaitAsync(cancellationToken)
70105
_ = loginSemaphore.Release();
71106
}
72107
}
73-
else if (accessTokenExpiryInstantUtc <= timeProvider.GetUtcNow())
108+
else if (accessTokenExpiryInstantUtc <= TimeProvider.GetUtcNow())
74109
{
75110
await loginSemaphore.WaitAsync(cancellationToken)
76111
.ConfigureAwait(false);
@@ -80,12 +115,12 @@ await loginSemaphore.WaitAsync(cancellationToken)
80115
#pragma warning disable IDE0074 // Use compound assignment
81116

82117
// If the refresh token doesn't exist or is expired it is no good so we'll need to request a whole new token.
83-
if ((refreshTokenExpiryInstantUtc ?? DateTimeOffset.MinValue) <= timeProvider.GetUtcNow())
118+
if ((refreshTokenExpiryInstantUtc ?? DateTimeOffset.MinValue) <= TimeProvider.GetUtcNow())
84119
{
85120
(tokenResponse, accessTokenExpiryInstantUtc, refreshTokenExpiryInstantUtc) = await RequestTokenAsync(cancellationToken).ConfigureAwait(false);
86121
return tokenResponse.AccessToken;
87122
}
88-
else if (accessTokenExpiryInstantUtc <= timeProvider.GetUtcNow())
123+
else if (accessTokenExpiryInstantUtc <= TimeProvider.GetUtcNow())
89124
{
90125
(tokenResponse, accessTokenExpiryInstantUtc, refreshTokenExpiryInstantUtc) = await RefreshTokenAsync(cancellationToken).ConfigureAwait(false);
91126
return tokenResponse.AccessToken;
@@ -100,79 +135,7 @@ await loginSemaphore.WaitAsync(cancellationToken)
100135
}
101136
}
102137

103-
return tokenResponse.AccessToken;
104-
}
105-
106-
private async Task<(OAuthTokenResponse Token, DateTimeOffset ExpiresAt, DateTimeOffset? RefreshTokenExpiresAt)> RequestTokenAsync(CancellationToken cancellationToken = default)
107-
{
108-
using var activity = AydskoDataClientDiagnostics.ActivitySource.StartActivity("Retrieve \"password_limited\" token", System.Diagnostics.ActivityKind.Client);
109-
110-
try
111-
{
112-
if (string.IsNullOrWhiteSpace(options.Username)
113-
|| string.IsNullOrWhiteSpace(options.Password)
114-
|| string.IsNullOrWhiteSpace(options.ClientId)
115-
|| string.IsNullOrWhiteSpace(options.ClientSecret))
116-
{
117-
throw new InvalidOperationException("All of \"Username\", \"Password\", \"ClientId\", and \"ClientSecret\" must be set in the options.");
118-
}
119-
120-
if (string.IsNullOrWhiteSpace(options.AuthServiceBaseUrl)
121-
|| !Uri.TryCreate(options.AuthServiceBaseUrl, UriKind.Absolute, out var authServiceBaseUrl))
122-
{
123-
throw new InvalidOperationException("The \"AuthServiceBaseUrl\" must be a valid absolute URL in the iRacing Data Client options.");
124-
}
125-
126-
var encodedPassword = options.PasswordIsEncoded ? options.Password : ApiClient.EncodePassword(options.Username!, options.Password!);
127-
var encodedClientSecret = options.ClientSecretIsEncoded ? options.ClientSecret : ApiClient.EncodePassword(options.ClientId!, options.ClientSecret!);
128-
129-
using var newTokenRequest = new HttpRequestMessage(HttpMethod.Post,
130-
new Uri(authServiceBaseUrl, "/oauth2/token"))
131-
{
132-
Content = new FormUrlEncodedContent(
133-
[
134-
new("grant_type", "password_limited"),
135-
new("client_id", options.ClientId),
136-
new("client_secret", encodedClientSecret),
137-
new("username", options.Username),
138-
new("password", encodedPassword),
139-
new("scope", "iracing.auth iracing.profile"),
140-
]),
141-
};
142-
143-
var newTokenResponse = await httpClient.SendAsync(newTokenRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
144-
.ConfigureAwait(false);
145-
var utcNow = timeProvider.GetUtcNow();
146-
147-
if (!newTokenResponse.IsSuccessStatusCode)
148-
{
149-
#if NET6_0_OR_GREATER
150-
var errorContent = await newTokenResponse.Content.ReadAsStringAsync(cancellationToken)
151-
.ConfigureAwait(false);
152-
#else
153-
var errorContent = await newTokenResponse.Content.ReadAsStringAsync()
154-
.ConfigureAwait(false);
155-
#endif
156-
throw new iRacingLoginFailedException($"Attempt to retrieve \"password_limited\" OAuth token failed with status code {newTokenResponse.StatusCode} and content: {errorContent}");
157-
}
158-
159-
var token = await newTokenResponse.Content.ReadFromJsonAsync<OAuthTokenResponse>(cancellationToken)
160-
.ConfigureAwait(false)
161-
?? throw new iRacingLoginFailedException("Failed to deserialize OAuth token response from iRacing API.");
162-
163-
var expiresAt = utcNow.AddSeconds(token.ExpiresInSeconds);
164-
var refreshExpiresAt = token.RefreshTokenExpiresInSeconds != null ? utcNow.AddSeconds(token.RefreshTokenExpiresInSeconds.Value) : (DateTimeOffset?)null;
165-
166-
activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Ok, "Token retrieved successfully");
167-
168-
return (token, expiresAt, refreshExpiresAt);
169-
}
170-
catch (Exception ex)
171-
{
172-
activity?.AddException(ex);
173-
activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, "Exception thrown retrieving token");
174-
throw;
175-
}
138+
return tokenResponse.AccessToken;
176139
}
177140

178141
private async Task<(OAuthTokenResponse Token, DateTimeOffset ExpiresAt, DateTimeOffset? RefreshTokenExpiresAt)> RefreshTokenAsync(CancellationToken cancellationToken = default)
@@ -181,14 +144,14 @@ await loginSemaphore.WaitAsync(cancellationToken)
181144

182145
try
183146
{
184-
if (string.IsNullOrWhiteSpace(options.ClientId)
185-
|| string.IsNullOrWhiteSpace(options.ClientSecret))
147+
if (string.IsNullOrWhiteSpace(Options.ClientId)
148+
|| string.IsNullOrWhiteSpace(Options.ClientSecret))
186149
{
187150
throw new InvalidOperationException("All of \"ClientId\" and \"ClientSecret\" must be set in the options.");
188151
}
189152

190-
if (string.IsNullOrWhiteSpace(options.AuthServiceBaseUrl)
191-
|| !Uri.TryCreate(options.AuthServiceBaseUrl, UriKind.Absolute, out var authServiceBaseUrl))
153+
if (string.IsNullOrWhiteSpace(Options.AuthServiceBaseUrl)
154+
|| !Uri.TryCreate(Options.AuthServiceBaseUrl, UriKind.Absolute, out var authServiceBaseUrl))
192155
{
193156
throw new InvalidOperationException("The \"AuthServiceBaseUrl\" must be a valid absolute URL in the iRacing Data Client options.");
194157
}
@@ -199,23 +162,23 @@ await loginSemaphore.WaitAsync(cancellationToken)
199162
throw new InvalidOperationException("Previous response must have contained a refresh token value.");
200163
}
201164

202-
var encodedClientSecret = options.ClientSecretIsEncoded ? options.ClientSecret : ApiClient.EncodePassword(options.ClientId!, options.ClientSecret!);
165+
var encodedClientSecret = Options.ClientSecretIsEncoded ? Options.ClientSecret : ApiClient.EncodePassword(Options.ClientId!, Options.ClientSecret!);
203166

204167
using var newTokenRequest = new HttpRequestMessage(HttpMethod.Post,
205168
new Uri(authServiceBaseUrl, "/oauth2/token"))
206169
{
207170
Content = new FormUrlEncodedContent(
208171
[
209172
new("grant_type", "refresh_token"),
210-
new("client_id", options.ClientId),
173+
new("client_id", Options.ClientId),
211174
new("client_secret", encodedClientSecret),
212175
new("refresh_token", refreshToken),
213176
]),
214177
};
215178

216-
var newTokenResponse = await httpClient.SendAsync(newTokenRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
179+
var newTokenResponse = await HttpClient.SendAsync(newTokenRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
217180
.ConfigureAwait(false);
218-
var utcNow = timeProvider.GetUtcNow();
181+
var utcNow = TimeProvider.GetUtcNow();
219182

220183
if (!newTokenResponse.IsSuccessStatusCode)
221184
{
@@ -248,33 +211,5 @@ await loginSemaphore.WaitAsync(cancellationToken)
248211
}
249212
}
250213

251-
protected virtual void Dispose(bool disposing)
252-
{
253-
if (!disposedValue)
254-
{
255-
if (disposing)
256-
{
257-
// TODO: dispose managed state (managed objects)
258-
loginSemaphore.Dispose();
259-
}
260-
261-
// TODO: free unmanaged resources (unmanaged objects) and override finalizer
262-
// TODO: set large fields to null
263-
disposedValue = true;
264-
}
265-
}
266-
267-
// // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
268-
// ~OAuthPasswordLimitedAuthenticatingHttpClient()
269-
// {
270-
// // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
271-
// Dispose(disposing: false);
272-
// }
273-
274-
public void Dispose()
275-
{
276-
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
277-
Dispose(disposing: true);
278-
GC.SuppressFinalize(this);
279-
}
214+
protected abstract Task<(OAuthTokenResponse Token, DateTimeOffset ExpiresAt, DateTimeOffset? RefreshTokenExpiresAt)> RequestTokenAsync(CancellationToken cancellationToken = default);
280215
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using Aydsko.iRacingData.Exceptions;
2+
3+
namespace Aydsko.iRacingData;
4+
5+
public delegate Task<OAuthTokenResponse> GetOAuthTokenResponse(CancellationToken cancellationToken = default);
6+
7+
public class OAuthCallbackAuthenticatingApiClient(HttpClient httpClient,
8+
iRacingDataClientOptions options,
9+
TimeProvider timeProvider)
10+
: OAuthAuthenticatingHttpClientBase(httpClient, options, timeProvider), IAuthenticatingHttpClient
11+
{
12+
protected override async Task<(OAuthTokenResponse Token, DateTimeOffset ExpiresAt, DateTimeOffset? RefreshTokenExpiresAt)> RequestTokenAsync(CancellationToken cancellationToken = default)
13+
{
14+
if (Options.OAuthTokenResponseCallback is null)
15+
{
16+
throw new InvalidOperationException("The OAuthTokenResponseCallback must be set in the options.");
17+
}
18+
using var activity = AydskoDataClientDiagnostics.ActivitySource.StartActivity("Retrieve token via callback", System.Diagnostics.ActivityKind.Client);
19+
20+
try
21+
{
22+
var token = await Options.OAuthTokenResponseCallback(cancellationToken)
23+
.ConfigureAwait(false)
24+
?? throw new iRacingLoginFailedException("The OAuthTokenResponseCallback returned null.");
25+
26+
var utcNow = TimeProvider.GetUtcNow();
27+
28+
var expiresAt = utcNow.AddSeconds(token.ExpiresInSeconds);
29+
var refreshExpiresAt = token.RefreshTokenExpiresInSeconds != null ? utcNow.AddSeconds(token.RefreshTokenExpiresInSeconds.Value) : (DateTimeOffset?)null;
30+
31+
activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Ok, "Token retrieved successfully");
32+
33+
return (token, expiresAt, refreshExpiresAt);
34+
}
35+
catch (Exception ex)
36+
{
37+
activity?.AddException(ex);
38+
activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, "Exception thrown retrieving token");
39+
throw;
40+
}
41+
}
42+
}

src/Aydsko.iRacingData/Package Release Notes.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
NEW FEATURE: iRacing OAuth "Authorization Code Grant" Authentication
2+
- Allows authentication using the "/authorize" endpoint via a callback.
3+
- This authentication, or "Password Limited Grant", will be required after 9 Dec 2025 (see https://forums.iracing.com/discussion/84226/legacy-authentication-removal-dec-9-2025).
4+
- See https://oauth.iracing.com/oauth2/book/token_endpoint.html#authorization-code-grant for more information.
5+
6+
7+
8+
============================
9+
Release Notes for 2504.0.1
10+
============================
11+
112
BREAKING CHANGES:
213
- "IDataClient.GetMemberChartData" method was removed. Use "IDataClient.GetMemberChartDataAsync" instead.
314
- The "TrackScreenshotService" class has been removed. Use "IDataClient.GetTrackAssetScreenshotUris\" or "IDataClient.GetTrackAssetScreenshotUrisAsync" instead.

0 commit comments

Comments
 (0)