334 lines
13 KiB
JavaScript
334 lines
13 KiB
JavaScript
'use strict';
|
||
|
||
/*
|
||
* auth.js – offizieller Microsoft-Login für Minecraft (Phase 3)
|
||
* -------------------------------------------------------------
|
||
* Ablauf (Device-Code-Flow, wie bei Prism/MultiMC):
|
||
* 1. Microsoft OAuth Device-Code -> MS access_token + refresh_token
|
||
* 2. Xbox-Live-Authentifizierung -> XBL-Token + userhash
|
||
* 3. XSTS-Autorisierung -> XSTS-Token
|
||
* 4. Minecraft-Login -> MC access_token
|
||
* 5. Minecraft-Profil -> UUID + Name (prüft zugleich den Besitz)
|
||
*
|
||
* Erfordert eine kostenlose Azure-App-ID (öffentlicher Client, Device-Flow).
|
||
* Wir bauen KEINEN Offline-/Crack-Login – ohne echtes Konto kein Start.
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
// Fest eingebaute Azure-App-ID des Launchers (öffentlicher Client – kein Geheimnis).
|
||
// Jeder Nutzer meldet sich damit über SEIN eigenes Microsoft-Konto an.
|
||
// Über die Einstellungen lässt sich bei Bedarf eine eigene ID hinterlegen.
|
||
const DEFAULT_CLIENT_ID = '0625f4a8-dfff-4eab-8124-a573b00b0319';
|
||
|
||
const SCOPE = 'XboxLive.signin offline_access';
|
||
// Manche Endpunkte (v. a. api.minecraftservices.com hinter Cloudflare) lehnen
|
||
// Anfragen ohne User-Agent mit HTTP 403 ab -> überall mitsenden.
|
||
const UA = 'AeroMC-Launcher/0.5';
|
||
const DEVICECODE_URL = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode';
|
||
const TOKEN_URL = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token';
|
||
const XBL_URL = 'https://user.auth.xboxlive.com/user/authenticate';
|
||
const XSTS_URL = 'https://xsts.auth.xboxlive.com/xsts/authorize';
|
||
const MC_LOGIN_URL = 'https://api.minecraftservices.com/authentication/login_with_xbox';
|
||
const MC_PROFILE_URL = 'https://api.minecraftservices.com/minecraft/profile';
|
||
|
||
let USER_DATA = null;
|
||
let SECURE = null; // optionaler Verschlüsseler (Electron safeStorage)
|
||
|
||
function init(userDataPath, secure) {
|
||
USER_DATA = userDataPath;
|
||
SECURE = secure && secure.available && secure.available() ? secure : null;
|
||
migrateLegacy();
|
||
if (SECURE) { try { writeStore(readStore()); } catch { /* Migration best effort */ } }
|
||
}
|
||
|
||
// sensible Felder verschlüsselt ablegen (an das Windows-Benutzerkonto gebunden)
|
||
const SENSITIVE = ['mcAccessToken', 'msRefreshToken'];
|
||
const ENC = 'enc:v1:';
|
||
function protect(acc) {
|
||
if (!SECURE) return acc;
|
||
const out = Object.assign({}, acc);
|
||
for (const k of SENSITIVE) {
|
||
const v = out[k];
|
||
if (typeof v === 'string' && v && !v.startsWith(ENC)) {
|
||
try { out[k] = ENC + SECURE.encrypt(v); } catch { /* unverschlüsselt lassen */ }
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
function unprotect(acc) {
|
||
const out = Object.assign({}, acc);
|
||
for (const k of SENSITIVE) {
|
||
const v = out[k];
|
||
if (typeof v === 'string' && v.startsWith(ENC)) {
|
||
try { out[k] = SECURE ? SECURE.decrypt(v.slice(ENC.length)) : null; } catch { out[k] = null; }
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
function accountsFile() { return path.join(USER_DATA, 'accounts.json'); }
|
||
function legacyFile() { return path.join(USER_DATA, 'account.json'); }
|
||
|
||
// Speicherform: { activeId, accounts: [ {profile,mcAccessToken,mcExpiresAt,msRefreshToken,updatedAt}, ... ] }
|
||
function readStore() {
|
||
try {
|
||
const s = JSON.parse(fs.readFileSync(accountsFile(), 'utf8'));
|
||
if (s && Array.isArray(s.accounts)) {
|
||
return { activeId: s.activeId, accounts: s.accounts.map(unprotect) };
|
||
}
|
||
} catch { /* leer */ }
|
||
return { activeId: null, accounts: [] };
|
||
}
|
||
function writeStore(s) {
|
||
const safe = { activeId: s.activeId, accounts: (s.accounts || []).map(protect) };
|
||
fs.writeFileSync(accountsFile(), JSON.stringify(safe, null, 2));
|
||
}
|
||
|
||
// altes Einzelkonto (account.json) übernehmen
|
||
function migrateLegacy() {
|
||
try {
|
||
if (fs.existsSync(accountsFile())) return;
|
||
const old = JSON.parse(fs.readFileSync(legacyFile(), 'utf8'));
|
||
if (old && old.profile) {
|
||
writeStore({ activeId: old.profile.id, accounts: [old] });
|
||
try { fs.unlinkSync(legacyFile()); } catch { /* egal */ }
|
||
}
|
||
} catch { /* nichts zu migrieren */ }
|
||
}
|
||
|
||
function upsertAccount(acc) {
|
||
const s = readStore();
|
||
s.accounts = s.accounts.filter((a) => a.profile.id !== acc.profile.id);
|
||
s.accounts.push(acc);
|
||
s.activeId = acc.profile.id; // frisch eingeloggtes/erneuertes Konto wird aktiv
|
||
writeStore(s);
|
||
return acc;
|
||
}
|
||
|
||
function listAccounts() {
|
||
const s = readStore();
|
||
return s.accounts.map((a) => ({ id: a.profile.id, name: a.profile.name, active: a.profile.id === s.activeId }));
|
||
}
|
||
|
||
function setActive(id) {
|
||
const s = readStore();
|
||
if (s.accounts.some((a) => a.profile.id === id)) { s.activeId = id; writeStore(s); }
|
||
return listAccounts();
|
||
}
|
||
|
||
function removeAccount(id) {
|
||
const s = readStore();
|
||
s.accounts = s.accounts.filter((a) => a.profile.id !== id);
|
||
if (s.activeId === id) s.activeId = s.accounts[0] ? s.accounts[0].profile.id : null;
|
||
writeStore(s);
|
||
return listAccounts();
|
||
}
|
||
|
||
function getAccountById(id) {
|
||
return readStore().accounts.find((a) => a.profile.id === id) || null;
|
||
}
|
||
function getActiveRaw() {
|
||
const s = readStore();
|
||
return s.accounts.find((a) => a.profile.id === s.activeId) || null;
|
||
}
|
||
function clearAccount() { try { fs.unlinkSync(accountsFile()); } catch { /* egal */ } }
|
||
|
||
async function postForm(url, params) {
|
||
const res = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': UA, Accept: 'application/json' },
|
||
body: new URLSearchParams(params).toString(),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
return { status: res.status, data };
|
||
}
|
||
|
||
async function postJson(url, body, headers = {}) {
|
||
const res = await fetch(url, {
|
||
method: 'POST',
|
||
headers: Object.assign({ 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': UA }, headers),
|
||
body: JSON.stringify(body),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
return { status: res.status, data };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 1. Device-Code anfordern
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function requestDeviceCode(clientId) {
|
||
if (!clientId) throw new Error('Keine Azure-Client-ID gesetzt (Einstellungen → Microsoft-Login).');
|
||
const { status, data } = await postForm(DEVICECODE_URL, { client_id: clientId, scope: SCOPE });
|
||
if (status !== 200) {
|
||
throw new Error('Device-Code fehlgeschlagen: ' + (data.error_description || data.error || status));
|
||
}
|
||
return data; // { user_code, device_code, verification_uri, expires_in, interval, message }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 2. Auf Autorisierung warten (pollt Token-Endpoint)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function pollForToken(clientId, deviceCode, intervalSec, onTick) {
|
||
let interval = Math.max(intervalSec || 5, 3);
|
||
const deadline = Date.now() + 15 * 60 * 1000;
|
||
while (Date.now() < deadline) {
|
||
await sleep(interval * 1000);
|
||
const { data } = await postForm(TOKEN_URL, {
|
||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
||
client_id: clientId,
|
||
device_code: deviceCode,
|
||
});
|
||
if (data.access_token) return data; // { access_token, refresh_token, expires_in }
|
||
if (data.error === 'authorization_pending') { onTick && onTick(); continue; }
|
||
if (data.error === 'slow_down') { interval += 5; continue; }
|
||
if (data.error === 'authorization_declined') throw new Error('Anmeldung abgelehnt.');
|
||
if (data.error === 'expired_token') throw new Error('Code abgelaufen – bitte erneut anmelden.');
|
||
throw new Error('Login-Fehler: ' + (data.error_description || data.error));
|
||
}
|
||
throw new Error('Zeitüberschreitung – Code nicht rechtzeitig bestätigt.');
|
||
}
|
||
|
||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 3.–5. Xbox -> XSTS -> Minecraft -> Profil
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function xboxLogin(msAccessToken) {
|
||
const { status, data } = await postJson(XBL_URL, {
|
||
Properties: { AuthMethod: 'RPS', SiteName: 'user.auth.xboxlive.com', RpsTicket: 'd=' + msAccessToken },
|
||
RelyingParty: 'http://auth.xboxlive.com',
|
||
TokenType: 'JWT',
|
||
});
|
||
if (status !== 200 || !data.Token) {
|
||
throw new Error('Xbox-Live-Login fehlgeschlagen (' + status + ')' + (data && data.Message ? ': ' + data.Message : '') + '.');
|
||
}
|
||
const uhs = data.DisplayClaims && data.DisplayClaims.xui && data.DisplayClaims.xui[0] && data.DisplayClaims.xui[0].uhs;
|
||
return { token: data.Token, uhs };
|
||
}
|
||
|
||
async function xstsAuth(xblToken) {
|
||
const { status, data } = await postJson(XSTS_URL, {
|
||
Properties: { SandboxId: 'RETAIL', UserTokens: [xblToken] },
|
||
RelyingParty: 'rp://api.minecraftservices.com/',
|
||
TokenType: 'JWT',
|
||
});
|
||
if (status === 401) {
|
||
const xerr = data.XErr;
|
||
if (xerr === 2148916233) throw new Error('Dieses Microsoft-Konto hat kein Xbox-Profil. Bitte einmal auf xbox.com anmelden.');
|
||
if (xerr === 2148916238) throw new Error('Kinderkonto – muss einer Familie hinzugefügt werden.');
|
||
throw new Error('XSTS-Autorisierung abgelehnt (XErr ' + xerr + ').');
|
||
}
|
||
if (status !== 200 || !data.Token) throw new Error('XSTS-Autorisierung fehlgeschlagen (' + status + ').');
|
||
return { token: data.Token };
|
||
}
|
||
|
||
async function minecraftLogin(uhs, xstsToken) {
|
||
const { status, data } = await postJson(MC_LOGIN_URL, {
|
||
identityToken: `XBL3.0 x=${uhs};${xstsToken}`,
|
||
});
|
||
if (status !== 200 || !data.access_token) {
|
||
const detail = data && (data.errorMessage || data.error || data.path) ? ': ' + (data.errorMessage || data.error || data.path) : '';
|
||
throw new Error('Minecraft-Login fehlgeschlagen (' + status + ')' + detail + '.');
|
||
}
|
||
return { accessToken: data.access_token, expiresIn: data.expires_in || 86400 };
|
||
}
|
||
|
||
async function minecraftProfile(mcAccessToken) {
|
||
const res = await fetch(MC_PROFILE_URL, {
|
||
headers: { Authorization: 'Bearer ' + mcAccessToken, 'User-Agent': UA, Accept: 'application/json' },
|
||
});
|
||
if (res.status === 404) throw new Error('Dieses Konto besitzt kein Minecraft (Java Edition).');
|
||
if (!res.ok) throw new Error('Profil laden fehlgeschlagen (' + res.status + ').');
|
||
const data = await res.json();
|
||
return { id: data.id, name: data.name }; // id = UUID ohne Bindestriche
|
||
}
|
||
|
||
// vollständige Kette ab MS-Token -> gespeichertes Konto
|
||
async function completeFromMsToken(msToken) {
|
||
const xbl = await xboxLogin(msToken.access_token);
|
||
const xsts = await xstsAuth(xbl.token);
|
||
const mc = await minecraftLogin(xbl.uhs, xsts.token);
|
||
const profile = await minecraftProfile(mc.accessToken);
|
||
|
||
return {
|
||
profile, // { id, name }
|
||
mcAccessToken: mc.accessToken,
|
||
mcExpiresAt: Date.now() + (mc.expiresIn - 60) * 1000,
|
||
msRefreshToken: msToken.refresh_token || null,
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
// ersetzt ein Konto in der Liste, ohne das aktive Konto zu ändern (für Token-Refresh)
|
||
function updateAccount(acc) {
|
||
const s = readStore();
|
||
s.accounts = s.accounts.filter((a) => a.profile.id !== acc.profile.id);
|
||
s.accounts.push(acc);
|
||
writeStore(s);
|
||
return acc;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Öffentliche Login-Orchestrierung
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/*
|
||
* startLogin: fordert Device-Code an und ruft danach den Callback mit dem Code.
|
||
* Läuft dann im Hintergrund bis zur Bestätigung. Gibt am Ende das Konto zurück.
|
||
*/
|
||
async function login(clientId, onCode, onTick) {
|
||
const dc = await requestDeviceCode(clientId);
|
||
onCode && onCode({
|
||
userCode: dc.user_code,
|
||
verificationUri: dc.verification_uri,
|
||
message: dc.message,
|
||
expiresIn: dc.expires_in,
|
||
});
|
||
const msToken = await pollForToken(clientId, dc.device_code, dc.interval, onTick);
|
||
const account = await completeFromMsToken(msToken);
|
||
return upsertAccount(account); // neues Konto speichern + aktiv setzen
|
||
}
|
||
|
||
// ein Konto gültig machen (Token ggf. per Refresh erneuern)
|
||
async function refreshAccount(clientId, acc) {
|
||
if (acc.mcAccessToken && acc.mcExpiresAt && Date.now() < acc.mcExpiresAt) return acc;
|
||
if (acc.msRefreshToken && clientId) {
|
||
const { data } = await postForm(TOKEN_URL, {
|
||
grant_type: 'refresh_token',
|
||
client_id: clientId,
|
||
refresh_token: acc.msRefreshToken,
|
||
scope: SCOPE,
|
||
});
|
||
if (data.access_token) {
|
||
try {
|
||
const fresh = await completeFromMsToken(data);
|
||
if (!fresh.msRefreshToken) fresh.msRefreshToken = acc.msRefreshToken;
|
||
return updateAccount(fresh);
|
||
} catch { return null; }
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// aktives Konto gültig holen (null wenn kein Login)
|
||
async function getValidAccount(clientId) {
|
||
const acc = getActiveRaw();
|
||
return acc ? refreshAccount(clientId, acc) : null;
|
||
}
|
||
// bestimmtes Konto gültig holen (für „starten als …")
|
||
async function getValidAccountById(clientId, id) {
|
||
const acc = getAccountById(id);
|
||
return acc ? refreshAccount(clientId, acc) : null;
|
||
}
|
||
|
||
function currentAccount() { return getActiveRaw(); }
|
||
|
||
module.exports = {
|
||
init, login, getValidAccount, getValidAccountById, currentAccount, clearAccount,
|
||
listAccounts, setActive, removeAccount, DEFAULT_CLIENT_ID,
|
||
};
|