Skip to content

Commit

Permalink
Add semaphore to Helix token validation
Browse files Browse the repository at this point in the history
  • Loading branch information
occluder committed Dec 17, 2023
1 parent f6dd7ef commit cbc7586
Show file tree
Hide file tree
Showing 2 changed files with 60 additions and 47 deletions.
4 changes: 4 additions & 0 deletions MiniTwitch.Helix/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Upcoming version

### Minor changes

- Added a semaphore lock to token validation to ensure consistency

### Fixes

- [Updated "Get Ad Schedule" response](https://dev.twitch.tv/docs/change-log/#:~:text=2023%E2%80%9112%E2%80%9111)
103 changes: 56 additions & 47 deletions MiniTwitch.Helix/Internal/HelixApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ internal sealed class HelixApiClient
};
internal long UserId { get; private set; }

private readonly SemaphoreSlim _validateLock = new(1);
private readonly HttpClient _httpClient = new();
private readonly string _tokenValidationUrl;
private readonly ILogger? _logger;
Expand All @@ -47,7 +48,7 @@ public HelixApiClient(string token, long userId, ILogger? logger, string tokenVa

private async Task<(HttpResponseMessage, TimeSpan)> PostAsync(RequestData requestObject, CancellationToken ct)
{
await ValidateToken();
await ValidateToken(ct);
string url = requestObject.GetUrl();
var sw = Stopwatch.StartNew();
HttpResponseMessage response = await _httpClient.PostAsJsonAsync(url, requestObject.Body, this.SerializerOptions, ct);
Expand All @@ -60,7 +61,7 @@ public HelixApiClient(string token, long userId, ILogger? logger, string tokenVa

private async Task<(HttpResponseMessage, TimeSpan)> GetAsync(RequestData requestObject, CancellationToken ct)
{
await ValidateToken();
await ValidateToken(ct);
string url = requestObject.GetUrl();
var sw = Stopwatch.StartNew();
HttpResponseMessage response = await _httpClient.GetAsync(url, ct);
Expand All @@ -73,7 +74,7 @@ public HelixApiClient(string token, long userId, ILogger? logger, string tokenVa

private async Task<(HttpResponseMessage, TimeSpan)> PutAsync(RequestData requestObject, CancellationToken ct)
{
await ValidateToken();
await ValidateToken(ct);
string url = requestObject.GetUrl();
var sw = Stopwatch.StartNew();
HttpResponseMessage response = await _httpClient.PutAsJsonAsync(url, requestObject.Body, this.SerializerOptions, ct);
Expand All @@ -86,7 +87,7 @@ public HelixApiClient(string token, long userId, ILogger? logger, string tokenVa

private async Task<(HttpResponseMessage, TimeSpan)> DeleteAsync(RequestData requestObject, CancellationToken ct)
{
await ValidateToken();
await ValidateToken(ct);
string url = requestObject.GetUrl();
var sw = Stopwatch.StartNew();
HttpResponseMessage response = await _httpClient.DeleteAsync(url, ct);
Expand All @@ -99,7 +100,7 @@ public HelixApiClient(string token, long userId, ILogger? logger, string tokenVa

private async Task<(HttpResponseMessage, TimeSpan)> PatchAsync(RequestData requestObject, CancellationToken ct)
{
await ValidateToken();
await ValidateToken(ct);
string url = requestObject.GetUrl();
string rawContent = JsonSerializer.Serialize(requestObject.Body, this.SerializerOptions);
var content = new StringContent(rawContent, Encoding.UTF8, "application/json");
Expand All @@ -112,65 +113,73 @@ public HelixApiClient(string token, long userId, ILogger? logger, string tokenVa
return (response, elapsed);
}

internal async ValueTask ValidateToken()
internal async ValueTask ValidateToken(CancellationToken ct)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (_tokenInfo is not null)
await _validateLock.WaitAsync(ct);
try
{
var expiresIn = TimeSpan.FromSeconds(_tokenInfo.ReceivedAt + _tokenInfo.ExpiresIn - now);
if (_tokenInfo.IsPermaToken)
if (_tokenInfo is not null)
{
Log(LogLevel.Trace, "Request sent with access token from user {Username} [No expiry]", _tokenInfo.Login);
var expiresIn = TimeSpan.FromSeconds(_tokenInfo.ReceivedAt + _tokenInfo.ExpiresIn - now);
if (_tokenInfo.IsPermaToken)
{
Log(LogLevel.Trace, "Request sent with access token from user {Username} [No expiry]", _tokenInfo.Login);
return;
}

switch (expiresIn)
{
case { TotalSeconds: <= -1 }:
throw new InvalidTokenException(null, $"Access token for user \"{_tokenInfo.Login}\" has expired");
case { TotalHours: < 0 }:
Log(LogLevel.Warning, "Access token for user {Username} expires in {ExpiresInMinutes} minutes", expiresIn.Minutes);
break;
case { TotalDays: < 0 }:
Log(LogLevel.Warning, "Access token for user {Username} expires in {ExpiresInHours} hours", expiresIn.Hours);
break;
default:
Log(LogLevel.Trace, "Request sent with access token from user {Username} [Expires in: {ExpiresIn}]", expiresIn);
break;
}

return;
}

switch (expiresIn)
HttpResponseMessage response = await _httpClient.GetAsync(_tokenValidationUrl);
if (!response.IsSuccessStatusCode)
{
case { TotalSeconds: <= -1 }:
throw new InvalidTokenException(null, $"Access token for user \"{_tokenInfo.Login}\" has expired");
case { TotalHours: < 0 }:
Log(LogLevel.Warning, "Access token for user {Username} expires in {ExpiresInMinutes} minutes", expiresIn.Minutes);
break;
case { TotalDays: < 0 }:
Log(LogLevel.Warning, "Access token for user {Username} expires in {ExpiresInHours} hours", expiresIn.Hours);
break;
default:
Log(LogLevel.Trace, "Request sent with access token from user {Username} [Expires in: {ExpiresIn}]", expiresIn);
break;
InvalidToken? invalid = await response.Content.ReadFromJsonAsync<InvalidToken>(options: null, cancellationToken: ct);
throw new InvalidTokenException(invalid?.Message, "Provided access token is either invalid or has expired");
}

return;
}
_tokenInfo = await response.Content.ReadFromJsonAsync<TokenInfo>(options: null, cancellationToken: ct);
if (_tokenInfo is null)
throw new InvalidTokenException(null, "Validating access token failed");

HttpResponseMessage response = await _httpClient.GetAsync(_tokenValidationUrl);
if (!response.IsSuccessStatusCode)
{
InvalidToken? invalid = await response.Content.ReadFromJsonAsync<InvalidToken>();
throw new InvalidTokenException(invalid?.Message, "Provided access token is either invalid or has expired");
}
_httpClient.DefaultRequestHeaders.Add("Client-Id", $"{_tokenInfo.ClientId}");
_tokenInfo.ReceivedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (_tokenInfo.IsPermaToken)
{
Log(
LogLevel.Information,
"Validated permanent access token from user {Username} with {ScopeCount} scopes",
_tokenInfo.Login, _tokenInfo.Scopes.Count
);

_tokenInfo = await response.Content.ReadFromJsonAsync<TokenInfo>();
if (_tokenInfo is null)
throw new InvalidTokenException(null, "Validating access token failed");
return;
}

_httpClient.DefaultRequestHeaders.Add("Client-Id", $"{_tokenInfo.ClientId}");
_tokenInfo.ReceivedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (_tokenInfo.IsPermaToken)
{
Log(
LogLevel.Information,
"Validated permanent access token from user {Username} with {ScopeCount} scopes",
_tokenInfo.Login, _tokenInfo.Scopes.Count
"Validated access token from user {Username} with {ScopeCount} scopes. The token expires at {ExpiresAt}",
_tokenInfo.Login, _tokenInfo.Scopes.Count, DateTimeOffset.FromUnixTimeSeconds(_tokenInfo.ReceivedAt + _tokenInfo.ExpiresIn)
);

return;
}

Log(
LogLevel.Information,
"Validated access token from user {Username} with {ScopeCount} scopes. The token expires at {ExpiresAt}",
_tokenInfo.Login, _tokenInfo.Scopes.Count, DateTimeOffset.FromUnixTimeSeconds(_tokenInfo.ReceivedAt + _tokenInfo.ExpiresIn)
);
finally
{
_validateLock.Release();
}
}

private void Log(LogLevel level, string template, params object[] properties) => GetLogger().Log(level, "[MiniTwitch.Helix] " + template, properties);
Expand Down

0 comments on commit cbc7586

Please sign in to comment.