Add Discord OAuth2 login and refactor authentication

Implemented Discord OAuth2 authentication flow with a new Discord.cs handler and integrated it into Program.cs. Refactored authentication logic to use shared JsonSerializerOptions from LauncherConstants. Updated BashUtils with cross-platform URL opening, improved MojangAPI to use shared HttpClient, and enhanced frontend login and log handling in logged.js and login.js.
This commit is contained in:
Gilles Lazures 2026-01-24 23:35:02 +01:00
parent 4ffe3b47c2
commit d2ae3122c9
7 changed files with 152 additions and 40 deletions

View File

@ -1,4 +1,6 @@
using Lentia.Utils;
using Lentia.Core.Auth.OAuth2;
using Lentia.Core.Constants;
using Lentia.Utils;
using Photino.NET;
using System.Text.Json;
using LentRules = Lentia.Core.Auth.Yggdrasil;
@ -9,7 +11,6 @@ class Program {
private static LentRules.AuthenticateResponse? _authenticatedPlayer;
private static readonly string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
private static readonly LentRules.Authenticator _ygg = new();
private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
[STAThread]
static void Main(string[] args) {
@ -64,13 +65,15 @@ class Program {
case "auth::lentia":
string user = jsonPayload.GetProperty("username").GetString()!;
string pass = jsonPayload.GetProperty("password").GetString()!;
var result = await _ygg.Login(user, pass);
var lentiaAuthResult = await _ygg.Login(user, pass);
if (result.Success) {
_authenticatedPlayer = result.Player;
if (lentiaAuthResult.Success) {
_authenticatedPlayer = lentiaAuthResult.Player;
WindowHelper.MakeStandardWindow(pw, 1356, 720, "wwwroot/logged.html");
payload = new { success = true };
} else {
payload = new { success = false, error = lentiaAuthResult.Error };
}
payload = result;
break;
case "auth::logout":
@ -83,7 +86,7 @@ class Program {
case "dialog::error":
string title = jsonPayload.TryGetProperty("title", out var t) ? t.GetString()! : "Lentia";
string msg = jsonPayload.GetProperty("message").GetString()!;
pw.ShowMessage(title, msg);
pw.ShowMessage(title, msg, PhotinoDialogButtons.Ok, PhotinoDialogIcon.Error);
payload = new { success = true };
break;
@ -188,10 +191,25 @@ class Program {
payload = new { success = false, error = ex.Message };
}
break;
case "oauth2::discord":
BashUtils.OpenUrl("https://yggdrasil.azures.fr/auth/provider/discord/login");
string? code = await Discord.ListenForCode();
var discordLoginResult = await Discord.LoginWithDiscordOAuth2(code!);
if (discordLoginResult.Success) {
_authenticatedPlayer = discordLoginResult.Player;
WindowHelper.MakeStandardWindow(pw, 1356, 720, "wwwroot/logged.html");
payload = new { success = true };
} else {
Console.WriteLine(discordLoginResult);
payload = new { success = false, error = discordLoginResult.Error };
}
break;
}
var response = new { requestId, payload };
window.SendWebMessage(JsonSerializer.Serialize(response, _jsonOptions));
window.SendWebMessage(JsonSerializer.Serialize(response, LauncherConstants._jsonOptions));
} catch (Exception ex) {
Console.WriteLine($"Bridge error: {ex.Message}");
}

View File

@ -1,11 +1,12 @@
using System.Net.Http;
using System.Text.Json;
namespace Lentia.Core.Constants
{
public static class LauncherConstants
{
public static readonly HttpClient Http = new HttpClient();
public static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
public static class Urls
{
public const string MojangManifest = "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json";

View File

@ -0,0 +1,57 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Lentia.Core.Auth.Yggdrasil;
using Lentia.Core.Constants;
namespace Lentia.Core.Auth.OAuth2;
public class Discord {
private static HttpListener? _listener;
private static readonly string _url = "http://localhost:8877/auth/provider/discord/login/callback/";
public static async Task<string?> ListenForCode() {
_listener = new HttpListener();
_listener.Prefixes.Add(_url);
_listener.Start();
HttpListenerContext context = await _listener.GetContextAsync();
HttpListenerRequest request = context.Request;
string? code = request.QueryString["code"];
string redirectUrl = "https://yggdrasil.azures.fr/static/success.html";
HttpListenerResponse response = context.Response;
response.StatusCode = (int)HttpStatusCode.Redirect;
response.RedirectLocation = redirectUrl;
response.OutputStream.Close();
_listener.Stop();
return code;
}
public static async Task<AuthResult> LoginWithDiscordOAuth2(string authCode) {
var url = $"https://yggdrasil.azures.fr/auth/provider/discord/login/callback?code={authCode}&requestUser=true";
try {
var response = await LauncherConstants.Http.GetAsync(url);
if (response.IsSuccessStatusCode) {
var data = await response.Content.ReadFromJsonAsync<AuthenticateResponse>();
return data != null ? AuthResult.Ok(data) : AuthResult.Fail("Erreur de lecture des données.");
} else {
var errorData = await response.Content.ReadFromJsonAsync<YggdrasilError>();
Console.WriteLine(await response.Content.ReadAsStringAsync());
return AuthResult.Fail(
errorData?.errorMessage ?? "Identifiants invalides.",
errorData?.error,
errorData?.cause
);
}
} catch (Exception ex) {
throw new Exception($"Error when establishing connection to the API : {ex.Message}");
}
}
}

View File

@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace Lentia.Utils;
@ -16,4 +17,22 @@ public static class BashUtils {
proc.Start();
return proc.StandardOutput.ReadToEnd();
}
public static void OpenUrl(string url) {
try {
Process.Start(url);
}
catch {
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
url = url.Replace("&", "^&");
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
} else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) {
Process.Start("xdg-open", url);
} else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) {
Process.Start("open", url);
} else {
throw;
}
}
}
}

View File

@ -1,15 +1,14 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Lentia.Core.Constants;
namespace Lentia.Utils;
public static class MojangAPI {
private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
public static async Task<string> UploadSkinAsync(string filePath, string variant, string token) {
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
LauncherConstants.Http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var content = new MultipartFormDataContent();
@ -21,7 +20,7 @@ public static class MojangAPI {
content.Add(fileContent, "file", Path.GetFileName(filePath));
var response = await client.PostAsync("https://yggdrasil.azures.fr/minecraftservices/minecraft/profile/skins", content);
var response = await LauncherConstants.Http.PostAsync("https://yggdrasil.azures.fr/minecraftservices/minecraft/profile/skins", content);
if (response.IsSuccessStatusCode) {
return await response.Content.ReadAsStringAsync();
@ -31,66 +30,61 @@ public static class MojangAPI {
}
public static async Task<object> GetPlayerProfileAsync(string accessToken) {
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
public static async Task<object> GetPlayerProfileAsync(string accessToken) {
LauncherConstants.Http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
LauncherConstants.Http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await client.GetAsync("https://yggdrasil.azures.fr/minecraftservices/minecraft/profile");
var response = await LauncherConstants.Http.GetAsync("https://yggdrasil.azures.fr/minecraftservices/minecraft/profile");
if (response.IsSuccessStatusCode) {
string jsonResponse = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<object>(jsonResponse, _jsonOptions)!;
return JsonSerializer.Deserialize<object>(jsonResponse, LauncherConstants._jsonOptions)!;
} else {
throw new Exception($"API Error: {response.StatusCode}");
}
}
public static async Task<object> HideCapeAsync(string accessToken) {
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
LauncherConstants.Http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var response = await client.DeleteAsync("https://yggdrasil.azures.fr/minecraftservices/minecraft/profile/capes/active");
var response = await LauncherConstants.Http.DeleteAsync("https://yggdrasil.azures.fr/minecraftservices/minecraft/profile/capes/active");
if (response.IsSuccessStatusCode) {
string jsonResponse = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<object>(jsonResponse, _jsonOptions)!;
return JsonSerializer.Deserialize<object>(jsonResponse, LauncherConstants._jsonOptions)!;
} else {
throw new Exception($"Échec du retrait de la cape : {response.StatusCode}");
}
}
public static async Task<object> ShowCapeAsync(string accessToken, string capeId) {
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
LauncherConstants.Http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var body = new { capeId = capeId };
string jsonBody = JsonSerializer.Serialize(body);
var content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
var response = await client.PutAsync("https://yggdrasil.azures.fr/minecraftservices/minecraft/profile/capes/active", content);
var response = await LauncherConstants.Http.PutAsync("https://yggdrasil.azures.fr/minecraftservices/minecraft/profile/capes/active", content);
if (response.IsSuccessStatusCode) {
string jsonResponse = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<object>(jsonResponse, _jsonOptions)!;
return JsonSerializer.Deserialize<object>(jsonResponse, LauncherConstants._jsonOptions)!;
} else {
throw new Exception($"Erreur lors de l'équipement de la cape : {response.StatusCode}");
}
}
public static async Task<object> ChangeUsernameAsync(string newName, string accessToken) {
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
LauncherConstants.Http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var url = $"https://yggdrasil.azures.fr/minecraftservices/minecraft/profile/name/{newName}";
var request = new HttpRequestMessage(HttpMethod.Put, url);
var response = await client.SendAsync(request);
var response = await LauncherConstants.Http.SendAsync(request);
if (response.IsSuccessStatusCode) {
string jsonResponse = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<object>(jsonResponse, _jsonOptions)!;
return JsonSerializer.Deserialize<object>(jsonResponse, LauncherConstants._jsonOptions)!;
}
else {
throw new Exception($"Erreur lors du changement de pseudo (Code: {response.StatusCode})");

View File

@ -1,6 +1,7 @@
const buttons = document.querySelectorAll("button[frame]")
const dynmapFrame = document.querySelector("article.frame.dynmap > iframe")
const capesSelector = document.querySelector("article.capes > div.capes")
const logsContainer = document.querySelector("div.container.logs")
let viewerInstance = new skinview3d.SkinViewer({
canvas: document.getElementById("skin"),
@ -99,7 +100,7 @@ window.initSkin = async function initSkin() {
canvas: document.getElementById("skin"),
width: container.clientWidth,
height: container.clientHeight,
skin: `https://yggdrasil.azures.fr/textures/${profile.skins.find(skin => skin.state == "ACTIVE").url}`.replace(/\/+$/, "")
skin: `https://yggdrasil.azures.fr/textures/${profile.skins.find(skin => skin.state == "ACTIVE").url.replace(/^\//, "")}`
})
const activeCape = window.profile.capes.find(s => s.state === "ACTIVE") || null
@ -228,6 +229,18 @@ window.changeUsername = async function changeUsername(newName) {
}
}
window.gamelog = {}
window.gamelog.put = async function put(log) {
const logElement = document.createElement("p")
logElement.innerText = log
logsContainer.appendChild(log)
}
window.gamelog.clear = async function clear() {
logsContainer.innerHTML = ""
}
await initSkin()
await initSettings()
await initCapesSelector()

View File

@ -17,22 +17,32 @@ async function login() {
const username = document.querySelector("#username").value
const password = document.querySelector("#password").value
const result = await system.call("auth::lentia", { username, password })
if (result.success == false) {
await system.call("dialog::error", {
title: result.error.error,
message: result.error.errorMessage
})
}
await processLoginResult(result)
}
function clearPassword() {
password.value = ""
}
function requestLoginWithOAuth2() {
async function requestLoginWithOAuth2() {
showFrame("oauth2")
hideInformation()
showLoadingBar()
const result = await system.call("oauth2::discord")
await processLoginResult(result, true)
}
async function processLoginResult(payload, showDefaultPage = false) {
if (payload.success == false) {
await system.call("dialog::error", {
title: payload.error.error,
message: payload.error.errorMessage
})
if (showDefaultPage) {
hideLoadingBar()
showFrame("provider")
}
}
}
hideLoadingBar()