Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/Nullinside.Api.Common/Twitch/ITwitchApiProxy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ public interface ITwitchApiProxy {
/// The Twitch access token. These are the credentials used for all requests.
/// </summary>
TwitchAccessToken? OAuth { get; set; }

/// <summary>
/// The Twitch app configuration. These are used for all requests.
/// </summary>
TwitchAppConfig? TwitchAppConfig { get; set; }

/// <summary>
/// Creates a new access token from a code using Twitch's OAuth workflow.
Expand Down
82 changes: 49 additions & 33 deletions src/Nullinside.Api.Common/Twitch/TwitchApiProxy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,25 +29,15 @@ public class TwitchApiProxy : ITwitchApiProxy {
/// </summary>
private static readonly ILog Log = LogManager.GetLogger(typeof(TwitchApiProxy));

/// <summary>
/// The, public, twitch client id.
/// </summary>
private static readonly string ClientId = Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_ID")!;

/// <summary>
/// The, private, twitch client secret.
/// </summary>
private static readonly string ClientSecret = Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_SECRET")!;

/// <summary>
/// The redirect url.
/// </summary>
private static readonly string ClientRedirect = Environment.GetEnvironmentVariable("TWITCH_BOT_CLIENT_REDIRECT")!;

/// <summary>
/// Initializes a new instance of the <see cref="TwitchApiProxy" /> class.
/// </summary>
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")
};
}

/// <summary>
Expand All @@ -56,12 +46,25 @@ public TwitchApiProxy() {
/// <param name="token">The access token.</param>
/// <param name="refreshToken">The refresh token.</param>
/// <param name="tokenExpires">When the token expires (utc).</param>
public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires) {
/// <param name="clientId">The client id of the registered twitch app, uses environment variable
/// "TWITCH_BOT_CLIENT_ID" when null.</param>
/// <param name="clientSecret">The client secret of the registered twitch app, uses environment variable
/// "TWITCH_BOT_CLIENT_SECRET" when null.</param>
/// <param name="clientRedirect">The url to redirect to from the registered twitch app, uses environment variable
/// "TWITCH_BOT_CLIENT_REDIRECT" when null.</param>
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")
};
}

/// <summary>
Expand All @@ -70,12 +73,16 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires)
public int Retries { get; set; } = 3;

/// <inheritdoc />
public TwitchAccessToken? OAuth { get; set; }
public virtual TwitchAccessToken? OAuth { get; set; }

/// <inheritdoc />
public virtual TwitchAppConfig? TwitchAppConfig { get; set; }

/// <inheritdoc />
public async Task<TwitchAccessToken?> CreateAccessToken(string code, CancellationToken token = new()) {
public virtual async Task<TwitchAccessToken?> 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;
}
Expand All @@ -89,10 +96,14 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires)
}

/// <inheritdoc />
public async Task<TwitchAccessToken?> RefreshAccessToken(CancellationToken token = new()) {
public virtual async Task<TwitchAccessToken?> 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;
}
Expand All @@ -111,11 +122,16 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires)

/// <inheritdoc />
public async Task<bool> GetAccessTokenIsValid(CancellationToken token = new()) {
return !string.IsNullOrWhiteSpace((await GetUser(token)).id);
try {
return !string.IsNullOrWhiteSpace((await GetUser(token)).id);
}
catch {
return false;
}
}

/// <inheritdoc />
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();
Expand All @@ -129,7 +145,7 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires)
}

/// <inheritdoc />
public async Task<string?> GetUserEmail(CancellationToken token = new()) {
public virtual async Task<string?> GetUserEmail(CancellationToken token = new()) {
return await Retry.Execute(async () => {
ITwitchAPI api = GetApi();
GetUsersResponse? response = await api.Helix.Users.GetUsersAsync();
Expand All @@ -142,7 +158,7 @@ public TwitchApiProxy(string token, string refreshToken, DateTime tokenExpires)
}

/// <inheritdoc />
public async Task<IEnumerable<TwitchModeratedChannel>> GetUserModChannels(string userId) {
public virtual async Task<IEnumerable<TwitchModeratedChannel>> GetUserModChannels(string userId) {
using var client = new HttpClient();

var ret = new List<TwitchModeratedChannel>();
Expand All @@ -155,7 +171,7 @@ public async Task<IEnumerable<TwitchModeratedChannel>> 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();
Expand All @@ -173,7 +189,7 @@ public async Task<IEnumerable<TwitchModeratedChannel>> GetUserModChannels(string
}

/// <inheritdoc />
public async Task<IEnumerable<BannedUser>> BanChannelUsers(string channelId, string botId,
public virtual async Task<IEnumerable<BannedUser>> BanChannelUsers(string channelId, string botId,
IEnumerable<(string Id, string Username)> users, string reason, CancellationToken token = new()) {
return await Retry.Execute(async () => {
ITwitchAPI api = GetApi();
Expand Down Expand Up @@ -207,7 +223,7 @@ public async Task<IEnumerable<BannedUser>> BanChannelUsers(string channelId, str
}

/// <inheritdoc />
public async Task<IEnumerable<Chatter>> GetChannelUsers(string channelId, string botId,
public virtual async Task<IEnumerable<Chatter>> GetChannelUsers(string channelId, string botId,
CancellationToken token = new()) {
return await Retry.Execute(async () => {
ITwitchAPI api = GetApi();
Expand All @@ -231,7 +247,7 @@ public async Task<IEnumerable<Chatter>> GetChannelUsers(string channelId, string
}

/// <inheritdoc />
public async Task<IEnumerable<string>> GetChannelsLive(IEnumerable<string> userIds) {
public virtual async Task<IEnumerable<string>> GetChannelsLive(IEnumerable<string> userIds) {
ITwitchAPI api = GetApi();

// We can only query 100 at a time, so throttle the search.
Expand All @@ -255,7 +271,7 @@ public async Task<IEnumerable<string>> GetChannelsLive(IEnumerable<string> userI
}

/// <inheritdoc />
public async Task<IEnumerable<Moderator>> GetChannelMods(string channelId, CancellationToken token = new()) {
public virtual async Task<IEnumerable<Moderator>> GetChannelMods(string channelId, CancellationToken token = new()) {
return await Retry.Execute(async () => {
ITwitchAPI api = GetApi();

Expand All @@ -282,7 +298,7 @@ public async Task<IEnumerable<string>> GetChannelsLive(IEnumerable<string> userI
}

/// <inheritdoc />
public async Task<bool> AddChannelMod(string channelId, string userId, CancellationToken token = new()) {
public virtual async Task<bool> AddChannelMod(string channelId, string userId, CancellationToken token = new()) {
return await Retry.Execute(async () => {
ITwitchAPI api = GetApi();
await api.Helix.Moderation.AddChannelModeratorAsync(channelId, userId);
Expand All @@ -294,10 +310,10 @@ public async Task<IEnumerable<string>> GetChannelsLive(IEnumerable<string> userI
/// Gets a new instance of the <see cref="TwitchAPI" />.
/// </summary>
/// <returns>A new instance of the <see cref="TwitchAPI" />.</returns>
protected ITwitchAPI GetApi() {
protected virtual ITwitchAPI GetApi() {
var api = new TwitchAPI {
Settings = {
ClientId = ClientId,
ClientId = TwitchAppConfig?.ClientId,
AccessToken = OAuth?.AccessToken
}
};
Expand Down
21 changes: 21 additions & 0 deletions src/Nullinside.Api.Common/Twitch/TwitchAppConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace Nullinside.Api.Common.Twitch;

/// <summary>
/// The configuration for a twitch app that provides OAuth tokens.
/// </summary>
public class TwitchAppConfig {
/// <summary>
/// The client id.
/// </summary>
public string? ClientId { get; set; }

/// <summary>
/// The client secret.
/// </summary>
public string? ClientSecret { get; set; }

/// <summary>
/// A registered URL that the Twitch API is allowed to redirect to on our website.
/// </summary>
public string? ClientRedirect { get; set; }
}
63 changes: 63 additions & 0 deletions src/Nullinside.Api/Controllers/UserController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,69 @@ public async Task<RedirectResult> TwitchLogin([FromQuery] string code, [FromServ

return Redirect($"{siteUrl}/user/login?token={bearerToken}");
}

/// <summary>
/// **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.
/// </summary>
/// <param name="code">The credentials provided by twitch.</param>
/// <param name="api">The twitch api.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>
/// A redirect to the nullinside website.
/// Errors:
/// 2 = Internal error generating token.
/// 3 = Code was invalid
/// 4 = Twitch account has no email
/// </returns>
[AllowAnonymous]
[HttpGet]
[Route("twitch-login/twitch-streaming-tools")]
public async Task<RedirectResult> TwitchStreamingToolsLogin([FromQuery] string code, [FromServices] ITwitchApiProxy api,
CancellationToken token = new()) {
string? siteUrl = _configuration.GetValue<string>("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()}");
}

/// <summary>
/// Used to refresh OAuth tokens from the desktop application.
/// </summary>
/// <param name="refreshToken">The oauth refresh token provided by twitch.</param>
/// <param name="api">The twitch api.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>
/// A redirect to the nullinside website.
/// Errors:
/// 2 = Internal error generating token.
/// 3 = Code was invalid
/// 4 = Twitch account has no email
/// </returns>
[AllowAnonymous]
[HttpPost]
[Route("twitch-login/twitch-streaming-tools")]
public async Task<IActionResult> TwitchStreamingToolsRefreshToken(string refreshToken, [FromServices] ITwitchApiProxy api,
CancellationToken token = new()) {
string? siteUrl = _configuration.GetValue<string>("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
});
}

/// <summary>
/// Gets the roles of the current user.
Expand Down
2 changes: 1 addition & 1 deletion src/Nullinside.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
builder.EnableRetryOnFailure(3);
}));
builder.Services.AddScoped<IAuthorizationHandler, BasicAuthorizationHandler>();
builder.Services.AddScoped<ITwitchApiProxy, TwitchApiProxy>();
builder.Services.AddTransient<ITwitchApiProxy, TwitchApiProxy>();
builder.Services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>("Bearer", _ => { });
builder.Services.AddScoped<IDockerProxy, DockerProxy>();
Expand Down