Skip to content

Commit dbaa396

Browse files
Merge pull request #112 from nullinside-development-group/feat/ws
feat: adding web socket pattern for desktop applications
2 parents 37309f8 + c86cf65 commit dbaa396

File tree

9 files changed

+157
-39
lines changed

9 files changed

+157
-39
lines changed

src/Nullinside.Api.Common.AspNetCore/Nullinside.Api.Common.AspNetCore.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
<PrivateAssets>all</PrivateAssets>
2323
</PackageReference>
2424
<PackageReference Include="Nullinside.MySql.EntityFrameworkCore" Version="9.0.3"/>
25-
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" />
26-
<PackageReference Include="System.Text.Json" Version="9.0.7" />
25+
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3"/>
26+
<PackageReference Include="System.Text.Json" Version="9.0.7"/>
2727
</ItemGroup>
2828

2929
<ItemGroup>

src/Nullinside.Api.Model/Nullinside.Api.Model.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@
1717
</PropertyGroup>
1818

1919
<ItemGroup>
20-
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.7" />
20+
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.7"/>
2121
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
2222
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
2323
<PrivateAssets>all</PrivateAssets>
2424
</PackageReference>
2525
<PackageReference Include="Nullinside.MySql.EntityFrameworkCore" Version="9.0.3"/>
26-
<PackageReference Include="System.Text.Json" Version="9.0.7" />
26+
<PackageReference Include="System.Text.Json" Version="9.0.7"/>
2727
</ItemGroup>
2828

2929
<ItemGroup>

src/Nullinside.Api.Tests/Nullinside.Api.Tests.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
</PropertyGroup>
1616

1717
<ItemGroup>
18-
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.7" />
19-
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.7" />
18+
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.7"/>
19+
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.7"/>
2020
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
2121
<PackageReference Include="Moq" Version="4.20.72"/>
2222
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>

src/Nullinside.Api.Tests/Nullinside.Api/Controllers/UserControllerTests.cs

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
using Nullinside.Api.Controllers;
1414
using Nullinside.Api.Model;
1515
using Nullinside.Api.Model.Ddl;
16+
using Nullinside.Api.Shared;
1617
using Nullinside.Api.Shared.Json;
1718

1819
namespace Nullinside.Api.Tests.Nullinside.Api.Controllers;
@@ -31,6 +32,11 @@ public class UserControllerTests : UnitTestBase {
3132
/// </summary>
3233
private Mock<ITwitchApiProxy> _twitchApi;
3334

35+
/// <summary>
36+
/// The web socket persister.
37+
/// </summary>
38+
private Mock<IWebSocketPersister> _webSocketPersister;
39+
3440
/// <inheritdoc />
3541
public override void Setup() {
3642
base.Setup();
@@ -45,6 +51,7 @@ public override void Setup() {
4551
.Build();
4652

4753
_twitchApi = new Mock<ITwitchApiProxy>();
54+
_webSocketPersister = new Mock<IWebSocketPersister>();
4855
}
4956

5057
/// <summary>
@@ -61,7 +68,7 @@ public async Task PerformGoogleLoginExisting() {
6168
await _db.SaveChangesAsync();
6269

6370
// Make the call and ensure it's successful.
64-
var controller = new TestableUserController(_configuration, _db);
71+
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
6572
controller.Email = "hi";
6673
RedirectResult obj = await controller.Login(new GoogleOpenIdToken { credential = "stuff" });
6774

@@ -81,7 +88,7 @@ public async Task PerformGoogleLoginExisting() {
8188
[Test]
8289
public async Task PerformGoogleLoginNewUser() {
8390
// Make the call and ensure it's successful.
84-
var controller = new TestableUserController(_configuration, _db);
91+
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
8592
controller.Email = "hi";
8693
RedirectResult obj = await controller.Login(new GoogleOpenIdToken { credential = "stuff" });
8794

@@ -103,7 +110,7 @@ public async Task GoToErrorOnDbException() {
103110
_db.Users = null!;
104111

105112
// Make the call and ensure it's successful.
106-
var controller = new TestableUserController(_configuration, _db);
113+
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
107114
controller.Email = "hi";
108115
RedirectResult obj = await controller.Login(new GoogleOpenIdToken { credential = "stuff" });
109116

@@ -117,7 +124,7 @@ public async Task GoToErrorOnDbException() {
117124
[Test]
118125
public async Task GoToErrorOnBadGmailResponse() {
119126
// Make the call and ensure it's successful.
120-
var controller = new TestableUserController(_configuration, _db);
127+
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
121128
controller.Email = null;
122129
RedirectResult obj = await controller.Login(new GoogleOpenIdToken { credential = "stuff" });
123130

@@ -147,7 +154,7 @@ public async Task PerformTwitchLoginExisting() {
147154
await _db.SaveChangesAsync();
148155

149156
// Make the call and ensure it's successful.
150-
var controller = new TestableUserController(_configuration, _db);
157+
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
151158
RedirectResult obj = await controller.TwitchLogin("things", _twitchApi.Object);
152159

153160
// We should have been redirected to the successful route.
@@ -174,7 +181,7 @@ public async Task PerformTwitchLoginNewUser() {
174181
.Returns(() => Task.FromResult<string?>("hi"));
175182

176183
// Make the call and ensure it's successful.
177-
var controller = new TestableUserController(_configuration, _db);
184+
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
178185
RedirectResult obj = await controller.TwitchLogin("things", _twitchApi.Object);
179186

180187
// We should have been redirected to the successful route.
@@ -197,7 +204,7 @@ public async Task PerformTwitchLoginBadTwitchResponse() {
197204
.Returns(() => Task.FromResult<TwitchAccessToken?>(null));
198205

199206
// Make the call and ensure it's successful.
200-
var controller = new TestableUserController(_configuration, _db);
207+
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
201208
RedirectResult obj = await controller.TwitchLogin("things", _twitchApi.Object);
202209

203210
// We should have gone down the bad route
@@ -214,7 +221,7 @@ public async Task PerformTwitchLoginWithNoEmailAccount() {
214221
.Returns(() => Task.FromResult<TwitchAccessToken?>(new TwitchAccessToken()));
215222

216223
// Make the call and ensure it's successful.
217-
var controller = new TestableUserController(_configuration, _db);
224+
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
218225
RedirectResult obj = await controller.TwitchLogin("things", _twitchApi.Object);
219226

220227
// 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() {
237244
.Returns(() => Task.FromResult<string?>("hi"));
238245

239246
// Make the call and ensure it's successful.
240-
var controller = new TestableUserController(_configuration, _db);
247+
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
241248
RedirectResult obj = await controller.TwitchLogin("things", _twitchApi.Object);
242249

243250
// We should have been redirected to the error route because of an exception in DB processing.
@@ -256,7 +263,7 @@ public void GetRoles() {
256263
var identity = new ClaimsIdentity(claims, "icecream");
257264

258265
// Make the call and ensure it's successful.
259-
var controller = new TestableUserController(_configuration, _db);
266+
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
260267
controller.ControllerContext = new ControllerContext();
261268
controller.ControllerContext.HttpContext = new DefaultHttpContext();
262269
controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(identity);
@@ -279,7 +286,7 @@ public async Task ValidateTokenExists() {
279286
await _db.SaveChangesAsync();
280287

281288
// Make the call and ensure it's successful.
282-
var controller = new TestableUserController(_configuration, _db);
289+
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
283290
IActionResult obj = await controller.Validate(new AuthToken("123"));
284291
Assert.That((obj as IStatusCodeActionResult)?.StatusCode, Is.EqualTo(200));
285292

@@ -293,7 +300,7 @@ public async Task ValidateTokenExists() {
293300
[Test]
294301
public async Task ValidateFailWithoutToken() {
295302
// Make the call and ensure it fails.
296-
var controller = new TestableUserController(_configuration, _db);
303+
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
297304
IActionResult obj = await controller.Validate(new AuthToken("123"));
298305
Assert.That((obj as IStatusCodeActionResult)?.StatusCode, Is.EqualTo(401));
299306
}
@@ -306,7 +313,7 @@ public async Task ValidateFailOnDbFailure() {
306313
_db.Users = null!;
307314

308315
// Make the call and ensure it fails.
309-
var controller = new TestableUserController(_configuration, _db);
316+
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
310317
IActionResult obj = await controller.Validate(new AuthToken("123"));
311318
Assert.That((obj as IStatusCodeActionResult)?.StatusCode, Is.EqualTo(500));
312319
}
@@ -317,7 +324,7 @@ public async Task ValidateFailOnDbFailure() {
317324
/// </summary>
318325
public class TestableUserController : UserController {
319326
/// <inheritdoc />
320-
public TestableUserController(IConfiguration configuration, INullinsideContext dbContext) : base(configuration, dbContext) {
327+
public TestableUserController(IConfiguration configuration, INullinsideContext dbContext, IWebSocketPersister webSocketPersister) : base(configuration, dbContext, webSocketPersister) {
321328
}
322329

323330
/// <summary>

src/Nullinside.Api/Controllers/UserController.cs

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
using System.Net.WebSockets;
12
using System.Security.Claims;
3+
using System.Text;
24

35
using Google.Apis.Auth;
46

@@ -8,10 +10,14 @@
810
using Microsoft.AspNetCore.Mvc;
911
using Microsoft.EntityFrameworkCore;
1012

13+
using Newtonsoft.Json;
14+
15+
using Nullinside.Api.Common.Extensions;
1116
using Nullinside.Api.Common.Twitch;
1217
using Nullinside.Api.Model;
1318
using Nullinside.Api.Model.Ddl;
1419
using Nullinside.Api.Model.Shared;
20+
using Nullinside.Api.Shared;
1521
using Nullinside.Api.Shared.Json;
1622

1723
namespace Nullinside.Api.Controllers;
@@ -37,14 +43,21 @@ public class UserController : ControllerBase {
3743
/// </summary>
3844
private readonly ILog _logger = LogManager.GetLogger(typeof(UserController));
3945

46+
/// <summary>
47+
/// A collection of web sockets key'd by an id representing the request for the information.
48+
/// </summary>
49+
private readonly IWebSocketPersister _webSockets;
50+
4051
/// <summary>
4152
/// Initializes a new instance of the <see cref="UserController" /> class.
4253
/// </summary>
4354
/// <param name="configuration">The application's configuration file.</param>
4455
/// <param name="dbContext">The nullinside database.</param>
45-
public UserController(IConfiguration configuration, INullinsideContext dbContext) {
56+
/// <param name="webSocketPersister">The web socket persistence service.</param>
57+
public UserController(IConfiguration configuration, INullinsideContext dbContext, IWebSocketPersister webSocketPersister) {
4658
_configuration = configuration;
4759
_dbContext = dbContext;
60+
_webSockets = webSocketPersister;
4861
}
4962

5063
/// <summary>
@@ -128,6 +141,7 @@ public async Task<RedirectResult> TwitchLogin([FromQuery] string code, [FromServ
128141
/// redirects users back to the nullinside website.
129142
/// </summary>
130143
/// <param name="code">The credentials provided by twitch.</param>
144+
/// <param name="state">An identifier for the request allowing for retrieval of the login information.</param>
131145
/// <param name="api">The twitch api.</param>
132146
/// <param name="token">The cancellation token.</param>
133147
/// <returns>
@@ -140,14 +154,73 @@ public async Task<RedirectResult> TwitchLogin([FromQuery] string code, [FromServ
140154
[AllowAnonymous]
141155
[HttpGet]
142156
[Route("twitch-login/twitch-streaming-tools")]
143-
public async Task<RedirectResult> TwitchStreamingToolsLogin([FromQuery] string code, [FromServices] ITwitchApiProxy api,
157+
public async Task<RedirectResult> TwitchStreamingToolsLogin([FromQuery] string code, [FromQuery] string state, [FromServices] ITwitchApiProxy api,
144158
CancellationToken token = new()) {
159+
// The first thing we need to do is make sure someone subscribed to a web socket waiting for the answer to the
160+
// credentials question we're being asked.
145161
string? siteUrl = _configuration.GetValue<string>("Api:SiteUrl");
162+
if (!_webSockets.WebSockets.ContainsKey(state)) {
163+
return Redirect($"{siteUrl}/user/login/desktop?error=2");
164+
}
165+
166+
// Since someone already warned us this request was coming, create an oauth token from the code we received.
146167
if (null == await api.CreateAccessToken(code, token)) {
147168
return Redirect($"{siteUrl}/user/login/desktop?error=3");
148169
}
149170

150-
return Redirect($"{siteUrl}/user/login/desktop?bearer={api.OAuth?.AccessToken}&refresh={api.OAuth?.RefreshToken}&expiresUtc={api.OAuth?.ExpiresUtc?.ToString()}");
171+
// The "someone" that warned us this request was coming has been sitting around waiting for an answer on a web
172+
// socket so we will pull up that socket and give them their oauth information.
173+
try {
174+
WebSocket socket = _webSockets.WebSockets[state];
175+
var oAuth = new TwitchAccessToken {
176+
AccessToken = api.OAuth?.AccessToken ?? string.Empty,
177+
RefreshToken = api.OAuth?.RefreshToken ?? string.Empty,
178+
ExpiresUtc = api.OAuth?.ExpiresUtc ?? DateTime.MinValue
179+
};
180+
181+
await socket.SendTextAsync(JsonConvert.SerializeObject(oAuth), token);
182+
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Completed Successfully!", token);
183+
_webSockets.WebSockets.TryRemove(state, out _);
184+
socket.Dispose();
185+
}
186+
catch {
187+
return Redirect($"{siteUrl}/user/login/desktop?error=2");
188+
}
189+
190+
return Redirect($"{siteUrl}/user/login/desktop");
191+
}
192+
193+
/// <summary>
194+
/// A websocket used by clients to wait for their login token after twitch authenticates.
195+
/// </summary>
196+
/// <param name="token">The cancellation token.</param>
197+
[AllowAnonymous]
198+
[HttpGet]
199+
[Route("twitch-login/twitch-streaming-tools/ws")]
200+
public async Task TwitchStreamingToolsRefreshToken(CancellationToken token = new()) {
201+
if (HttpContext.WebSockets.IsWebSocketRequest) {
202+
// Connect with the client
203+
using WebSocket webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
204+
205+
// The first communication over the web socket is always the id that we will later get from the
206+
// twitch api with the associated credentials.
207+
string id = await webSocket.ReceiveTextAsync(token);
208+
id = id.Trim();
209+
210+
// Add the web socket to web socket persistant service. It will be sitting there until the twitch api calls our
211+
// api later on.
212+
_webSockets.WebSockets.TryAdd(id, webSocket);
213+
214+
// Regardless of whether you have a using statement above, the minute we leave the controller method we will
215+
// lose the connection. That's just the way web sockets are implemented in .NET Core Web APIs. So we have to sit
216+
// here in an await (specifically in an await so we don't mess up the thread pool) until twitch calls us.
217+
while (null == webSocket.CloseStatus) {
218+
await Task.Delay(1000, token);
219+
}
220+
}
221+
else {
222+
HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
223+
}
151224
}
152225

153226
/// <summary>
@@ -156,19 +229,11 @@ public async Task<RedirectResult> TwitchStreamingToolsLogin([FromQuery] string c
156229
/// <param name="refreshToken">The oauth refresh token provided by twitch.</param>
157230
/// <param name="api">The twitch api.</param>
158231
/// <param name="token">The cancellation token.</param>
159-
/// <returns>
160-
/// A redirect to the nullinside website.
161-
/// Errors:
162-
/// 2 = Internal error generating token.
163-
/// 3 = Code was invalid
164-
/// 4 = Twitch account has no email
165-
/// </returns>
166232
[AllowAnonymous]
167233
[HttpPost]
168234
[Route("twitch-login/twitch-streaming-tools")]
169235
public async Task<IActionResult> TwitchStreamingToolsRefreshToken([FromForm] string refreshToken, [FromServices] ITwitchApiProxy api,
170236
CancellationToken token = new()) {
171-
string? siteUrl = _configuration.GetValue<string>("Api:SiteUrl");
172237
api.OAuth = new TwitchAccessToken {
173238
AccessToken = null,
174239
RefreshToken = refreshToken,
@@ -179,10 +244,10 @@ public async Task<IActionResult> TwitchStreamingToolsRefreshToken([FromForm] str
179244
return BadRequest();
180245
}
181246

182-
return Ok(new {
183-
bearer = api.OAuth.AccessToken,
184-
refresh = api.OAuth.RefreshToken,
185-
expiresUtc = api.OAuth.ExpiresUtc
247+
return Ok(new TwitchAccessToken {
248+
AccessToken = api.OAuth.AccessToken ?? string.Empty,
249+
RefreshToken = api.OAuth.RefreshToken ?? string.Empty,
250+
ExpiresUtc = api.OAuth.ExpiresUtc ?? DateTime.MinValue
186251
});
187252
}
188253

src/Nullinside.Api/Nullinside.Api.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333
<PackageReference Include="Nullinside.MySql.EntityFrameworkCore" Version="9.0.3"/>
3434
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
3535
<PackageReference Include="SSH.NET" Version="2025.0.0"/>
36-
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" />
37-
<PackageReference Include="System.Text.Json" Version="9.0.7" />
36+
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3"/>
37+
<PackageReference Include="System.Text.Json" Version="9.0.7"/>
3838
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1"/>
3939
<PackageReference Include="TwitchLib.Api" Version="3.9.0"/>
4040
</ItemGroup>

0 commit comments

Comments
 (0)