diff --git a/README.md b/README.md index f40b2ac2..19e21772 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # TwitchLib.Api API component of TwitchLib. -For a general overview and example, refer to https://github.com/TwitchLib/TwitchLib/blob/master/README.md +For a general overview and examples, refer to https://github.com/TwitchLib/TwitchLib/blob/master/README.md + +For Helix API Examples, refer to https://github.com/TwitchLib/TwitchLib.Api/blob/master/TwitchLib.Api.Helix/README.MD ```csharp using System; diff --git a/TwitchLib.Api.Core.Interfaces/IApiSettings.cs b/TwitchLib.Api.Core.Interfaces/IApiSettings.cs index 6aed6862..2a5dce49 100644 --- a/TwitchLib.Api.Core.Interfaces/IApiSettings.cs +++ b/TwitchLib.Api.Core.Interfaces/IApiSettings.cs @@ -4,14 +4,70 @@ namespace TwitchLib.Api.Core.Interfaces { + /// + /// These are settings that are shared throughout the application. Define these before + /// creating an instance of TwitchAPI + /// public interface IApiSettings { + /// + /// The current client credential access token. Provides limited access to some of the TwitchAPI. + /// string AccessToken { get; set; } + /// + /// Your application's Secret. Do not expose this to anyone else. This comes from the Twitch developer panel. https://dev.twitch.tv/console + /// string Secret { get; set; } + /// + /// Your application's client ID. This comes from the Twitch developer panel. https://dev.twitch.tv/console + /// string ClientId { get; set; } + /// + /// This does not appear to be used. + /// bool SkipDynamicScopeValidation { get; set; } + /// + /// If AccessToken is null, and this is set to true, then Helix API calls will not attempt to use + /// the ClientID/Secret to generate a client_credential access token. + /// bool SkipAutoServerTokenGeneration { get; set; } + /// + /// Add scopes that your application will be using to this collection before calling any Helix APIs. + /// A list of scopes can be found here: https://dev.twitch.tv/docs/authentication/scopes/ + /// See the TwitchAPI reference for the scopes specific to each API. + /// Note: Do not add ALL the scopes, or your account may be banned (see warning here: https://dev.twitch.tv/docs/authentication/scopes/) + /// List Scopes { get; set; } + /// + /// Set this value to another port if you have another application already listening to port 5000 on your machine. + /// Defaults to: 5000 + /// + int OAuthResponsePort { get; set; } + /// + /// Set this value to a hostname or IP address if you have a multi-homed machine (more than one IP address) + /// and you would like to bind the OAuth response listener to a specific IP address. Defaults to 'localhost' + /// + string OAuthResponseHostname { get; set; } + /// + /// Storage for oAuth refresh token, expiration dates, etc. Defaults to %AppData%\\TwitchLib.API\\[ApplicationName].json + /// Set this if you will be running multiple instances of the same application that you would like to use with different + /// user tokens. + /// + string OAuthTokenFile { get; set; } + /// + /// Set this value to true to enable Helix calls that require an oAuth User Token. This requires you to also set + /// ApiSettings.ClientID and ApiSettings.Secret. + /// + bool UseUserTokenForHelixCalls { get; set; } + /// + /// Setting this value to true will enable storage of the oAuth refresh token and other data. This storage will be done in + /// an unencrypted, insecure local file. Anyone else with access to your computer could read this file and gain access to + /// your Twitch account in unexpected ways. Only set this value to true if you have properly secured your computer. + /// If you do not set this value to True, and UseUserTokenForHelixCalls = True, a browser window will always open on the + /// first call to any Helix API to perform the OAuth handshake. + /// Defaults to: False + /// + bool EnableInsecureTokenStorage { get; set; } event PropertyChangedEventHandler PropertyChanged; } diff --git a/TwitchLib.Api.Core.Interfaces/IUserAccessTokenManager.cs b/TwitchLib.Api.Core.Interfaces/IUserAccessTokenManager.cs new file mode 100644 index 00000000..4be9964e --- /dev/null +++ b/TwitchLib.Api.Core.Interfaces/IUserAccessTokenManager.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using TwitchLib.Api.Core.Interfaces; + +namespace TwitchLib.Api.Core.Interfaces +{ + /// + /// Enables API calls to use user access tokens instead of client credentials. Most of the best parts of + /// the Twitch API are only available when using user access tokens. + /// + public interface IUserAccessTokenManager + { + /// + /// Uses the Authoization Grant flow to get an access code to get a token and refresh token. + /// https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#authorization-code-grant-flow + /// + /// + Task GetUserAccessToken(); + } +} diff --git a/TwitchLib.Api.Core/ApiBase.cs b/TwitchLib.Api.Core/ApiBase.cs index 68487b6e..c9df83a6 100644 --- a/TwitchLib.Api.Core/ApiBase.cs +++ b/TwitchLib.Api.Core/ApiBase.cs @@ -12,9 +12,14 @@ namespace TwitchLib.Api.Core { + /// + /// A base class for any method calling the Twitch API. Provides authorization credentials and + /// abstracts for calling basic web methods. + /// public class ApiBase { private readonly TwitchLibJsonSerializer _jsonSerializer; + private readonly IUserAccessTokenManager _userAccessTokenManager; protected readonly IApiSettings Settings; private readonly IRateLimiter _rateLimiter; private readonly IHttpCallHandler _http; @@ -25,20 +30,30 @@ public class ApiBase private DateTime? _serverBasedAccessTokenExpiry; private string _serverBasedAccessToken; - public ApiBase(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) + /// + /// Standard constructor for all derived API methods. + /// + /// Can be null. + /// Can be null. + /// Can be null. + /// Can be null. + public ApiBase(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) { Settings = settings; _rateLimiter = rateLimiter; _http = http; _jsonSerializer = new TwitchLibJsonSerializer(); + _userAccessTokenManager = userAccessTokenManager; } - public async ValueTask GetAccessTokenAsync(string accessToken = null) + private async ValueTask GetAccessTokenAsync(string accessToken = null) { if (!string.IsNullOrWhiteSpace(accessToken)) return accessToken; if (!string.IsNullOrWhiteSpace(Settings.AccessToken)) return Settings.AccessToken; + if (Settings.UseUserTokenForHelixCalls && _userAccessTokenManager != null) + return await GenerateUserAccessToken(); if (!string.IsNullOrWhiteSpace(Settings.Secret) && !string.IsNullOrWhiteSpace(Settings.ClientId) && !Settings.SkipAutoServerTokenGeneration) { if (_serverBasedAccessTokenExpiry == null || _serverBasedAccessTokenExpiry - TimeSpan.FromMinutes(1) < DateTime.Now) @@ -50,6 +65,11 @@ public async ValueTask GetAccessTokenAsync(string accessToken = null) return null; } + private async Task GenerateUserAccessToken() + { + return await _userAccessTokenManager.GetUserAccessToken(); + } + internal async Task GenerateServerBasedAccessToken() { var result = await _http.GeneralRequestAsync($"{BaseAuth}/token?client_id={Settings.ClientId}&client_secret={Settings.Secret}&grant_type=client_credentials", "POST", null, ApiVersion.Auth, Settings.ClientId, null).ConfigureAwait(false); @@ -338,10 +358,18 @@ private string ConstructResourceUrl(string resource = null, List _scopes; + private List _scopes = new List(); + private int _oauthResponsePort = 5000; + private string _oAuthResponseHostname = "localhost"; + private string _oauthTokenFile = System.Environment.ExpandEnvironmentVariables("%AppData%\\TwitchLib.API\\" + System.Diagnostics.Process.GetCurrentProcess().ProcessName + ".json"); + private bool _useUserTokenForHelixCalls = false; + private bool _enableInsecureTokenStorage = false; + private IUserAccessTokenManager _userAccessTokenManager; + + public string ClientId { get => _clientId; @@ -74,6 +83,12 @@ public bool SkipAutoServerTokenGeneration } } } + /// + /// Add scopes that your application will be using to this collection before calling any Helix APIs. + /// A list of scopes can be found here: https://dev.twitch.tv/docs/authentication/scopes/ + /// See the TwitchAPI reference for the scopes specific to each API. + /// Note: Do not add ALL the scopes, or your account may be banned (see warning here: https://dev.twitch.tv/docs/authentication/scopes/) + /// public List Scopes { get => _scopes; @@ -87,6 +102,107 @@ public List Scopes } } + /// + /// If you are using TwitchLib.Api and make calls to API endpoints that require a user token, then you can use + /// your ClientSecret and ClientID to establish an OAuth token for your service. Part of this token generation + /// process requires Twitch to authenticate your application using your browser. Twitch will return your browser + /// back to this library for token storage so, this library needs to listen for your browser's request on a port. + /// By default, this is port 5000. If you have another application also on port 5000, set this to another open port. + /// + public int OAuthResponsePort + { + get => _oauthResponsePort; + set + { + if (value != _oauthResponsePort) + { + _oauthResponsePort = value; + NotifyPropertyChanged(); + } + } + } + + /// + /// Set this value to a hostname or IP address if you have a multi-homed machine (more than one IP address) + /// and you would like to bind the OAuth response listener to a specific IP address. Defaults to 'localhost' + /// + public string OAuthResponseHostname + { + get => _oAuthResponseHostname; + set + { + if (value != _oAuthResponseHostname) + { + _oAuthResponseHostname = value; + NotifyPropertyChanged(); + } + } + } + + /// + /// Storage for oAuth refresh token, expiration dates, etc. Defaults to %AppData%\\TwitchLib.API\\[ApplicationName].json + /// Set this if you will be running multiple instances of the same application that you would like to use with different + /// user tokens. + /// + public string OAuthTokenFile + { + get => _oauthTokenFile; + set + { + if (value != _oauthTokenFile) + { + _oauthTokenFile = value; + NotifyPropertyChanged(); + } + } + } + + /// + /// Set this value to true to enable Helix calls that require an oAuth User Token. This requires you to also set + /// ApiSettings.ClientID and ApiSettings.Secret. + /// + public bool UseUserTokenForHelixCalls + { + get => _useUserTokenForHelixCalls; + set + { + if (value != _useUserTokenForHelixCalls) + { + if (String.IsNullOrWhiteSpace(ClientId) == true || String.IsNullOrWhiteSpace(Secret) == true) + throw new Exception("You must set ApiSettings.ClientId and ApiSettings.Secret before you can enable this setting."); + + _useUserTokenForHelixCalls = value; + NotifyPropertyChanged(); + } + } + } + + /// + /// Setting this value to true will enable storage of the oAuth refresh token and other data. This storage will be done in + /// an unencrypted, insecure local file. Anyone else with access to your computer could read this file and gain access to + /// your Twitch account in unexpected ways. Only set this value to true if you have properly secured your computer. + /// If you do not set this value to True, and UseUserTokenForHelixCalls = True, a browser window will always open on the + /// first call to any Helix API to perform the OAuth handshake. + /// Defaults to: False + /// + public bool EnableInsecureTokenStorage + { + get => _enableInsecureTokenStorage; + set + { + if (value != _enableInsecureTokenStorage) + { + _enableInsecureTokenStorage = value; + NotifyPropertyChanged(); + } + } + } + + + + /// + /// This event fires when ever a property is changed on the settings class. + /// public event PropertyChangedEventHandler PropertyChanged; private void NotifyPropertyChanged([CallerMemberName] string propertyName = "") diff --git a/TwitchLib.Api.Core/Exceptions/HttpResponseException.cs b/TwitchLib.Api.Core/Exceptions/HttpResponseException.cs index 905d726a..551d0659 100644 --- a/TwitchLib.Api.Core/Exceptions/HttpResponseException.cs +++ b/TwitchLib.Api.Core/Exceptions/HttpResponseException.cs @@ -12,10 +12,19 @@ public class HttpResponseException : Exception /// Null if using or /// public HttpResponseMessage HttpResponse { get; } + public string HttpResponseContent { get; } public HttpResponseException(string apiData, HttpResponseMessage httpResponse) : base(apiData) { HttpResponse = httpResponse; + + try + { + HttpResponseContent = httpResponse.Content.ReadAsStringAsync().Result; + } catch (Exception ex) + { + HttpResponseContent = $"Couldn't read response from server: {ex.ToString()}"; + } } } } \ No newline at end of file diff --git a/TwitchLib.Api.Core/HttpCallHandlers/TwitchHttpClient.cs b/TwitchLib.Api.Core/HttpCallHandlers/TwitchHttpClient.cs index 0e713e75..dd39efee 100644 --- a/TwitchLib.Api.Core/HttpCallHandlers/TwitchHttpClient.cs +++ b/TwitchLib.Api.Core/HttpCallHandlers/TwitchHttpClient.cs @@ -122,30 +122,42 @@ public async Task RequestReturnResponseCodeAsync(string url, string method, private void HandleWebException(HttpResponseMessage errorResp) { + // Show the actual message that came back from the server. Masking it only makes debugging hard. + var actualContent = String.Empty; + + try + { + actualContent = errorResp.Content.ReadAsStringAsync().Result; + } + catch (Exception ex) + { + actualContent = $"Couldn't read response from server: {ex.Message}"; + } + switch (errorResp.StatusCode) { case HttpStatusCode.BadRequest: - throw new BadRequestException("Your request failed because either: \n 1. Your ClientID was invalid/not set. \n 2. Your refresh token was invalid. \n 3. You requested a username when the server was expecting a user ID.", errorResp); + throw new BadRequestException(actualContent + "\n\nYour request may have failed because either: \n 1. Your ClientID was invalid/not set. \n 2. Your refresh token was invalid. \n 3. You requested a username when the server was expecting a user ID.", errorResp); case HttpStatusCode.Unauthorized: var authenticateHeader = errorResp.Headers.WwwAuthenticate; if (authenticateHeader == null || authenticateHeader.Count <= 0) - throw new BadScopeException("Your request was blocked due to bad credentials (Do you have the right scope for your access token?).", errorResp); - throw new TokenExpiredException("Your request was blocked due to an expired Token. Please refresh your token and update your API instance settings.", errorResp); + throw new BadScopeException(actualContent + "\n\nYour request was blocked due to bad credentials (Do you have the right scope for your access token?).", errorResp); + throw new TokenExpiredException(actualContent + "\n\nYour request was blocked due to an expired Token. Please refresh your token and update your API instance settings.", errorResp); case HttpStatusCode.NotFound: - throw new BadResourceException("The resource you tried to access was not valid.", errorResp); + throw new BadResourceException(actualContent + "\n\nThe resource you tried to access was not valid.", errorResp); case (HttpStatusCode)429: - errorResp.Headers.TryGetValues("Ratelimit-Reset", out var resetTime); - throw new TooManyRequestsException("You have reached your rate limit. Too many requests were made", resetTime.FirstOrDefault(), errorResp); + errorResp.Headers.TryGetValues("Ratelimit-Reset", out var resetTime); + throw new TooManyRequestsException(actualContent + "\n\nYou have reached your rate limit. Too many requests were made", resetTime.FirstOrDefault(), errorResp); case HttpStatusCode.BadGateway: - throw new BadGatewayException("The API answered with a 502 Bad Gateway. Please retry your request", errorResp); + throw new BadGatewayException(actualContent + "\n\nThe API answered with a 502 Bad Gateway. Please retry your request", errorResp); case HttpStatusCode.GatewayTimeout: - throw new GatewayTimeoutException("The API answered with a 504 Gateway Timeout. Please retry your request", errorResp); + throw new GatewayTimeoutException(actualContent + "\n\nThe API answered with a 504 Gateway Timeout. Please retry your request", errorResp); case HttpStatusCode.InternalServerError: - throw new InternalServerErrorException("The API answered with a 500 Internal Server Error. Please retry your request", errorResp); + throw new InternalServerErrorException(actualContent + "\n\nThe API answered with a 500 Internal Server Error. Please retry your request", errorResp); case HttpStatusCode.Forbidden: - throw new BadTokenException("The token provided in the request did not match the associated user. Make sure the token you're using is from the resource owner (streamer? viewer?)", errorResp); + throw new BadTokenException(actualContent + "\n\nThe token provided in the request did not match the associated user. Make sure the token you're using is from the resource owner (streamer? viewer?)", errorResp); default: - throw new HttpRequestException("Something went wrong during the request! Please try again later"); + throw new HttpRequestException(actualContent + "\n\nSomething went wrong during the request! Please try again later"); } } diff --git a/TwitchLib.Api.Core/TwitchLib.Api.Core.csproj b/TwitchLib.Api.Core/TwitchLib.Api.Core.csproj index 62f0bb2e..76d1d43f 100644 --- a/TwitchLib.Api.Core/TwitchLib.Api.Core.csproj +++ b/TwitchLib.Api.Core/TwitchLib.Api.Core.csproj @@ -24,6 +24,7 @@ + @@ -36,5 +37,8 @@ + + + diff --git a/TwitchLib.Api.Core/Undocumented/Undocumented.cs b/TwitchLib.Api.Core/Undocumented/Undocumented.cs index 4f4a2ff8..aa7040ec 100644 --- a/TwitchLib.Api.Core/Undocumented/Undocumented.cs +++ b/TwitchLib.Api.Core/Undocumented/Undocumented.cs @@ -15,7 +15,7 @@ namespace TwitchLib.Api.Core.Undocumented /// public class Undocumented : ApiBase { - public Undocumented(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Undocumented(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Ads.cs b/TwitchLib.Api.Helix/Ads.cs index 463ce9cb..098b1d48 100644 --- a/TwitchLib.Api.Helix/Ads.cs +++ b/TwitchLib.Api.Helix/Ads.cs @@ -12,7 +12,7 @@ namespace TwitchLib.Api.Helix /// public class Ads : ApiBase { - public Ads(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Ads(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Analytics.cs b/TwitchLib.Api.Helix/Analytics.cs index 07121cf4..118a2474 100644 --- a/TwitchLib.Api.Helix/Analytics.cs +++ b/TwitchLib.Api.Helix/Analytics.cs @@ -14,7 +14,7 @@ namespace TwitchLib.Api.Helix /// public class Analytics : ApiBase { - public Analytics(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Analytics(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Bits.cs b/TwitchLib.Api.Helix/Bits.cs index a62a8c7b..02be32d1 100644 --- a/TwitchLib.Api.Helix/Bits.cs +++ b/TwitchLib.Api.Helix/Bits.cs @@ -15,7 +15,7 @@ namespace TwitchLib.Api.Helix /// public class Bits :ApiBase { - public Bits(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Bits(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/ChannelPoints.cs b/TwitchLib.Api.Helix/ChannelPoints.cs index 782e35d0..d33d399a 100644 --- a/TwitchLib.Api.Helix/ChannelPoints.cs +++ b/TwitchLib.Api.Helix/ChannelPoints.cs @@ -19,7 +19,7 @@ namespace TwitchLib.Api.Helix /// public class ChannelPoints : ApiBase { - public ChannelPoints(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public ChannelPoints(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Channels.cs b/TwitchLib.Api.Helix/Channels.cs index a96b70cb..f70d431d 100644 --- a/TwitchLib.Api.Helix/Channels.cs +++ b/TwitchLib.Api.Helix/Channels.cs @@ -20,7 +20,7 @@ namespace TwitchLib.Api.Helix /// public class Channels : ApiBase { - public Channels(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Channels(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Charity.cs b/TwitchLib.Api.Helix/Charity.cs index 8f2e821b..b4ccb59a 100644 --- a/TwitchLib.Api.Helix/Charity.cs +++ b/TwitchLib.Api.Helix/Charity.cs @@ -15,7 +15,7 @@ namespace TwitchLib.Api.Helix public class Charity : ApiBase { - public Charity(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Charity(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Chat.cs b/TwitchLib.Api.Helix/Chat.cs index 879ab7fa..02ae6841 100644 --- a/TwitchLib.Api.Helix/Chat.cs +++ b/TwitchLib.Api.Helix/Chat.cs @@ -25,7 +25,7 @@ namespace TwitchLib.Api.Helix /// public class Chat : ApiBase { - public Chat(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Chat(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } #region Badges diff --git a/TwitchLib.Api.Helix/Clips.cs b/TwitchLib.Api.Helix/Clips.cs index 8c45a543..72ebb587 100644 --- a/TwitchLib.Api.Helix/Clips.cs +++ b/TwitchLib.Api.Helix/Clips.cs @@ -17,7 +17,7 @@ namespace TwitchLib.Api.Helix /// public class Clips : ApiBase { - public Clips(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Clips(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } #region GetClips diff --git a/TwitchLib.Api.Helix/Entitlements.cs b/TwitchLib.Api.Helix/Entitlements.cs index f306e8a8..e3742fa9 100644 --- a/TwitchLib.Api.Helix/Entitlements.cs +++ b/TwitchLib.Api.Helix/Entitlements.cs @@ -18,7 +18,7 @@ namespace TwitchLib.Api.Helix /// public class Entitlements : ApiBase { - public Entitlements(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Entitlements(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/EventSub.cs b/TwitchLib.Api.Helix/EventSub.cs index f73fc699..64c74fc4 100644 --- a/TwitchLib.Api.Helix/EventSub.cs +++ b/TwitchLib.Api.Helix/EventSub.cs @@ -13,7 +13,7 @@ namespace TwitchLib.Api.Helix { public class EventSub : ApiBase { - public EventSub(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public EventSub(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Extensions.cs b/TwitchLib.Api.Helix/Extensions.cs index 6cc712e3..cd6d0bff 100644 --- a/TwitchLib.Api.Helix/Extensions.cs +++ b/TwitchLib.Api.Helix/Extensions.cs @@ -16,7 +16,7 @@ namespace TwitchLib.Api.Helix /// public class Extensions : ApiBase { - public Extensions(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Extensions(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } #region GetExtensionTransactions diff --git a/TwitchLib.Api.Helix/Games.cs b/TwitchLib.Api.Helix/Games.cs index 63767f49..306c482c 100644 --- a/TwitchLib.Api.Helix/Games.cs +++ b/TwitchLib.Api.Helix/Games.cs @@ -14,7 +14,7 @@ namespace TwitchLib.Api.Helix /// public class Games : ApiBase { - public Games(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Games(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Goals.cs b/TwitchLib.Api.Helix/Goals.cs index 34de5076..76cd42e5 100644 --- a/TwitchLib.Api.Helix/Goals.cs +++ b/TwitchLib.Api.Helix/Goals.cs @@ -13,7 +13,7 @@ namespace TwitchLib.Api.Helix /// public class Goals : ApiBase { - public Goals(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Goals(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Helix.cs b/TwitchLib.Api.Helix/Helix.cs index f1fe5dc1..11db00dd 100644 --- a/TwitchLib.Api.Helix/Helix.cs +++ b/TwitchLib.Api.Helix/Helix.cs @@ -136,41 +136,41 @@ public class Helix /// Instance Of RateLimiter, otherwise no ratelimiter is used. /// Instance of ApiSettings, otherwise defaults used, can be changed later /// Instance of HttpCallHandler, otherwise default handler used - public Helix(ILoggerFactory loggerFactory = null, IRateLimiter rateLimiter = null, IApiSettings settings = null, IHttpCallHandler http = null) + public Helix(ILoggerFactory loggerFactory = null, IRateLimiter rateLimiter = null, IApiSettings settings = null, IHttpCallHandler http = null, IUserAccessTokenManager userAccessTokenManager = null) { _logger = loggerFactory?.CreateLogger(); rateLimiter = rateLimiter ?? BypassLimiter.CreateLimiterBypassInstance(); http = http ?? new TwitchHttpClient(loggerFactory?.CreateLogger()); Settings = settings ?? new ApiSettings(); - Analytics = new Analytics(Settings, rateLimiter, http); - Ads = new Ads(Settings, rateLimiter, http); - Bits = new Bits(Settings, rateLimiter, http); - Chat = new Chat(Settings, rateLimiter, http); - Channels = new Channels(Settings, rateLimiter, http); - ChannelPoints = new ChannelPoints(Settings, rateLimiter, http); - Charity = new Charity(Settings, rateLimiter, http); - Clips = new Clips(Settings, rateLimiter, http); - Entitlements = new Entitlements(Settings, rateLimiter, http); - EventSub = new EventSub(Settings, rateLimiter, http); - Extensions = new Extensions(Settings, rateLimiter, http); - Games = new Games(Settings, rateLimiter, http); - Goals = new Goals(settings, rateLimiter, http); - HypeTrain = new HypeTrain(Settings, rateLimiter, http); - Moderation = new Moderation(Settings, rateLimiter, http); - Polls = new Polls(Settings, rateLimiter, http); - Predictions = new Predictions(Settings, rateLimiter, http); - Raids = new Raids(settings, rateLimiter, http); - Schedule = new Schedule(Settings, rateLimiter, http); - Search = new Search(Settings, rateLimiter, http); - Soundtrack = new Soundtrack(Settings, rateLimiter, http); - Streams = new Streams(Settings, rateLimiter, http); - Subscriptions = new Subscriptions(Settings, rateLimiter, http); - Tags = new Tags(Settings, rateLimiter, http); - Teams = new Teams(Settings, rateLimiter, http); - Users = new Users(Settings, rateLimiter, http); - Videos = new Videos(Settings, rateLimiter, http); - Whispers = new Whispers(Settings, rateLimiter, http); + Analytics = new Analytics(Settings, rateLimiter, http, userAccessTokenManager); + Ads = new Ads(Settings, rateLimiter, http, userAccessTokenManager); + Bits = new Bits(Settings, rateLimiter, http, userAccessTokenManager); + Chat = new Chat(Settings, rateLimiter, http, userAccessTokenManager); + Channels = new Channels(Settings, rateLimiter, http, userAccessTokenManager); + ChannelPoints = new ChannelPoints(Settings, rateLimiter, http, userAccessTokenManager); + Charity = new Charity(Settings, rateLimiter, http, userAccessTokenManager); + Clips = new Clips(Settings, rateLimiter, http, userAccessTokenManager); + Entitlements = new Entitlements(Settings, rateLimiter, http, userAccessTokenManager); + EventSub = new EventSub(Settings, rateLimiter, http, userAccessTokenManager); + Extensions = new Extensions(Settings, rateLimiter, http, userAccessTokenManager); + Games = new Games(Settings, rateLimiter, http, userAccessTokenManager); + Goals = new Goals(settings, rateLimiter, http, userAccessTokenManager); + HypeTrain = new HypeTrain(Settings, rateLimiter, http, userAccessTokenManager); + Moderation = new Moderation(Settings, rateLimiter, http, userAccessTokenManager); + Polls = new Polls(Settings, rateLimiter, http, userAccessTokenManager); + Predictions = new Predictions(Settings, rateLimiter, http, userAccessTokenManager); + Raids = new Raids(settings, rateLimiter, http, userAccessTokenManager); + Schedule = new Schedule(Settings, rateLimiter, http, userAccessTokenManager); + Search = new Search(Settings, rateLimiter, http, userAccessTokenManager); + Soundtrack = new Soundtrack(Settings, rateLimiter, http, userAccessTokenManager); + Streams = new Streams(Settings, rateLimiter, http, userAccessTokenManager); + Subscriptions = new Subscriptions(Settings, rateLimiter, http, userAccessTokenManager); + Tags = new Tags(Settings, rateLimiter, http, userAccessTokenManager); + Teams = new Teams(Settings, rateLimiter, http, userAccessTokenManager); + Users = new Users(Settings, rateLimiter, http, userAccessTokenManager); + Videos = new Videos(Settings, rateLimiter, http, userAccessTokenManager); + Whispers = new Whispers(Settings, rateLimiter, http, userAccessTokenManager); } } } diff --git a/TwitchLib.Api.Helix/HypeTrain.cs b/TwitchLib.Api.Helix/HypeTrain.cs index cd044aad..952ff2ce 100644 --- a/TwitchLib.Api.Helix/HypeTrain.cs +++ b/TwitchLib.Api.Helix/HypeTrain.cs @@ -13,7 +13,7 @@ namespace TwitchLib.Api.Helix /// public class HypeTrain : ApiBase { - public HypeTrain(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public HypeTrain(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Moderation.cs b/TwitchLib.Api.Helix/Moderation.cs index 69d781e0..3e19ff19 100644 --- a/TwitchLib.Api.Helix/Moderation.cs +++ b/TwitchLib.Api.Helix/Moderation.cs @@ -27,7 +27,7 @@ namespace TwitchLib.Api.Helix /// public class Moderation : ApiBase { - public Moderation(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Moderation(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } #region ManageHeldAutoModMessage diff --git a/TwitchLib.Api.Helix/Polls.cs b/TwitchLib.Api.Helix/Polls.cs index 8bf73d77..c0a0b4d6 100644 --- a/TwitchLib.Api.Helix/Polls.cs +++ b/TwitchLib.Api.Helix/Polls.cs @@ -14,7 +14,7 @@ namespace TwitchLib.Api.Helix { public class Polls : ApiBase { - public Polls(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Polls(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Predictions.cs b/TwitchLib.Api.Helix/Predictions.cs index 61ef1eca..ab93c5f3 100644 --- a/TwitchLib.Api.Helix/Predictions.cs +++ b/TwitchLib.Api.Helix/Predictions.cs @@ -17,7 +17,7 @@ namespace TwitchLib.Api.Helix /// public class Predictions : ApiBase { - public Predictions(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Predictions(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/README.md b/TwitchLib.Api.Helix/README.md new file mode 100644 index 00000000..f5a28a17 --- /dev/null +++ b/TwitchLib.Api.Helix/README.md @@ -0,0 +1,120 @@ +

+ +

+ +## Helix API +The Twitch Helix API is the main API layer for Twitch services. The Helix API has two levels of security gated by two distinct types of OAuth tokens. The first type is a App Access Token. This token type is directly granted by Twitch's server to your application and does not require a user to be logged in to grant scope level access. Several Twitch API methods can be accessed by this type of token *but not all of them*. Several APIs can only be used with a User Access Token. This token type is scoped to access specific APIs and you must be logged into a Twitch account to allow this token to access these scopes. TwitchLib.API now supports direct authentication of User Access tokens to make this easy for you. + +Please refer to the Twitch API to learn more about the two different token types and when to use them. +https://dev.twitch.tv/docs/authentication/ + + +You can decide which type of token you will need by determining which APIs you would like to use from the Helix API set. A list of all of the APIs can be found here: +https://dev.twitch.tv/docs/api/reference/ + +For example, the Get Channel Information API will work with either an App Access Token or a User Access Token because it works with information that is publicly accessible without being logged in. Some Channel API methods however, like Start Commercial will only allow the a User Access Token with the channel:edit:commercial scope assigned to it. You can see what a Helix API method will require by looking at the Authorization section of any API method in the reference. + +One final word about scopes. Do not assign ALL of the available scopes to AppSettings.Scopes. Doing so may get your application banned. See the warning here! + +# How it works +To support User Access Tokens, TwitchLib.API has a small web server integrated into it. When you authenticate your Client ID and Secret to the Twitch API to get a User Access Token, it will open a browser window that will require you to login with your Twitch account and then grant explicit permissions to your application to do specific actions with the Twitch API. Once you do, Twitch API will forward your browser back to this web server which hands the credential securely back to TwitchLib.API. These credentials will then be used by all future calls to the Helix API. OAuth complexities like periodic validation and refresh tokens are handled for you by the library. You can also implement this yourself if you need more functionality than this built in solution provides. See the section on Settings for more information. + +> ***If you are using User Access Tokens, be sure to set AppSettings.UseUserTokenForHelixCalls to True!*** + +For App Access Tokens, simply provide your ClientId and Secret. There is no interactive authorization stage for this type of token so this can work better for a server application, or an application that will run as a daemon, service or scheduled task when interacting with a web browser is not viable. App Access Tokens are much more limited and much of the Helix API does not work with them, but some public data methods do. Review the Helix API reference to see what type of token you will need. + +# Settings +Authentication is controlled by several settings in the AppSettings class that you pass to TwitchLib.API when your application starts up. + +| Setting Name | Description | +| --- | --- | +| AccessToken | The current access token that is being used to access the Helix API. You can specify this yourself to directly control the Access Token in use, or you can let the TwitchLib.API library control it for you. | +| Secret | This comes from the Twitch Developer console after you set up your application. Used for both App Access and User Access Tokens | +| ClientId | This comes from the Twitch Developer console after you set up your application. Used for both App Access and User Access Tokens | +| SkipAutoServerTokenGeneration | If AccessToken is null, and this is set to true, then Helix API calls will not attempt to use the ClientID/Secret to generate a client_credential access token. Set this to true if you intend to implement your own token management. Defaults to False.| +| Scopes | Add scopes that your application will be using to this collection before calling any Helix APIs. A list of scopes can be found here. See the TwitchAPI reference for the scopes specific to each API. Note: Do not add ALL the scopes, or your account may be banned! See warning here.) | +| OAuthResponsePort | Set this value to another port if you have another application already listening to port 5000 on your machine. Defaults to: 5000 | +| OAuthResponseHostname | Set this value to a host name or IP address if you have a multi-homed machine (more than one IP address) and you would like to bind the OAuth response listener to a specific IP address. Defaults to 'localhost' | +| OAuthTokenFile | Storage for oAuth refresh token, expiration dates, etc. Defaults to %AppData%\\TwitchLib.API\[ApplicationName].json Set this if you will be running multiple instances of the same application that you would like to use with different user tokens. | +| UseUserTokenForHelixCalls | Set this value to true to enable Helix calls that require an oAuth User Token. This requires you to also set ApiSettings.ClientID and ApiSettings.Secret. Defaults to False | +| EnableInsecureTokenStorage | TwitchLib.API keeps OAuthTokenFile in a relatively secure location, but anyone with administrator access to your computer could get access to this file and then access to Twitch API using the scopes you have granted to this token. While unlikely, we do want to call out that using this file is insecure. Enabling this setting will enable your app to run without re-authenticating between runs. Defaults to False. | + + +# Prerequisites +Before you get started with the Helix API, you must first register your application with the Twitch Developer console. Follow the Twitch API guide here. Use http://localhost:5000/api/callback for your OAuth Redirect URL. Come back once you have your Application's ClientId and Secret. + +# Example 1 - App Access Token +```csharp + var settings = new ApiSettings + { + ClientId = CLIENT_ID, + Secret = SECRET + }; + + // While we are setting the correct scopes, they will not be used because we are not + // using a User Access Token. + settings.Scopes.Add(Core.Enums.AuthScopes.Helix_Moderation_Read); + settings.Scopes.Add(Core.Enums.AuthScopes.Helix_Moderator_Manage_Banned_Users); + + using ILoggerFactory loggerFactory = + LoggerFactory.Create(builder => + builder.AddSimpleConsole(options => + { + options.IncludeScopes = true; + options.SingleLine = true; + options.TimestampFormat = "HH:mm:ss "; + })); + + var twitchAPI = new TwitchAPI(loggerFactory: loggerFactory, settings: settings); + + var channelInformation = twitchAPI.Helix.Channels.GetChannelInformationAsync(BROADCASTER_ID); + + Console.WriteLine($"Channel Information Returned: {channelInformation.Result.Data.Length}"); + + try + { + // This will fail because an App Access Token cannot be used with the Helix Moderation API. + var bannedUsers = twitchAPI.Helix.Moderation.GetBannedUsersAsync(BROADCASTER_ID); + + Console.WriteLine($"Banned user count: {bannedUsers.Result.Data.Length}"); + } + catch (Exception ex) + { + Console.WriteLine($"\n\n\nCan't use an App Access Token with the Helix Moderation API: {ex.Message}\n\n\n"); + } +``` + +# Example 2 - User Access Token +```csharp +var settings = new ApiSettings +{ + ClientId = CLIENT_ID, + Secret = SECRET, + EnableInsecureTokenStorage = true, // Allows the application to start silently after the first authorization. + UseUserTokenForHelixCalls = true // Enables User Access Tokens to be used for Helix API calls. +}; + +// Now that we are using User Access Tokens, the Helix Scopes can also be used. +settings.Scopes.Add(Core.Enums.AuthScopes.Helix_Moderation_Read); +settings.Scopes.Add(Core.Enums.AuthScopes.Helix_Moderator_Manage_Banned_Users); + +using ILoggerFactory loggerFactory = + LoggerFactory.Create(builder => + builder.AddSimpleConsole(options => + { + options.IncludeScopes = true; + options.SingleLine = true; + options.TimestampFormat = "HH:mm:ss "; + })); + +var twitchAPI = new TwitchAPI(loggerFactory: loggerFactory, settings: settings); + +var channelInformation = twitchAPI.Helix.Channels.GetChannelInformationAsync(BROADCASTER_ID); + +Console.WriteLine($"Channel Information Returned: {channelInformation.Result.Data.Length}"); + +// This works fine now because we have a User Access Token with the correct scopes. +var bannedUsers = twitchAPI.Helix.Moderation.GetBannedUsersAsync(BROADCASTER_ID); + +Console.WriteLine($"Banned user count: {bannedUsers.Result.Data.Length}"); +``` \ No newline at end of file diff --git a/TwitchLib.Api.Helix/Raids.cs b/TwitchLib.Api.Helix/Raids.cs index 2a833193..f5b71244 100644 --- a/TwitchLib.Api.Helix/Raids.cs +++ b/TwitchLib.Api.Helix/Raids.cs @@ -12,7 +12,7 @@ namespace TwitchLib.Api.Helix /// public class Raids : ApiBase { - public Raids(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Raids(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Schedule.cs b/TwitchLib.Api.Helix/Schedule.cs index e88e2fdb..e23b6642 100644 --- a/TwitchLib.Api.Helix/Schedule.cs +++ b/TwitchLib.Api.Helix/Schedule.cs @@ -18,7 +18,7 @@ namespace TwitchLib.Api.Helix /// public class Schedule : ApiBase { - public Schedule(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Schedule(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } /// diff --git a/TwitchLib.Api.Helix/Search.cs b/TwitchLib.Api.Helix/Search.cs index f8c37a23..095e2fa7 100644 --- a/TwitchLib.Api.Helix/Search.cs +++ b/TwitchLib.Api.Helix/Search.cs @@ -13,7 +13,7 @@ namespace TwitchLib.Api.Helix /// public class Search : ApiBase { - public Search(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Search(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Soundtrack.cs b/TwitchLib.Api.Helix/Soundtrack.cs index 854bc466..3113eaff 100644 --- a/TwitchLib.Api.Helix/Soundtrack.cs +++ b/TwitchLib.Api.Helix/Soundtrack.cs @@ -15,7 +15,7 @@ namespace TwitchLib.Api.Helix /// public class Soundtrack : ApiBase { - public Soundtrack(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Soundtrack(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Streams.cs b/TwitchLib.Api.Helix/Streams.cs index f3612aaf..6562aa7f 100644 --- a/TwitchLib.Api.Helix/Streams.cs +++ b/TwitchLib.Api.Helix/Streams.cs @@ -21,7 +21,7 @@ namespace TwitchLib.Api.Helix /// public class Streams : ApiBase { - public Streams(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Streams(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Subscriptions.cs b/TwitchLib.Api.Helix/Subscriptions.cs index aaac69b6..3ee852f5 100644 --- a/TwitchLib.Api.Helix/Subscriptions.cs +++ b/TwitchLib.Api.Helix/Subscriptions.cs @@ -14,7 +14,7 @@ namespace TwitchLib.Api.Helix /// public class Subscriptions : ApiBase { - public Subscriptions(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Subscriptions(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Tags.cs b/TwitchLib.Api.Helix/Tags.cs index 0e6e2a81..6b4e9a0c 100644 --- a/TwitchLib.Api.Helix/Tags.cs +++ b/TwitchLib.Api.Helix/Tags.cs @@ -14,7 +14,7 @@ namespace TwitchLib.Api.Helix /// public class Tags : ApiBase { - public Tags(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Tags(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Teams.cs b/TwitchLib.Api.Helix/Teams.cs index b1461e67..091651a9 100644 --- a/TwitchLib.Api.Helix/Teams.cs +++ b/TwitchLib.Api.Helix/Teams.cs @@ -12,7 +12,7 @@ namespace TwitchLib.Api.Helix /// public class Teams : ApiBase { - public Teams(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Teams(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Users.cs b/TwitchLib.Api.Helix/Users.cs index 4a226a8e..e3100283 100644 --- a/TwitchLib.Api.Helix/Users.cs +++ b/TwitchLib.Api.Helix/Users.cs @@ -22,7 +22,7 @@ namespace TwitchLib.Api.Helix /// public class Users : ApiBase { - public Users(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Users(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Videos.cs b/TwitchLib.Api.Helix/Videos.cs index bdb1bb61..45441770 100644 --- a/TwitchLib.Api.Helix/Videos.cs +++ b/TwitchLib.Api.Helix/Videos.cs @@ -16,7 +16,7 @@ namespace TwitchLib.Api.Helix /// public class Videos : ApiBase { - public Videos(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Videos(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } diff --git a/TwitchLib.Api.Helix/Whispers.cs b/TwitchLib.Api.Helix/Whispers.cs index 314c4923..c0da8c9a 100644 --- a/TwitchLib.Api.Helix/Whispers.cs +++ b/TwitchLib.Api.Helix/Whispers.cs @@ -13,7 +13,7 @@ namespace TwitchLib.Api.Helix /// public class Whispers : ApiBase { - public Whispers(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public Whispers(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http, IUserAccessTokenManager userAccessTokenManager) : base(settings, rateLimiter, http, userAccessTokenManager) { } #region SendWhisper diff --git a/TwitchLib.Api/Auth/AccessCodeResponse.cs b/TwitchLib.Api/Auth/AccessCodeResponse.cs new file mode 100644 index 00000000..7ab6f549 --- /dev/null +++ b/TwitchLib.Api/Auth/AccessCodeResponse.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace TwitchLib.Api.Auth +{ + public class AccessCodeResponse + { + [JsonProperty(PropertyName = "access_code")] + public string AccessCode { get; set; } + + [JsonProperty(PropertyName = "scope")] + public string[] Scopes { get; set; } + + [JsonProperty(PropertyName = "state")] + public string State { get; set; } + } +} diff --git a/TwitchLib.Api/Auth/Auth.cs b/TwitchLib.Api/Auth/Auth.cs index 0be63f6a..f1d92701 100644 --- a/TwitchLib.Api/Auth/Auth.cs +++ b/TwitchLib.Api/Auth/Auth.cs @@ -1,4 +1,8 @@ -using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; using System.Threading.Tasks; using System.Web; using TwitchLib.Api.Core; @@ -11,8 +15,32 @@ namespace TwitchLib.Api.Auth /// These endpoints fall outside of v5 and Helix, and relate to Authorization public class Auth : ApiBase { - public Auth(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + private ILogger _logger; + private IApiSettings _settings; + + internal Auth(ILogger logger, IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http, null) { + _logger = logger; + _settings = settings; + } + + internal AccessCodeResponse GetAccessCodeFromClientIdAndSecret(CancellationTokenSource cancellationToken, string clientId, string secret) + { + string authorizationUrl = GetAuthorizationCodeUrl($"http://{_settings.OAuthResponseHostname}:{_settings.OAuthResponsePort}/api/callback", _settings.Scopes, forceVerify: false, state: Guid.NewGuid().ToString(), clientId: clientId); + + _logger.LogTrace($"Auth::GetAccessCodeFromClientIdAndSecret(): authorizationUrl = {authorizationUrl}"); + + // Launch a browser to authorize the user's scope and trigger Twitch to return an access code. + Process process = new Process(); + process.StartInfo.UseShellExecute = true; + process.StartInfo.FileName = authorizationUrl; + process.Start(); + + var authenticationServerManager = new AuthenticationServerManager(_logger); + + AccessCodeResponse response = authenticationServerManager.WaitForAuthorizationCodeCallback(cancellationToken, _settings.OAuthResponseHostname, _settings.OAuthResponsePort); + + return response; } /// diff --git a/TwitchLib.Api/Auth/AuthenticationServerManager.cs b/TwitchLib.Api/Auth/AuthenticationServerManager.cs new file mode 100644 index 00000000..6213d065 --- /dev/null +++ b/TwitchLib.Api/Auth/AuthenticationServerManager.cs @@ -0,0 +1,64 @@ + +using System; +using System.Threading; +using System.Threading.Tasks; +using EmbedIO; +using EmbedIO.WebApi; +using Microsoft.Extensions.Logging; +using Swan.Logging; + +namespace TwitchLib.Api.Auth +{ + /// + /// Controls a private WebAPI service that can receive authentication results from oAuth. + /// + internal class AuthenticationServerManager + { + internal AuthenticationServerManager(Microsoft.Extensions.Logging.ILogger logger) + { + Logger.UnregisterLogger(); + + if (logger != null) + Logger.RegisterLogger(new LoggerBridge(logger)); + } + + private event EventHandler _accessCodeReceived; + + internal AccessCodeResponse WaitForAuthorizationCodeCallback(CancellationTokenSource cancellationToken, string hostname, int listenerPort) + { + AccessCodeResponse returnValue = null; + AutoResetEvent waitHandle = new AutoResetEvent(false); + + _accessCodeReceived += (source, accessCodeResponse) => + { + returnValue = accessCodeResponse; + waitHandle.Set(); + }; + + StartLocalService(cancellationToken.Token, hostname, listenerPort); + + // Wait for oAuth to complete and Twitch to call us back with the Access Code. + if (waitHandle.WaitOne(30 * 1000) == false) + { + cancellationToken.Cancel(); + throw new TimeoutException("More than 30 seconds elapsed without receiving an Access Code from Twitch. Check https://dev.twitch.tv/console to ensure the callback URL is in the oAuth Redirect URLs list."); + } + + // Shutdown the web server. + cancellationToken.Cancel(); + + return returnValue; + } + + private void StartLocalService(CancellationToken cancellationToken, string hostname, int port = 5000) + { + WebServer ws = new WebServer(o => o + .WithUrlPrefix($"http://{hostname}:{port}") + .WithMode(HttpListenerMode.EmbedIO)) + .WithWebApi("/api", m => m + .WithController(() => { return new ApiController(_accessCodeReceived); })); + + ws.RunAsync(cancellationToken); + } + } +} diff --git a/TwitchLib.Api/Auth/LoggerBridge.cs b/TwitchLib.Api/Auth/LoggerBridge.cs new file mode 100644 index 00000000..75503808 --- /dev/null +++ b/TwitchLib.Api/Auth/LoggerBridge.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Logging; +using Swan.Logging; +using System; +using System.Collections.Generic; +using System.Text; + +namespace TwitchLib.Api.Auth +{ + internal class LoggerBridge : Swan.Logging.ILogger + { + private Microsoft.Extensions.Logging.ILogger _logger; + + public LoggerBridge(Microsoft.Extensions.Logging.ILogger logger) => _logger = logger; + + public Swan.Logging.LogLevel LogLevel => Swan.Logging.LogLevel.Info; + + public void Dispose() + { + } + + public void Log(LogMessageReceivedEventArgs logEvent) + { + _logger.LogInformation(logEvent.Message); + } + } +} diff --git a/TwitchLib.Api/Auth/UserAccessTokenManager.cs b/TwitchLib.Api/Auth/UserAccessTokenManager.cs new file mode 100644 index 00000000..08557f60 --- /dev/null +++ b/TwitchLib.Api/Auth/UserAccessTokenManager.cs @@ -0,0 +1,134 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using TwitchLib.Api.Core; +using TwitchLib.Api.Core.Enums; +using TwitchLib.Api.Core.Interfaces; +using TwitchLib.Api.Helix.Models.Extensions.ReleasedExtensions; +using TwitchLib.Api.Interfaces; +using static Swan.Terminal; + +namespace TwitchLib.Api.Auth +{ + internal class UserAccessTokenManager : IUserAccessTokenManager + { + IApiSettings _settings; + private Auth _auth; + private ILogger _logger; + + private DateTime _tokenExpiration = DateTime.MinValue; + private DateTime _tokenValidation = DateTime.MinValue; + private string _refreshToken = null; + private string _accessToken = null; + private string[] _scopes; + + public UserAccessTokenManager(IApiSettings settings, Auth auth, ILogger logger) + { + _settings = settings; + _auth = auth; + _logger = logger; + + if (Directory.Exists(Path.GetDirectoryName(_settings.OAuthTokenFile)) == false) + Directory.CreateDirectory(Path.GetDirectoryName(_settings.OAuthTokenFile)); + } + + public async Task GetUserAccessToken() + { + // First, see if the stored refresh token is still valid. + if (_refreshToken == null) + { + if (File.Exists(_settings.OAuthTokenFile) == true) + { + RefreshResponse refreshData = JsonConvert.DeserializeObject(File.ReadAllText(_settings.OAuthTokenFile)); + _tokenExpiration = File.GetCreationTime(_settings.OAuthTokenFile).AddSeconds(refreshData.ExpiresIn); + + _refreshToken = refreshData.RefreshToken; + _scopes = refreshData.Scopes; + _accessToken = refreshData.AccessToken; + + _logger.LogTrace($"UserAccessTokenManager::GetUserAccessToken(): Loaded oAuth token from file: {_settings.OAuthTokenFile}"); + _logger.LogTrace($"UserAccessTokenManager::GetUserAccessToken(): Token expiration: {_tokenExpiration.ToString()}"); + _logger.LogTrace($"UserAccessTokenManager::GetUserAccessToken(): Token scopes: {String.Join(",", _scopes)}"); + } + } + + // If we couldn't read the refresh token, then do a full reauthorize. + if (_refreshToken == null || DoScopesMatchSettings(_settings.Scopes, _scopes) == false) + { + await Reauthorize(); + } + + // If the token hasn't expired yet, then we can return it. + if (_tokenExpiration > DateTime.Now.AddMinutes(5)) + { + // Ensure that the token is still valid once every 55 minutes. + if(_tokenValidation.AddMinutes(55) > DateTime.Now || IsAccessTokenStillValid(_accessToken) == true) + return _accessToken; + } + + // If the token has expired, then do a refresh + await Refresh(); + + return _accessToken; + } + + private bool IsAccessTokenStillValid(string accessToken) + { + _tokenValidation = DateTime.Now; + + var result = _auth.ValidateAccessTokenAsync(accessToken); + + return result.Result != null; + } + + private bool DoScopesMatchSettings(List desiredScopes, string[] currentScopes) + { + if(currentScopes.Length != desiredScopes.Count) + return false; + + var matches = desiredScopes.Join(currentScopes, p => TwitchLib.Api.Core.Common.Helpers.AuthScopesToString(p), r => r, (p, r) => p); + + if(matches.Count() != currentScopes.Length) + return false; + + return true; + } + + private async Task Reauthorize() + { + CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); + + var accessCodeResponse = _auth.GetAccessCodeFromClientIdAndSecret(cancellationTokenSource, _settings.ClientId, _settings.Secret); + Console.WriteLine($"code: {accessCodeResponse.AccessCode}"); + + var accessTokenReponse = await _auth.GetAccessTokenFromCodeAsync(accessCodeResponse.AccessCode, _settings.Secret, $"http://{_settings.OAuthResponseHostname}:{_settings.OAuthResponsePort}/api/callback", _settings.ClientId); + Console.WriteLine($"accessToken: {accessTokenReponse.AccessToken}, Expiration: {DateTime.Now.AddSeconds(accessTokenReponse.ExpiresIn)}"); + + _accessToken = accessTokenReponse.AccessToken; + _scopes = accessTokenReponse.Scopes; + _refreshToken = accessTokenReponse.RefreshToken; + _tokenExpiration = DateTime.Now.AddSeconds(accessTokenReponse.ExpiresIn); + + File.WriteAllText(_settings.OAuthTokenFile, JsonConvert.SerializeObject(accessTokenReponse)); + } + + private async Task Refresh() + { + var refreshResponse = await _auth.RefreshAuthTokenAsync(_refreshToken, _settings.Secret, _settings.ClientId); + + _accessToken = refreshResponse.AccessToken; + _scopes = refreshResponse.Scopes; + _refreshToken = refreshResponse.RefreshToken; + _tokenExpiration = DateTime.Now.AddSeconds(refreshResponse.ExpiresIn); + + File.WriteAllText(_settings.OAuthTokenFile, JsonConvert.SerializeObject(refreshResponse)); + } + } +} diff --git a/TwitchLib.Api/Auth/WebApiController.cs b/TwitchLib.Api/Auth/WebApiController.cs new file mode 100644 index 00000000..c4bb5f11 --- /dev/null +++ b/TwitchLib.Api/Auth/WebApiController.cs @@ -0,0 +1,32 @@ +using EmbedIO.Routing; +using EmbedIO.WebApi; +using EmbedIO; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace TwitchLib.Api.Auth +{ + internal class ApiController : WebApiController + { + EventHandler AccessCodeReceived; + + public ApiController(EventHandler accessCodeReceived) : base() + { + AccessCodeReceived = accessCodeReceived; + } + + [Route(HttpVerbs.Get, "/callback")] + public void Callback([QueryField] string code, [QueryField] string scope, [QueryField] string state) + { + //await HttpContext.SendDataAsync($"code: {code}, scope: {scope}, state: {state}"); + AccessCodeReceived?.Invoke(this, new AccessCodeResponse + { + AccessCode = code, + Scopes = scope.Split(' '), + State = state + }); + } + } +} diff --git a/TwitchLib.Api/ThirdParty/ThirdParty.cs b/TwitchLib.Api/ThirdParty/ThirdParty.cs index 2f73bb19..62549cd2 100644 --- a/TwitchLib.Api/ThirdParty/ThirdParty.cs +++ b/TwitchLib.Api/ThirdParty/ThirdParty.cs @@ -30,7 +30,7 @@ public ThirdParty(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHand public class UsernameChangeApi : ApiBase { - public UsernameChangeApi(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public UsernameChangeApi(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http, null) { } @@ -52,7 +52,7 @@ public Task> GetUsernameChangesAsync(string username public class ModLookupApi : ApiBase { - public ModLookupApi(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public ModLookupApi(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http, null) { } @@ -92,7 +92,7 @@ public class AuthorizationFlowApi : ApiBase private string _apiId; private Timer _pingTimer; - public AuthorizationFlowApi(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http) + public AuthorizationFlowApi(IApiSettings settings, IRateLimiter rateLimiter, IHttpCallHandler http) : base(settings, rateLimiter, http, null) { } diff --git a/TwitchLib.Api/TwitchAPI.cs b/TwitchLib.Api/TwitchAPI.cs index 244fbd51..3ef4e394 100644 --- a/TwitchLib.Api/TwitchAPI.cs +++ b/TwitchLib.Api/TwitchAPI.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using Microsoft.Extensions.Logging; +using TwitchLib.Api.Auth; using TwitchLib.Api.Core; using TwitchLib.Api.Core.HttpCallHandlers; using TwitchLib.Api.Core.Interfaces; @@ -32,10 +33,15 @@ public TwitchAPI(ILoggerFactory loggerFactory = null, IRateLimiter rateLimiter = http = http ?? new TwitchHttpClient(loggerFactory?.CreateLogger()); Settings = settings ?? new ApiSettings(); - Auth = new Auth.Auth(Settings, rateLimiter, http); - Helix = new Helix.Helix(loggerFactory, rateLimiter, Settings, http); + + + Auth = new Auth.Auth(_logger, Settings, rateLimiter, http); + + var userAccessTokenManager = new UserAccessTokenManager(settings, Auth, _logger); + + Helix = new Helix.Helix(loggerFactory, rateLimiter, Settings, http, userAccessTokenManager); ThirdParty = new ThirdParty.ThirdParty(Settings, rateLimiter, http); - Undocumented = new Undocumented(Settings, rateLimiter, http); + Undocumented = new Undocumented(Settings, rateLimiter, http, userAccessTokenManager); Settings.PropertyChanged += SettingsPropertyChanged; } diff --git a/TwitchLib.Api/TwitchLib.Api.csproj b/TwitchLib.Api/TwitchLib.Api.csproj index ca3d26ef..221774f7 100644 --- a/TwitchLib.Api/TwitchLib.Api.csproj +++ b/TwitchLib.Api/TwitchLib.Api.csproj @@ -23,6 +23,7 @@ True +