'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, };