diff --git a/CelesteNet.Server.FrontendModule/Content/frontend/main/index.js b/CelesteNet.Server.FrontendModule/Content/frontend/main/index.js
index 49dd21c5..c70d827f 100644
--- a/CelesteNet.Server.FrontendModule/Content/frontend/main/index.js
+++ b/CelesteNet.Server.FrontendModule/Content/frontend/main/index.js
@@ -96,7 +96,8 @@ function renderUser() {
Create a CelesteNet account to show your profile picture in-game and to let the server remember your last channel and command settings.
- Link your Discord account
+ Link your Discord account
+ Link your Twitch account
Linking your account is fully optional and requires telling your browser to store a "cookie." This cookie is only used to keep you logged in.
diff --git a/CelesteNet.Server.FrontendModule/Frontend.cs b/CelesteNet.Server.FrontendModule/Frontend.cs
index f016a131..264389b6 100644
--- a/CelesteNet.Server.FrontendModule/Frontend.cs
+++ b/CelesteNet.Server.FrontendModule/Frontend.cs
@@ -506,7 +506,7 @@ public NameValueCollection ParseQueryString(string url) {
NameValueCollection nvc = new();
int indexOfSplit = url.IndexOf('?');
- if (indexOfSplit == -1)
+ if (indexOfSplit == -1)
return nvc;
url = url.Substring(indexOfSplit + 1);
diff --git a/CelesteNet.Server.FrontendModule/FrontendSettings.cs b/CelesteNet.Server.FrontendModule/FrontendSettings.cs
index a9ba2c55..4898cfa0 100644
--- a/CelesteNet.Server.FrontendModule/FrontendSettings.cs
+++ b/CelesteNet.Server.FrontendModule/FrontendSettings.cs
@@ -24,12 +24,19 @@ public class FrontendSettings : CelesteNetServerModuleSettings {
// TODO: Separate Discord auth module!
[YamlIgnore]
- public string DiscordOAuthURL => $"https://discord.com/oauth2/authorize?client_id={DiscordOAuthClientID}&redirect_uri={Uri.EscapeDataString(DiscordOAuthRedirectURL)}&response_type=code&scope=identify";
+ public string DiscordOAuthURL => $"https://discord.com/oauth2/authorize?client_id={DiscordOAuthClientID}&redirect_uri={Uri.EscapeDataString(DiscordOAuthRedirectURL)}&response_type=code&scope=identify&state=discord";
[YamlIgnore]
- public string DiscordOAuthRedirectURL => $"{CanonicalAPIRoot}/discordauth";
+ public string DiscordOAuthRedirectURL => $"{CanonicalAPIRoot}/standardauth";
public string DiscordOAuthClientID { get; set; } = "";
public string DiscordOAuthClientSecret { get; set; } = "";
+ [YamlIgnore]
+ public string TwitchOAuthURL => $"https://id.twitch.tv/oauth2/authorize?response_type=code&client_id={TwitchOAuthClientID}&redirect_uri={Uri.EscapeDataString(TwitchOAuthRedirectURL)}&response_type=code&state=twitch";
+ [YamlIgnore]
+ public string TwitchOAuthRedirectURL => $"{CanonicalAPIRoot}/standardauth";
+ public string TwitchOAuthClientID { get; set; } = "";
+ public string TwitchOAuthClientSecret { get; set; } = "";
+
public HashSet ExecOnlySettings { get; set; } = new();
}
diff --git a/CelesteNet.Server.FrontendModule/RCEPs/RCEPPublic.cs b/CelesteNet.Server.FrontendModule/RCEPs/RCEPPublic.cs
index 7fc8d1d7..31fa2dbd 100644
--- a/CelesteNet.Server.FrontendModule/RCEPs/RCEPPublic.cs
+++ b/CelesteNet.Server.FrontendModule/RCEPs/RCEPPublic.cs
@@ -1,4 +1,5 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
using System.Collections.Specialized;
using System.IO;
using System.Linq;
@@ -17,16 +18,53 @@
namespace Celeste.Mod.CelesteNet.Server.Control {
public static partial class RCEndpoints {
- [RCEndpoint(false, "/discordauth", "", "", "Discord OAuth2", "User auth using Discord.")]
- public static void DiscordOAuth(Frontend f, HttpRequestEventArgs c) {
+ [RCEndpoint(false, "/standardauth", "", "", "OAuth2", "User auth for all platforms.")]
+ public static void StandardOAuth(Frontend f, HttpRequestEventArgs c) {
+ string[] providers = { "discord", "twitch" };
NameValueCollection args = f.ParseQueryString(c.Request.RawUrl);
+ Logger.Log(LogLevel.DBG, "frontend-standardauth", $"{c.Request.RawUrl}");
+ Logger.Log(LogLevel.DBG, "frontend-standardauth", $"{f.ParseQueryString(c.Request.RawUrl)}");
if (args.Count == 0) {
- // c.Response.Redirect(f.Settings.OAuthURL);
- c.Response.StatusCode = (int) HttpStatusCode.Redirect;
- c.Response.Headers.Set("Location", f.Settings.DiscordOAuthURL);
+ c.Response.StatusCode = (int) HttpStatusCode.Unauthorized;
f.RespondJSON(c, new {
- Info = $"Redirecting to {f.Settings.DiscordOAuthURL}"
+ Error = "Unauthorized - no OAuth provider stated."
+ });
+ return;
+ }
+
+ if (args.Count == 1) {
+ if (args["state"] == "discord") {
+ // c.Response.Redirect(f.Settings.OAuthURL);
+ c.Response.StatusCode = (int)HttpStatusCode.Redirect;
+ c.Response.Headers.Set("Location", f.Settings.DiscordOAuthURL);
+ f.RespondJSON(c, new
+ {
+ Info = $"Redirecting to {f.Settings.DiscordOAuthURL}"
+ });
+ return;
+ }
+
+ if (args["state"] == "twitch")
+ {
+ // c.Response.Redirect(f.Settings.OAuthURL);
+ c.Response.StatusCode = (int)HttpStatusCode.Redirect;
+ c.Response.Headers.Set("Location", f.Settings.TwitchOAuthURL);
+ f.RespondJSON(c, new
+ {
+ Info = $"Redirecting to {f.Settings.TwitchOAuthURL}"
+ });
+
+ return;
+ }
+ }
+
+ if (args["state"].IsNullOrEmpty() || (args["state"] != "discord" && args["state"] != "twitch"))
+ {
+ c.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
+ f.RespondJSON(c, new
+ {
+ Error = "Unauthorized - Invalid OAuth provider."
});
return;
}
@@ -53,106 +91,196 @@ public static void DiscordOAuth(Frontend f, HttpRequestEventArgs c) {
dynamic? tokenData;
dynamic? userData;
-
- using (HttpClient client = new()) {
+
+ string requestUri = args["state"] == "discord" ? "https://discord.com/api/oauth2/token" :
+ args["state"] == "twitch" ? "https://id.twitch.tv/oauth2/token" : "";
+ string clientId = args["state"] == "discord" ? f.Settings.DiscordOAuthClientID :
+ args["state"] == "twitch" ? f.Settings.TwitchOAuthClientID : "";
+ string clientSecret = args["state"] == "discord" ? f.Settings.DiscordOAuthClientSecret :
+ args["state"] == "twitch" ? f.Settings.TwitchOAuthClientSecret : "";
+ string redirectUri = args["state"] == "discord" ? f.Settings.DiscordOAuthRedirectURL :
+ args["state"] == "twitch" ? f.Settings.TwitchOAuthRedirectURL : "";
+ string scopes = args["state"] == "discord" ? "identity" : args["state"] == "twitch" ? "user:read:chat" : "";
+
+ using (HttpClient client = new())
+ {
#pragma warning disable CS8714 // new FormUrlEncodedContent expects nullable.
- using (Stream s = client.PostAsync("https://discord.com/api/oauth2/token", new FormUrlEncodedContent(new Dictionary() {
+ using (Stream s = client.PostAsync(requestUri,
+ new FormUrlEncodedContent(new Dictionary()
+ {
#pragma warning restore CS8714
- { "client_id", f.Settings.DiscordOAuthClientID },
- { "client_secret", f.Settings.DiscordOAuthClientSecret },
- { "grant_type", "authorization_code" },
- { "code", code },
- { "redirect_uri", f.Settings.DiscordOAuthRedirectURL },
- { "scope", "identity" }
- })).Await().Content.ReadAsStreamAsync().Await())
- using (StreamReader sr = new(s))
- using (JsonTextReader jtr = new(sr))
- tokenData = f.Serializer.Deserialize(jtr);
-
- if (tokenData?.access_token?.ToString() is not string token ||
- tokenData?.token_type?.ToString() is not string tokenType ||
- token.IsNullOrEmpty() ||
- tokenType.IsNullOrEmpty()) {
- Logger.Log(LogLevel.CRI, "frontend-discordauth", $"Failed to obtain token: {tokenData}");
- c.Response.StatusCode = (int) HttpStatusCode.InternalServerError;
- f.RespondJSON(c, new {
- Error = "Couldn't obtain access token from Discord."
+ { "client_id", clientId },
+ { "client_secret", clientSecret },
+ { "grant_type", "authorization_code" },
+ { "code", code },
+ { "redirect_uri", redirectUri },
+ { "scope", scopes }
+ })).Await().Content.ReadAsStreamAsync().Await())
+ using (StreamReader sr = new(s))
+ using (JsonTextReader jtr = new(sr))
+ tokenData = f.Serializer.Deserialize(jtr);
+
+ Logger.Log(LogLevel.DBG, "frontend-standardauth", $"Request URI: {requestUri}");
+
+ if (tokenData?.access_token?.ToString() is not string token ||
+ tokenData?.token_type?.ToString() is not string tokenType ||
+ token.IsNullOrEmpty() ||
+ tokenType.IsNullOrEmpty())
+ {
+ Logger.Log(LogLevel.CRI, "frontend-standardauth", $"Failed to obtain token: {tokenData}");
+ c.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
+ f.RespondJSON(c, new
+ {
+ Error = "Couldn't obtain access token."
+ });
+ return;
+ }
+
+ // Twitch hates it if "bearer" is lowercase
+ if (tokenType == "bearer" && args["state"] == "twitch")
+ tokenType = "Bearer";
+
+ requestUri = args["state"] == "discord" ? "https://discord.com/api/users/@me" :
+ args["state"] == "twitch" ? "https://api.twitch.tv/helix/users" : "";
+
+ var request = new HttpRequestMessage
+ {
+ RequestUri = new Uri(requestUri),
+ Method = HttpMethod.Get
+ };
+
+ if (args["state"] == "discord")
+ {
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue(tokenType, token);
+ } else if (args["state"] == "twitch")
+ {
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue(tokenType, token);
+ request.Headers.Add("Client-ID", f.Settings.TwitchOAuthClientID);
+ }
+
+ using (Stream s = client.SendAsync(request).Await().Content.ReadAsStreamAsync().Await())
+ using (StreamReader sr = new(s))
+ using (JsonTextReader jtr = new(sr))
+ userData = f.Serializer.Deserialize(jtr);
+ }
+
+ if (!string.IsNullOrEmpty(userData?.error))
+ {
+ Logger.Log(LogLevel.CRI, "frontend-standardauth", $"Status: {userData?.status}. Error: {userData?.error}");
+ c.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
+ f.RespondJSON(c, new
+ {
+ Error = $"The OAuth provider you have chosen returned an error with status code {userData?.status}. This means that it came back as {userData?.error}"
+ });
+ return;
+ }
+ try
+ {
+ userData = userData?.data[0];
+ } catch (Exception ex) {
+ Logger.Log(LogLevel.DEV, "frontend-standardauth", $"No \"data\" array in userData: {ex}");
+ }
+
+ if (!(userData?.id?.ToString() is string uid) ||
+ uid.IsNullOrEmpty())
+ {
+ Logger.Log(LogLevel.CRI, "frontend-standardauth", $"Failed to obtain ID: {userData}");
+ c.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
+ f.RespondJSON(c, new
+ {
+ Error = "Couldn't obtain user ID."
});
return;
}
+ string pfpId = uid;
+ uid = $"{uid}-{args["state"]}";
+ string key = f.Server.UserData.Create(uid, false);
+ BasicUserInfo info = f.Server.UserData.Load(uid);
- using (Stream s = client.SendAsync(new HttpRequestMessage {
- RequestUri = new("https://discord.com/api/users/@me"),
- Method = HttpMethod.Get,
- Headers = {
- { "Authorization", $"{tokenType} {token}" }
- }
- }).Await().Content.ReadAsStreamAsync().Await())
- using (StreamReader sr = new(s))
- using (JsonTextReader jtr = new(sr))
- userData = f.Serializer.Deserialize(jtr);
- }
-
- if (!(userData?.id?.ToString() is string uid) ||
- uid.IsNullOrEmpty()) {
- Logger.Log(LogLevel.CRI, "frontend-discordauth", $"Failed to obtain ID: {userData}");
- c.Response.StatusCode = (int) HttpStatusCode.InternalServerError;
- f.RespondJSON(c, new {
- Error = "Couldn't obtain user ID from Discord."
- });
- return;
- }
+ if ((userData.global_name?.ToString() ?? userData.display_name?.ToString() ?? userData.login?.ToString()) is string global_name && !global_name.IsNullOrEmpty())
+ {
+ info.Name = global_name;
+ }
+ else
+ {
+ info.Name = userData.username.ToString();
+ }
- string key = f.Server.UserData.Create(uid, false);
- BasicUserInfo info = f.Server.UserData.Load(uid);
+ if (info.Name.Length > 32)
+ {
+ info.Name = info.Name.Substring(0, 32);
+ }
- if (userData.global_name?.ToString() is string global_name && !global_name.IsNullOrEmpty()) {
- info.Name = global_name;
- } else {
- info.Name = userData.username.ToString();
- }
- if (info.Name.Length > 32) {
- info.Name = info.Name.Substring(0, 32);
- }
- info.Discrim = userData.discriminator.ToString();
- f.Server.UserData.Save(uid, info);
-
- Image avatarOrig;
- using (HttpClient client = new()) {
- try {
- using Stream s = client.GetAsync(
- $"https://cdn.discordapp.com/avatars/{uid}/{userData.avatar.ToString()}.png?size=64"
- ).Await().Content.ReadAsStreamAsync().Await();
- avatarOrig = Image.Load(s);
- } catch {
- using Stream s = client.GetAsync(
- $"https://cdn.discordapp.com/embed/avatars/{((int) userData.discriminator) % 6}.png"
- ).Await().Content.ReadAsStreamAsync().Await();
- avatarOrig = Image.Load(s);
+ // Since ALL discord accounts do not have discriminators, we can seperate DISCORD and TWITCH accounts with the discrim value
+ info.Discrim = args["state"]?.ToUpper() ?? "FALLBACK";
+ f.Server.UserData.Save(uid, info);
+
+ Image avatarOrig;
+ using (HttpClient client = new())
+ {
+ if (args["state"] == "discord") {
+ try
+ {
+ using Stream s = client.GetAsync(
+ $"https://cdn.discordapp.com/avatars/{pfpId}/{userData.avatar.ToString()}.png?size=64"
+ ).Await().Content.ReadAsStreamAsync().Await();
+ avatarOrig = Image.Load(s);
+ }
+ catch
+ {
+ using Stream s = client.GetAsync(
+ $"https://cdn.discordapp.com/embed/avatars/{((int)userData.discriminator) % 6}.png"
+ ).Await().Content.ReadAsStreamAsync().Await();
+ avatarOrig = Image.Load(s);
+ }
+ } else if (args["state"] == "twitch")
+ {
+ try
+ {
+ using Stream s = client.GetAsync(
+ $"{userData?.profile_image_url}"
+ ).Await().Content.ReadAsStreamAsync().Await();
+ avatarOrig = Image.Load(s);
+ }
+ catch
+ {
+ using Stream s = client.GetAsync(
+ $"https://static-cdn.jtvnw.net/user-default-pictures-uv/13e5fa74-defa-11e9-809c-784f43822e80-profile_image-300x300.png"
+ ).Await().Content.ReadAsStreamAsync().Await();
+ avatarOrig = Image.Load(s);
+ }
+ }
+ else
+ {
+ using Stream s = client.GetAsync(
+ $"https://static-cdn.jtvnw.net/user-default-pictures-uv/13e5fa74-defa-11e9-809c-784f43822e80-profile_image-300x300.png"
+ ).Await().Content.ReadAsStreamAsync().Await();
+ avatarOrig = Image.Load(s);
+ }
}
- }
- using (avatarOrig)
- using (Image avatarScale = avatarOrig.Clone(x => x.Resize(64, 64, sampler: KnownResamplers.Lanczos3)))
- using (Image avatarFinal = avatarScale.Clone(x => x.ApplyRoundedCorners().ApplyTagOverlays(f, info))) {
+ using (avatarOrig)
+ using (Image avatarScale = avatarOrig.Clone(x => x.Resize(64, 64, sampler: KnownResamplers.Lanczos3)))
+ using (Image avatarFinal = avatarScale.Clone(x => x.ApplyRoundedCorners().ApplyTagOverlays(f, info)))
+ {
- using (Stream s = f.Server.UserData.WriteFile(uid, "avatar.orig.png"))
- avatarScale.SaveAsPng(s, new PngEncoder() { ColorType = PngColorType.RgbWithAlpha });
+ using (Stream s = f.Server.UserData.WriteFile(uid, "avatar.orig.png"))
+ avatarScale.SaveAsPng(s, new PngEncoder() { ColorType = PngColorType.RgbWithAlpha });
- using (Stream s = f.Server.UserData.WriteFile(uid, "avatar.png"))
- avatarFinal.SaveAsPng(s, new PngEncoder() { ColorType = PngColorType.RgbWithAlpha });
- }
+ using (Stream s = f.Server.UserData.WriteFile(uid, "avatar.png"))
+ avatarFinal.SaveAsPng(s, new PngEncoder() { ColorType = PngColorType.RgbWithAlpha });
+ }
- c.Response.StatusCode = (int) HttpStatusCode.Redirect;
- c.Response.Headers.Set("Location", $"http://{c.Request.UserHostName}/");
- f.SetKeyCookie(c, key);
- f.SetDiscordAuthCookie(c, code);
- f.RespondJSON(c, new {
- Info = "Success - redirecting to /"
- });
+ c.Response.StatusCode = (int)HttpStatusCode.Redirect;
+ c.Response.Headers.Set("Location", $"http://{c.Request.UserHostName}/");
+ f.SetKeyCookie(c, key);
+ f.SetDiscordAuthCookie(c, code);
+ f.RespondJSON(c, new
+ {
+ Info = "Success - redirecting to /"
+ });
}
-
private static IImageProcessingContext ApplyTagOverlays(this IImageProcessingContext context, Frontend f, BasicUserInfo info) {
foreach (string tagName in info.Tags) {
using Stream? asset = f.OpenContent($"frontend/assets/tags/{tagName}.png", out _, out _, out _);