diff --git a/src/Nullinside.Api.Common.AspNetCore/Nullinside.Api.Common.AspNetCore.csproj b/src/Nullinside.Api.Common.AspNetCore/Nullinside.Api.Common.AspNetCore.csproj index 098b92e..5bd5e2c 100644 --- a/src/Nullinside.Api.Common.AspNetCore/Nullinside.Api.Common.AspNetCore.csproj +++ b/src/Nullinside.Api.Common.AspNetCore/Nullinside.Api.Common.AspNetCore.csproj @@ -22,8 +22,8 @@ all - - + + diff --git a/src/Nullinside.Api.Model/Nullinside.Api.Model.csproj b/src/Nullinside.Api.Model/Nullinside.Api.Model.csproj index 6c6f632..b343277 100644 --- a/src/Nullinside.Api.Model/Nullinside.Api.Model.csproj +++ b/src/Nullinside.Api.Model/Nullinside.Api.Model.csproj @@ -17,13 +17,13 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/src/Nullinside.Api.Tests/Nullinside.Api.Tests.csproj b/src/Nullinside.Api.Tests/Nullinside.Api.Tests.csproj index fd633c8..f219033 100644 --- a/src/Nullinside.Api.Tests/Nullinside.Api.Tests.csproj +++ b/src/Nullinside.Api.Tests/Nullinside.Api.Tests.csproj @@ -15,8 +15,8 @@ - - + + diff --git a/src/Nullinside.Api.Tests/Nullinside.Api/Controllers/UserControllerTests.cs b/src/Nullinside.Api.Tests/Nullinside.Api/Controllers/UserControllerTests.cs index 04a4ea8..3b810de 100644 --- a/src/Nullinside.Api.Tests/Nullinside.Api/Controllers/UserControllerTests.cs +++ b/src/Nullinside.Api.Tests/Nullinside.Api/Controllers/UserControllerTests.cs @@ -13,6 +13,7 @@ using Nullinside.Api.Controllers; using Nullinside.Api.Model; using Nullinside.Api.Model.Ddl; +using Nullinside.Api.Shared; using Nullinside.Api.Shared.Json; namespace Nullinside.Api.Tests.Nullinside.Api.Controllers; @@ -31,6 +32,11 @@ public class UserControllerTests : UnitTestBase { /// private Mock _twitchApi; + /// + /// The web socket persister. + /// + private Mock _webSocketPersister; + /// public override void Setup() { base.Setup(); @@ -45,6 +51,7 @@ public override void Setup() { .Build(); _twitchApi = new Mock(); + _webSocketPersister = new Mock(); } /// @@ -61,7 +68,7 @@ public async Task PerformGoogleLoginExisting() { await _db.SaveChangesAsync(); // Make the call and ensure it's successful. - var controller = new TestableUserController(_configuration, _db); + var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object); controller.Email = "hi"; RedirectResult obj = await controller.Login(new GoogleOpenIdToken { credential = "stuff" }); @@ -81,7 +88,7 @@ public async Task PerformGoogleLoginExisting() { [Test] public async Task PerformGoogleLoginNewUser() { // Make the call and ensure it's successful. - var controller = new TestableUserController(_configuration, _db); + var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object); controller.Email = "hi"; RedirectResult obj = await controller.Login(new GoogleOpenIdToken { credential = "stuff" }); @@ -103,7 +110,7 @@ public async Task GoToErrorOnDbException() { _db.Users = null!; // Make the call and ensure it's successful. - var controller = new TestableUserController(_configuration, _db); + var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object); controller.Email = "hi"; RedirectResult obj = await controller.Login(new GoogleOpenIdToken { credential = "stuff" }); @@ -117,7 +124,7 @@ public async Task GoToErrorOnDbException() { [Test] public async Task GoToErrorOnBadGmailResponse() { // Make the call and ensure it's successful. - var controller = new TestableUserController(_configuration, _db); + var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object); controller.Email = null; RedirectResult obj = await controller.Login(new GoogleOpenIdToken { credential = "stuff" }); @@ -147,7 +154,7 @@ public async Task PerformTwitchLoginExisting() { await _db.SaveChangesAsync(); // Make the call and ensure it's successful. - var controller = new TestableUserController(_configuration, _db); + var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object); RedirectResult obj = await controller.TwitchLogin("things", _twitchApi.Object); // We should have been redirected to the successful route. @@ -174,7 +181,7 @@ public async Task PerformTwitchLoginNewUser() { .Returns(() => Task.FromResult("hi")); // Make the call and ensure it's successful. - var controller = new TestableUserController(_configuration, _db); + var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object); RedirectResult obj = await controller.TwitchLogin("things", _twitchApi.Object); // We should have been redirected to the successful route. @@ -197,7 +204,7 @@ public async Task PerformTwitchLoginBadTwitchResponse() { .Returns(() => Task.FromResult(null)); // Make the call and ensure it's successful. - var controller = new TestableUserController(_configuration, _db); + var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object); RedirectResult obj = await controller.TwitchLogin("things", _twitchApi.Object); // We should have gone down the bad route @@ -214,7 +221,7 @@ public async Task PerformTwitchLoginWithNoEmailAccount() { .Returns(() => Task.FromResult(new TwitchAccessToken())); // Make the call and ensure it's successful. - var controller = new TestableUserController(_configuration, _db); + var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object); RedirectResult obj = await controller.TwitchLogin("things", _twitchApi.Object); // We should have gone down the bad route because no email was associated with the twitch account. @@ -237,7 +244,7 @@ public async Task PerformTwitchLoginDbFailure() { .Returns(() => Task.FromResult("hi")); // Make the call and ensure it's successful. - var controller = new TestableUserController(_configuration, _db); + var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object); RedirectResult obj = await controller.TwitchLogin("things", _twitchApi.Object); // We should have been redirected to the error route because of an exception in DB processing. @@ -256,7 +263,7 @@ public void GetRoles() { var identity = new ClaimsIdentity(claims, "icecream"); // Make the call and ensure it's successful. - var controller = new TestableUserController(_configuration, _db); + var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object); controller.ControllerContext = new ControllerContext(); controller.ControllerContext.HttpContext = new DefaultHttpContext(); controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(identity); @@ -279,7 +286,7 @@ public async Task ValidateTokenExists() { await _db.SaveChangesAsync(); // Make the call and ensure it's successful. - var controller = new TestableUserController(_configuration, _db); + var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object); IActionResult obj = await controller.Validate(new AuthToken("123")); Assert.That((obj as IStatusCodeActionResult)?.StatusCode, Is.EqualTo(200)); @@ -293,7 +300,7 @@ public async Task ValidateTokenExists() { [Test] public async Task ValidateFailWithoutToken() { // Make the call and ensure it fails. - var controller = new TestableUserController(_configuration, _db); + var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object); IActionResult obj = await controller.Validate(new AuthToken("123")); Assert.That((obj as IStatusCodeActionResult)?.StatusCode, Is.EqualTo(401)); } @@ -306,7 +313,7 @@ public async Task ValidateFailOnDbFailure() { _db.Users = null!; // Make the call and ensure it fails. - var controller = new TestableUserController(_configuration, _db); + var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object); IActionResult obj = await controller.Validate(new AuthToken("123")); Assert.That((obj as IStatusCodeActionResult)?.StatusCode, Is.EqualTo(500)); } @@ -317,7 +324,7 @@ public async Task ValidateFailOnDbFailure() { /// public class TestableUserController : UserController { /// - public TestableUserController(IConfiguration configuration, INullinsideContext dbContext) : base(configuration, dbContext) { + public TestableUserController(IConfiguration configuration, INullinsideContext dbContext, IWebSocketPersister webSocketPersister) : base(configuration, dbContext, webSocketPersister) { } /// diff --git a/src/Nullinside.Api/Controllers/UserController.cs b/src/Nullinside.Api/Controllers/UserController.cs index df547b3..b969b7e 100644 --- a/src/Nullinside.Api/Controllers/UserController.cs +++ b/src/Nullinside.Api/Controllers/UserController.cs @@ -1,4 +1,6 @@ +using System.Net.WebSockets; using System.Security.Claims; +using System.Text; using Google.Apis.Auth; @@ -8,10 +10,14 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; + +using Nullinside.Api.Common.Extensions; using Nullinside.Api.Common.Twitch; using Nullinside.Api.Model; using Nullinside.Api.Model.Ddl; using Nullinside.Api.Model.Shared; +using Nullinside.Api.Shared; using Nullinside.Api.Shared.Json; namespace Nullinside.Api.Controllers; @@ -37,14 +43,21 @@ public class UserController : ControllerBase { /// private readonly ILog _logger = LogManager.GetLogger(typeof(UserController)); + /// + /// A collection of web sockets key'd by an id representing the request for the information. + /// + private readonly IWebSocketPersister _webSockets; + /// /// Initializes a new instance of the class. /// /// The application's configuration file. /// The nullinside database. - public UserController(IConfiguration configuration, INullinsideContext dbContext) { + /// The web socket persistence service. + public UserController(IConfiguration configuration, INullinsideContext dbContext, IWebSocketPersister webSocketPersister) { _configuration = configuration; _dbContext = dbContext; + _webSockets = webSocketPersister; } /// @@ -128,6 +141,7 @@ public async Task TwitchLogin([FromQuery] string code, [FromServ /// redirects users back to the nullinside website. /// /// The credentials provided by twitch. + /// An identifier for the request allowing for retrieval of the login information. /// The twitch api. /// The cancellation token. /// @@ -140,14 +154,73 @@ public async Task TwitchLogin([FromQuery] string code, [FromServ [AllowAnonymous] [HttpGet] [Route("twitch-login/twitch-streaming-tools")] - public async Task TwitchStreamingToolsLogin([FromQuery] string code, [FromServices] ITwitchApiProxy api, + public async Task TwitchStreamingToolsLogin([FromQuery] string code, [FromQuery] string state, [FromServices] ITwitchApiProxy api, CancellationToken token = new()) { + // The first thing we need to do is make sure someone subscribed to a web socket waiting for the answer to the + // credentials question we're being asked. string? siteUrl = _configuration.GetValue("Api:SiteUrl"); + if (!_webSockets.WebSockets.ContainsKey(state)) { + return Redirect($"{siteUrl}/user/login/desktop?error=2"); + } + + // Since someone already warned us this request was coming, create an oauth token from the code we received. 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()}"); + // The "someone" that warned us this request was coming has been sitting around waiting for an answer on a web + // socket so we will pull up that socket and give them their oauth information. + try { + WebSocket socket = _webSockets.WebSockets[state]; + var oAuth = new TwitchAccessToken { + AccessToken = api.OAuth?.AccessToken ?? string.Empty, + RefreshToken = api.OAuth?.RefreshToken ?? string.Empty, + ExpiresUtc = api.OAuth?.ExpiresUtc ?? DateTime.MinValue + }; + + await socket.SendTextAsync(JsonConvert.SerializeObject(oAuth), token); + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Completed Successfully!", token); + _webSockets.WebSockets.TryRemove(state, out _); + socket.Dispose(); + } + catch { + return Redirect($"{siteUrl}/user/login/desktop?error=2"); + } + + return Redirect($"{siteUrl}/user/login/desktop"); + } + + /// + /// A websocket used by clients to wait for their login token after twitch authenticates. + /// + /// The cancellation token. + [AllowAnonymous] + [HttpGet] + [Route("twitch-login/twitch-streaming-tools/ws")] + public async Task TwitchStreamingToolsRefreshToken(CancellationToken token = new()) { + if (HttpContext.WebSockets.IsWebSocketRequest) { + // Connect with the client + using WebSocket webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync(); + + // The first communication over the web socket is always the id that we will later get from the + // twitch api with the associated credentials. + string id = await webSocket.ReceiveTextAsync(token); + id = id.Trim(); + + // Add the web socket to web socket persistant service. It will be sitting there until the twitch api calls our + // api later on. + _webSockets.WebSockets.TryAdd(id, webSocket); + + // Regardless of whether you have a using statement above, the minute we leave the controller method we will + // lose the connection. That's just the way web sockets are implemented in .NET Core Web APIs. So we have to sit + // here in an await (specifically in an await so we don't mess up the thread pool) until twitch calls us. + while (null == webSocket.CloseStatus) { + await Task.Delay(1000, token); + } + } + else { + HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + } } /// @@ -156,19 +229,11 @@ public async Task TwitchStreamingToolsLogin([FromQuery] string c /// 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([FromForm] string refreshToken, [FromServices] ITwitchApiProxy api, CancellationToken token = new()) { - string? siteUrl = _configuration.GetValue("Api:SiteUrl"); api.OAuth = new TwitchAccessToken { AccessToken = null, RefreshToken = refreshToken, @@ -179,10 +244,10 @@ public async Task TwitchStreamingToolsRefreshToken([FromForm] str return BadRequest(); } - return Ok(new { - bearer = api.OAuth.AccessToken, - refresh = api.OAuth.RefreshToken, - expiresUtc = api.OAuth.ExpiresUtc + return Ok(new TwitchAccessToken { + AccessToken = api.OAuth.AccessToken ?? string.Empty, + RefreshToken = api.OAuth.RefreshToken ?? string.Empty, + ExpiresUtc = api.OAuth.ExpiresUtc ?? DateTime.MinValue }); } diff --git a/src/Nullinside.Api/Nullinside.Api.csproj b/src/Nullinside.Api/Nullinside.Api.csproj index 9e43dd3..c7e3856 100644 --- a/src/Nullinside.Api/Nullinside.Api.csproj +++ b/src/Nullinside.Api/Nullinside.Api.csproj @@ -33,8 +33,8 @@ - - + + diff --git a/src/Nullinside.Api/Program.cs b/src/Nullinside.Api/Program.cs index cbcadcb..95c9f4d 100644 --- a/src/Nullinside.Api/Program.cs +++ b/src/Nullinside.Api/Program.cs @@ -10,10 +10,19 @@ using Nullinside.Api.Common.Docker; using Nullinside.Api.Common.Twitch; using Nullinside.Api.Model; +using Nullinside.Api.Shared; using WebApplicationBuilder = Microsoft.AspNetCore.Builder.WebApplicationBuilder; const string corsKey = "_customAllowedSpecificOrigins"; +string[] domains = [ + "https://www.nullinside.com", + "https://nullinside.com", +#if DEBUG + "http://localhost:4200", + "http://127.0.0.1:4200" +#endif +]; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.Logging.ClearProviders(); @@ -35,6 +44,7 @@ builder.Services.AddAuthentication() .AddScheme("Bearer", _ => { }); builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddAuthorization(options => { // Dynamically add all of the user roles that exist in the application. @@ -91,8 +101,7 @@ builder.Services.AddCors(options => { options.AddPolicy(corsKey, policyBuilder => { - policyBuilder.WithOrigins("https://www.nullinside.com", "https://nullinside.com", "http://localhost:4200", - "http://127.0.0.1:4200") + policyBuilder.WithOrigins(domains) .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials(); @@ -118,6 +127,16 @@ app.UseCors(corsKey); app.UseAuthorization(); +var webSocketOptions = new WebSocketOptions { + KeepAliveInterval = TimeSpan.FromMinutes(2) +}; + +foreach (string domain in domains) { + webSocketOptions.AllowedOrigins.Add(domain); +} + +app.UseWebSockets(webSocketOptions); + app.MapControllers(); app.Run(); \ No newline at end of file diff --git a/src/Nullinside.Api/Shared/IWebSocketPersister.cs b/src/Nullinside.Api/Shared/IWebSocketPersister.cs new file mode 100644 index 0000000..9046eb7 --- /dev/null +++ b/src/Nullinside.Api/Shared/IWebSocketPersister.cs @@ -0,0 +1,15 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; + +namespace Nullinside.Api.Shared; + +/// +/// A contract for a service that persists web sockets so they can be used for asynchronous communication over long +/// periods of time. +/// +public interface IWebSocketPersister { + /// + /// A collection of web sockets key'd by an identifier for the web socket connection. + /// + public ConcurrentDictionary WebSockets { get; set; } +} \ No newline at end of file diff --git a/src/Nullinside.Api/Shared/WebSocketPersister.cs b/src/Nullinside.Api/Shared/WebSocketPersister.cs new file mode 100644 index 0000000..92b6c46 --- /dev/null +++ b/src/Nullinside.Api/Shared/WebSocketPersister.cs @@ -0,0 +1,12 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; + +namespace Nullinside.Api.Shared; + +/// +/// Persists web sockets so they can be used for asynchronous communication over long periods of time. +/// +public class WebSocketPersister : IWebSocketPersister { + /// + public ConcurrentDictionary WebSockets { get; set; } = new(); +} \ No newline at end of file