diff --git a/src/Nullinside.Api.Common/Twitch/ITwitchApiProxy.cs b/src/Nullinside.Api.Common/Twitch/ITwitchApiProxy.cs index f5d651c..bcb3ca9 100644 --- a/src/Nullinside.Api.Common/Twitch/ITwitchApiProxy.cs +++ b/src/Nullinside.Api.Common/Twitch/ITwitchApiProxy.cs @@ -14,6 +14,11 @@ public interface ITwitchApiProxy { /// The Twitch access token. These are the credentials used for all requests. /// TwitchAccessToken? OAuth { get; set; } + + /// + /// The Twitch app configuration. These are used for all requests. + /// + TwitchAppConfig? TwitchAppConfig { get; set; } /// /// Creates a new access token from a code using Twitch's OAuth workflow. diff --git a/src/Nullinside.Api.Common/Twitch/TwitchApiProxy.cs b/src/Nullinside.Api.Common/Twitch/TwitchApiProxy.cs index 56a3ec4..fc0f708 100644 --- a/src/Nullinside.Api.Common/Twitch/TwitchApiProxy.cs +++ b/src/Nullinside.Api.Common/Twitch/TwitchApiProxy.cs @@ -29,25 +29,15 @@ public class TwitchApiProxy : ITwitchApiProxy { /// private static readonly ILog Log = LogManager.GetLogger(typeof(TwitchApiProxy)); - /// - /// The, public, twitch client id. - /// - private static readonly string ClientId = Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_ID")!; - - /// - /// The, private, twitch client secret. - /// - private static readonly string ClientSecret = Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_SECRET")!; - - /// - /// The redirect url. - /// - private static readonly string ClientRedirect = Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_REDIRECT")!; - /// /// Initializes a new instance of the class. /// public TwitchApiProxy() { + TwitchAppConfig = new() { + ClientId = Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_ID"), + ClientSecret = Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_SECRET"), + ClientRedirect = Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_REDIRECT") + }; } /// @@ -56,12 +46,25 @@ public TwitchApiProxy() { /// The access token. /// The refresh token. /// When the token expires (utc). - public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires) { + /// The client id of the registered twitch app, uses environment variable + /// "TWITCH_BOT_CLIENT_ID" when null. + /// The client secret of the registered twitch app, uses environment variable + /// "TWITCH_BOT_CLIENT_SECRET" when null. + /// The url to redirect to from the registered twitch app, uses environment variable + /// "TWITCH_BOT_CLIENT_REDIRECT" when null. + public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires, string? clientId = null, + string? clientSecret = null, string? clientRedirect = null) { OAuth = new TwitchAccessToken { AccessToken = token, RefreshToken = refreshToken, ExpiresUtc = tokenExpires }; + + TwitchAppConfig = new() { + ClientId = clientId ?? Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_ID"), + ClientSecret = clientSecret ?? Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_SECRET"), + ClientRedirect = clientRedirect ?? Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_REDIRECT") + }; } /// @@ -70,12 +73,16 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires) public int Retries { get; set; } = 3; /// - public TwitchAccessToken? OAuth { get; set; } + public virtual TwitchAccessToken? OAuth { get; set; } + + /// + public virtual TwitchAppConfig? TwitchAppConfig { get; set; } /// - public async Task CreateAccessToken(string code, CancellationToken token = new()) { + public virtual async Task CreateAccessToken(string code, CancellationToken token = new()) { ITwitchAPI api = GetApi(); - AuthCodeResponse? response = await api.Auth.GetAccessTokenFromCodeAsync(code, ClientSecret, ClientRedirect); + AuthCodeResponse? response = await api.Auth.GetAccessTokenFromCodeAsync(code, TwitchAppConfig?.ClientSecret, + TwitchAppConfig?.ClientRedirect); if (null == response) { return null; } @@ -89,10 +96,14 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires) } /// - public async Task RefreshAccessToken(CancellationToken token = new()) { + public virtual async Task RefreshAccessToken(CancellationToken token = new()) { try { + if (string.IsNullOrWhiteSpace(TwitchAppConfig?.ClientSecret) || string.IsNullOrWhiteSpace(TwitchAppConfig?.ClientId)) { + return null; + } + ITwitchAPI api = GetApi(); - RefreshResponse? response = await api.Auth.RefreshAuthTokenAsync(OAuth?.RefreshToken, ClientSecret, ClientId); + RefreshResponse? response = await api.Auth.RefreshAuthTokenAsync(OAuth?.RefreshToken, TwitchAppConfig?.ClientSecret, TwitchAppConfig?.ClientId); if (null == response) { return null; } @@ -111,11 +122,16 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires) /// public async Task GetAccessTokenIsValid(CancellationToken token = new()) { - return !string.IsNullOrWhiteSpace((await GetUser(token)).id); + try { + return !string.IsNullOrWhiteSpace((await GetUser(token)).id); + } + catch { + return false; + } } /// - public async Task<(string? id, string? username)> GetUser(CancellationToken token = new()) { + public virtual async Task<(string? id, string? username)> GetUser(CancellationToken token = new()) { return await Retry.Execute(async () => { ITwitchAPI api = GetApi(); GetUsersResponse? response = await api.Helix.Users.GetUsersAsync(); @@ -129,7 +145,7 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires) } /// - public async Task GetUserEmail(CancellationToken token = new()) { + public virtual async Task GetUserEmail(CancellationToken token = new()) { return await Retry.Execute(async () => { ITwitchAPI api = GetApi(); GetUsersResponse? response = await api.Helix.Users.GetUsersAsync(); @@ -142,7 +158,7 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires) } /// - public async Task> GetUserModChannels(string userId) { + public virtual async Task> GetUserModChannels(string userId) { using var client = new HttpClient(); var ret = new List(); @@ -155,7 +171,7 @@ public async Task> GetUserModChannels(string var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.Add("Authorization", $"Bearer {OAuth?.AccessToken}"); - request.Headers.Add("Client-Id", ClientId); + request.Headers.Add("Client-Id", TwitchAppConfig?.ClientId); using HttpResponseMessage response = await client.SendAsync(request); response.EnsureSuccessStatusCode(); @@ -173,7 +189,7 @@ public async Task> GetUserModChannels(string } /// - public async Task> BanChannelUsers(string channelId, string botId, + public virtual async Task> BanChannelUsers(string channelId, string botId, IEnumerable<(string Id, string Username)> users, string reason, CancellationToken token = new()) { return await Retry.Execute(async () => { ITwitchAPI api = GetApi(); @@ -207,7 +223,7 @@ public async Task> BanChannelUsers(string channelId, str } /// - public async Task> GetChannelUsers(string channelId, string botId, + public virtual async Task> GetChannelUsers(string channelId, string botId, CancellationToken token = new()) { return await Retry.Execute(async () => { ITwitchAPI api = GetApi(); @@ -231,7 +247,7 @@ public async Task> GetChannelUsers(string channelId, string } /// - public async Task> GetChannelsLive(IEnumerable userIds) { + public virtual async Task> GetChannelsLive(IEnumerable userIds) { ITwitchAPI api = GetApi(); // We can only query 100 at a time, so throttle the search. @@ -255,7 +271,7 @@ public async Task> GetChannelsLive(IEnumerable userI } /// - public async Task> GetChannelMods(string channelId, CancellationToken token = new()) { + public virtual async Task> GetChannelMods(string channelId, CancellationToken token = new()) { return await Retry.Execute(async () => { ITwitchAPI api = GetApi(); @@ -282,7 +298,7 @@ public async Task> GetChannelsLive(IEnumerable userI } /// - public async Task AddChannelMod(string channelId, string userId, CancellationToken token = new()) { + public virtual async Task AddChannelMod(string channelId, string userId, CancellationToken token = new()) { return await Retry.Execute(async () => { ITwitchAPI api = GetApi(); await api.Helix.Moderation.AddChannelModeratorAsync(channelId, userId); @@ -294,10 +310,10 @@ public async Task> GetChannelsLive(IEnumerable userI /// Gets a new instance of the . /// /// A new instance of the . - protected ITwitchAPI GetApi() { + protected virtual ITwitchAPI GetApi() { var api = new TwitchAPI { Settings = { - ClientId = ClientId, + ClientId = TwitchAppConfig?.ClientId, AccessToken = OAuth?.AccessToken } }; diff --git a/src/Nullinside.Api.Common/Twitch/TwitchAppConfig.cs b/src/Nullinside.Api.Common/Twitch/TwitchAppConfig.cs new file mode 100644 index 0000000..4e5222c --- /dev/null +++ b/src/Nullinside.Api.Common/Twitch/TwitchAppConfig.cs @@ -0,0 +1,21 @@ +namespace Nullinside.Api.Common.Twitch; + +/// +/// The configuration for a twitch app that provides OAuth tokens. +/// +public class TwitchAppConfig { + /// + /// The client id. + /// + public string? ClientId { get; set; } + + /// + /// The client secret. + /// + public string? ClientSecret { get; set; } + + /// + /// A registered URL that the Twitch API is allowed to redirect to on our website. + /// + public string? ClientRedirect { get; set; } +} \ No newline at end of file diff --git a/src/Nullinside.Api/Controllers/UserController.cs b/src/Nullinside.Api/Controllers/UserController.cs index a89a2a2..e112c58 100644 --- a/src/Nullinside.Api/Controllers/UserController.cs +++ b/src/Nullinside.Api/Controllers/UserController.cs @@ -122,6 +122,69 @@ public async Task TwitchLogin([FromQuery] string code, [FromServ return Redirect($"{siteUrl}/user/login?token={bearerToken}"); } + + /// + /// **NOT CALLED BY SITE OR USERS** This endpoint is called by twitch as part of their oauth workflow. It + /// redirects users back to the nullinside website. + /// + /// The credentials provided by twitch. + /// The twitch api. + /// The cancellation token. + /// + /// A redirect to the nullinside website. + /// Errors: + /// 2 = Internal error generating token. + /// 3 = Code was invalid + /// 4 = Twitch account has no email + /// + [AllowAnonymous] + [HttpGet] + [Route("twitch-login/twitch-streaming-tools")] + public async Task TwitchStreamingToolsLogin([FromQuery] string code, [FromServices] ITwitchApiProxy api, + CancellationToken token = new()) { + string? siteUrl = _configuration.GetValue("Api:SiteUrl"); + if (null == await api.CreateAccessToken(code, token)) { + return Redirect($"{siteUrl}/user/login/desktop?error=3"); + } + + return Redirect($"{siteUrl}/user/login/desktop?bearer={api.OAuth?.AccessToken}&refresh={api.OAuth?.RefreshToken}&expiresUtc={api.OAuth?.ExpiresUtc?.ToString()}"); + } + + /// + /// Used to refresh OAuth tokens from the desktop application. + /// + /// The oauth refresh token provided by twitch. + /// The twitch api. + /// The cancellation token. + /// + /// A redirect to the nullinside website. + /// Errors: + /// 2 = Internal error generating token. + /// 3 = Code was invalid + /// 4 = Twitch account has no email + /// + [AllowAnonymous] + [HttpPost] + [Route("twitch-login/twitch-streaming-tools")] + public async Task TwitchStreamingToolsRefreshToken(string refreshToken, [FromServices] ITwitchApiProxy api, + CancellationToken token = new()) { + string? siteUrl = _configuration.GetValue("Api:SiteUrl"); + api.OAuth = new() { + AccessToken = null, + RefreshToken = refreshToken, + ExpiresUtc = DateTime.MinValue + }; + + if (null == await api.RefreshAccessToken(token)) { + return this.BadRequest(); + } + + return Ok(new { + bearer = api.OAuth.AccessToken, + refresh = api.OAuth.RefreshToken, + expiresUtc = api.OAuth.ExpiresUtc + }); + } /// /// Gets the roles of the current user. diff --git a/src/Nullinside.Api/Program.cs b/src/Nullinside.Api/Program.cs index 7ad9380..9d12fcf 100644 --- a/src/Nullinside.Api/Program.cs +++ b/src/Nullinside.Api/Program.cs @@ -31,7 +31,7 @@ builder.EnableRetryOnFailure(3); })); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddTransient(); builder.Services.AddAuthentication() .AddScheme("Bearer", _ => { }); builder.Services.AddScoped();