Files
AeroMc-Launcher/src/auth.js
T

334 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
};