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