From 300012565681500346149f3b7a9382a204976ebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=E2=96=88=E2=96=88=E2=96=88=E2=96=88=E2=96=88?= Date: Sat, 4 Jan 2025 17:26:23 -0500 Subject: [PATCH 1/3] feat: adding endpoint for generating oauth token for desktop app --- .../Controllers/UserController.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/Nullinside.Api/Controllers/UserController.cs b/src/Nullinside.Api/Controllers/UserController.cs index a89a2a2..3ec749e 100644 --- a/src/Nullinside.Api/Controllers/UserController.cs +++ b/src/Nullinside.Api/Controllers/UserController.cs @@ -122,6 +122,33 @@ 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?error=3"); + } + + return Redirect($"{siteUrl}/user/login?token={api.OAuth?.AccessToken}&refresh={api.OAuth?.RefreshToken}&expiresUtc={api.OAuth?.ExpiresUtc?.ToString()}&desktop=true"); + } /// /// Gets the roles of the current user. From 0956c53c2e11e5298bb3df7da8076d0d1f75703c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=E2=96=88=E2=96=88=E2=96=88=E2=96=88=E2=96=88?= Date: Wed, 19 Feb 2025 17:18:37 -0500 Subject: [PATCH 2/3] feat: allowing the setting of twitch app settings --- .../Twitch/TwitchApiProxy.cs | 60 ++++++++++++------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/src/Nullinside.Api.Common/Twitch/TwitchApiProxy.cs b/src/Nullinside.Api.Common/Twitch/TwitchApiProxy.cs index 56a3ec4..15256f0 100644 --- a/src/Nullinside.Api.Common/Twitch/TwitchApiProxy.cs +++ b/src/Nullinside.Api.Common/Twitch/TwitchApiProxy.cs @@ -32,17 +32,17 @@ public class TwitchApiProxy : ITwitchApiProxy { /// /// The, public, twitch client id. /// - private static readonly string ClientId = Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_ID")!; + protected readonly string? _clientId; /// /// The, private, twitch client secret. /// - private static readonly string ClientSecret = Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_SECRET")!; + protected readonly string? _clientSecret; /// /// The redirect url. /// - private static readonly string ClientRedirect = Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_REDIRECT")!; + protected readonly string? _clientRedirect; /// /// Initializes a new instance of the class. @@ -56,7 +56,18 @@ 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) { + _clientId = clientId ?? Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_ID"); + _clientSecret = clientSecret ?? Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_SECRET"); + _clientRedirect = clientRedirect ?? Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_REDIRECT"); + OAuth = new TwitchAccessToken { AccessToken = token, RefreshToken = refreshToken, @@ -73,9 +84,9 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires) public TwitchAccessToken? OAuth { 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, _clientSecret, _clientRedirect); if (null == response) { return null; } @@ -89,10 +100,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(_clientSecret) || string.IsNullOrWhiteSpace(_clientId)) { + return null; + } + ITwitchAPI api = GetApi(); - RefreshResponse? response = await api.Auth.RefreshAuthTokenAsync(OAuth?.RefreshToken, ClientSecret, ClientId); + RefreshResponse? response = await api.Auth.RefreshAuthTokenAsync(OAuth?.RefreshToken, _clientSecret, _clientId); if (null == response) { return null; } @@ -111,11 +126,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 +149,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 +162,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 +175,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", _clientId); using HttpResponseMessage response = await client.SendAsync(request); response.EnsureSuccessStatusCode(); @@ -173,7 +193,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 +227,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 +251,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 +275,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 +302,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 +314,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 = _clientId, AccessToken = OAuth?.AccessToken } }; From e6de83ce41491bd598dd6b0cf62bf20ec9437371 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=E2=96=88=E2=96=88=E2=96=88=E2=96=88=E2=96=88?= Date: Wed, 19 Feb 2025 17:48:36 -0500 Subject: [PATCH 3/3] feat: adding endpoints for refreshing oauth tokens --- .../Twitch/ITwitchApiProxy.cs | 5 ++ .../Twitch/TwitchApiProxy.cs | 46 +++++++++---------- .../Twitch/TwitchAppConfig.cs | 21 +++++++++ .../Controllers/UserController.cs | 40 +++++++++++++++- src/Nullinside.Api/Program.cs | 2 +- 5 files changed, 86 insertions(+), 28 deletions(-) create mode 100644 src/Nullinside.Api.Common/Twitch/TwitchAppConfig.cs 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 15256f0..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. - /// - protected readonly string? _clientId; - - /// - /// The, private, twitch client secret. - /// - protected readonly string? _clientSecret; - - /// - /// The redirect url. - /// - protected readonly string? _clientRedirect; - /// /// 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") + }; } /// @@ -64,15 +54,17 @@ public TwitchApiProxy() { /// "TWITCH_BOT_CLIENT_REDIRECT" when null. public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires, string? clientId = null, string? clientSecret = null, string? clientRedirect = null) { - _clientId = clientId ?? Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_ID"); - _clientSecret = clientSecret ?? Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_SECRET"); - _clientRedirect = clientRedirect ?? Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_REDIRECT"); - 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") + }; } /// @@ -81,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 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; } @@ -102,12 +98,12 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires, /// public virtual async Task RefreshAccessToken(CancellationToken token = new()) { try { - if (string.IsNullOrWhiteSpace(_clientSecret) || string.IsNullOrWhiteSpace(_clientId)) { + 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; } @@ -175,7 +171,7 @@ public virtual async Task> GetUserModChanne 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(); @@ -317,7 +313,7 @@ public virtual async Task> GetChannelsLive(IEnumerable +/// 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 3ec749e..e112c58 100644 --- a/src/Nullinside.Api/Controllers/UserController.cs +++ b/src/Nullinside.Api/Controllers/UserController.cs @@ -144,10 +144,46 @@ public async Task TwitchStreamingToolsLogin([FromQuery] string c CancellationToken token = new()) { string? siteUrl = _configuration.GetValue("Api:SiteUrl"); if (null == await api.CreateAccessToken(code, token)) { - return Redirect($"{siteUrl}/user/login?error=3"); + return Redirect($"{siteUrl}/user/login/desktop?error=3"); } - return Redirect($"{siteUrl}/user/login?token={api.OAuth?.AccessToken}&refresh={api.OAuth?.RefreshToken}&expiresUtc={api.OAuth?.ExpiresUtc?.ToString()}&desktop=true"); + 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 + }); } /// 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();