diff --git a/package.json b/package.json index ce532af..cbecd4f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "url": "git://github.com/PrismarineJS/node-minecraft-protocol.git" }, "scripts": { + "prepare": "npm install require-self && require-self", "test": "mocha --recursive --reporter spec --exit --exclude \"non-par-test.js\"", "test-non-par": "mocha --recursive --reporter spec --exit \"test/non-par-test.js\"", "lint": "standard", @@ -37,24 +38,25 @@ "espower-loader": "^1.0.0", "intelli-espower-loader": "^1.0.0", "minecraft-packets": "^1.1.5", - "minecraft-protocol": "file:.", "minecraft-wrap": "^1.2.3", "mocha": "^9.0.0", "power-assert": "^1.0.0", + "require-self": "^0.2.3", "standard": "^16.0.1" }, "dependencies": { + "@azure/msal-node": "^1.0.0-beta.3", + "@xboxreplay/xboxlive-auth": "^3.3.3", "aes-js": "^3.1.2", "buffer-equal": "^1.0.0", - "debug": "^4.3.2", + "debug": "^4.1.0", "endian-toggle": "^0.0.0", "lodash.get": "^4.1.2", "lodash.merge": "^4.3.0", - "minecraft-data": "^2.89.4", - "minecraft-folder-path": "^1.2.0", + "minecraft-data": "^2.85.1", + "minecraft-folder-path": "^1.1.0", "node-fetch": "^2.6.1", "node-rsa": "^0.4.2", - "prismarine-auth": "^1.1.0", "prismarine-nbt": "^1.3.0", "protodef": "^1.8.0", "readable-stream": "^3.0.6", diff --git a/src/client/authConstants.js b/src/client/authConstants.js new file mode 100644 index 0000000..e2e81c7 --- /dev/null +++ b/src/client/authConstants.js @@ -0,0 +1,6 @@ +module.exports = { + XSTSRelyingParty: 'rp://api.minecraftservices.com/', + MinecraftServicesLogWithXbox: 'https://api.minecraftservices.com/authentication/login_with_xbox', + MinecraftServicesEntitlement: 'https://api.minecraftservices.com/entitlements/mcstore', + MinecraftServicesProfile: 'https://api.minecraftservices.com/minecraft/profile' +} diff --git a/src/client/authFlow.js b/src/client/authFlow.js new file mode 100644 index 0000000..2396e8c --- /dev/null +++ b/src/client/authFlow.js @@ -0,0 +1,131 @@ +const crypto = require('crypto') +const path = require('path') +const fs = require('fs') +const debug = require('debug')('minecraft-protocol') +const mcDefaultFolderPath = require('minecraft-folder-path') +const authConstants = require('./authConstants') +const { MsaTokenManager, XboxTokenManager, MinecraftTokenManager } = require('./tokens') + +// Initialize msal +// Docs: https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-common/docs/request.md#public-apis-1 +const msalConfig = { + auth: { + // the minecraft client: + // clientId: "000000004C12AE6F", + clientId: '389b1b32-b5d5-43b2-bddc-84ce938d6737', // token from https://github.com/microsoft/Office365APIEditor + authority: 'https://login.microsoftonline.com/consumers' + } +} + +async function retry (methodFn, beforeRetry, times) { + while (times--) { + if (times !== 0) { + try { return await methodFn() } catch (e) { debug(e) } + await new Promise(resolve => setTimeout(resolve, 2000)) + await beforeRetry() + } else { + return await methodFn() + } + } +} + +class MsAuthFlow { + constructor (username, cacheDir, codeCallback) { + this.initTokenCaches(username, cacheDir) + this.codeCallback = codeCallback + } + + initTokenCaches (username, cacheDir) { + const hash = sha1(username).substr(0, 6) + + let cachePath = cacheDir || mcDefaultFolderPath + try { + if (!fs.existsSync(cachePath + '/nmp-cache')) { + fs.mkdirSync(cachePath + '/nmp-cache', { recursive: true }) + } + cachePath += '/nmp-cache' + } catch (e) { + console.log('Failed to open cache dir', e) + cachePath = __dirname + } + + const cachePaths = { + msa: path.join(cachePath, `./${hash}_msa-cache.json`), + xbl: path.join(cachePath, `./${hash}_xbl-cache.json`), + mca: path.join(cachePath, `./${hash}_mca-cache.json`) + } + + const scopes = ['XboxLive.signin', 'offline_access'] + this.msa = new MsaTokenManager(msalConfig, scopes, cachePaths.msa) + this.xbl = new XboxTokenManager(authConstants.XSTSRelyingParty, cachePaths.xbl) + this.mca = new MinecraftTokenManager(cachePaths.mca) + } + + static resetTokenCaches (cacheDir) { + let cachePath = cacheDir || mcDefaultFolderPath + try { + if (fs.existsSync(cachePath + '/nmp-cache')) { + cachePath += '/nmp-cache' + fs.rmdirSync(cachePath, { recursive: true }) + return true + } + } catch (e) { + console.log('Failed to clear cache dir', e) + return false + } + } + + async getMsaToken () { + if (await this.msa.verifyTokens()) { + debug('[msa] Using existing tokens') + return this.msa.getAccessToken().token + } else { + debug('[msa] No valid cached tokens, need to sign in') + const ret = await this.msa.authDeviceCode((response) => { + console.info('[msa] First time signing in. Please authenticate now:') + console.info(response.message) + if (this.codeCallback) this.codeCallback(response) + }) + + console.info(`[msa] Signed in as ${ret.account.username}`) + + debug('[msa] got auth result', ret) + return ret.accessToken + } + } + + async getXboxToken () { + if (await this.xbl.verifyTokens()) { + debug('[xbl] Using existing tokens') + return this.xbl.getCachedXstsToken().data + } else { + debug('[xbl] Need to obtain tokens') + return await retry(async () => { + const msaToken = await this.getMsaToken() + const ut = await this.xbl.getUserToken(msaToken) + const xsts = await this.xbl.getXSTSToken(ut) + return xsts + }, () => { this.msa.forceRefresh = true }, 2) + } + } + + async getMinecraftToken () { + if (await this.mca.verifyTokens()) { + debug('[mc] Using existing tokens') + return this.mca.getCachedAccessToken().token + } else { + debug('[mc] Need to obtain tokens') + return await retry(async () => { + const xsts = await this.getXboxToken() + debug('[xbl] xsts data', xsts) + return this.mca.getAccessToken(xsts) + }, () => { this.xbl.forceRefresh = true }, 2) + } + } +} + +function sha1 (data) { + return crypto.createHash('sha1').update(data || '', 'binary').digest('hex') +} + +module.exports = { MsAuthFlow } diff --git a/src/client/microsoftAuth.js b/src/client/microsoftAuth.js index 62d3c24..759a39f 100644 --- a/src/client/microsoftAuth.js +++ b/src/client/microsoftAuth.js @@ -1,35 +1,132 @@ -const path = require('path') -const { Authflow: PrismarineAuth } = require('prismarine-auth') -const minecraftFolderPath = require('minecraft-folder-path') +const XboxLiveAuth = require('@xboxreplay/xboxlive-auth') const debug = require('debug')('minecraft-protocol') +const fetch = require('node-fetch') +const authConstants = require('./authConstants') +const { MsAuthFlow } = require('./authFlow.js') -async function authenticate (client, options) { - if (!options.profilesFolder) { - options.profilesFolder = path.join(minecraftFolderPath, 'nmp-cache') +const getFetchOptions = { + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'node-minecraft-protocol' + } +} + +/** + * Obtains Minecaft profile data using a Minecraft access token and starts the join sequence + * @param {object} client - The client passed to protocol + * @param {object} options - Client Options + * @param {string} mcAccessToken - Minecraft access token for session server + * @param {object?} msa - Cached Microsoft account data for more descriptive errors + */ +async function postAuthenticate (client, options, mcAccessToken, msa) { + options.haveCredentials = mcAccessToken != null + + let minecraftProfile + const res = await fetch(authConstants.MinecraftServicesProfile, getFetchOptions) + if (res.ok) { // res.status >= 200 && res.status < 300 + minecraftProfile = await res.json() + } else { + const user = msa ? msa.getUsers()[0] : options.username + throw Error(`Failed to obtain Minecraft profile data for '${user?.username}', does the account own Minecraft Java? Server returned: ${res.statusText}`) } - const Authflow = new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode) - const { token, entitlements, profile } = await Authflow.getMinecraftJavaToken({ fetchEntitlements: true, fetchProfile: true }) + if (!minecraftProfile.id) { + debug('[mc] profile', minecraftProfile) + throw Error('This user does not own minecraft according to minecraft services.') + } - if (entitlements.items.length === 0) throw Error('This user does not possess any entitlements on this account according to minecraft services.') - debug('[mc] entitlements', entitlements) - - options.haveCredentials = token !== null - if (profile.error) throw Error(`Failed to obtain profile data for ${options.username}, does the account own minecraft?\n${profile}`) - debug('[mc] profile', profile) + // This profile / session here could be simplified down to where it just passes the uuid of the player to encrypt.js + // That way you could remove some lines of code. It accesses client.session.selectedProfile.id so /shrug. + // - Kashalls + const profile = { + name: minecraftProfile.name, + id: minecraftProfile.id + } const session = { - accessToken: token, + accessToken: mcAccessToken, selectedProfile: profile, availableProfile: [profile] } client.session = session - client.username = profile.name - options.accessToken = token + client.username = minecraftProfile.name + options.accessToken = mcAccessToken client.emit('session', session) options.connect(client) } -module.exports = { - authenticate +/** + * Authenticates with Mincrosoft through user credentials, then + * with Xbox Live, Minecraft, checks entitlements and returns profile + * + * @function + * @param {object} client - The client passed to protocol + * @param {object} options - Client Options + */ +async function authenticatePassword (client, options) { + let XAuthResponse + + try { + XAuthResponse = await XboxLiveAuth.authenticate(options.username, options.password, { XSTSRelyingParty: authConstants.XSTSRelyingParty }) + .catch((err) => { + console.warn('Unable to authenticate with Microsoft', err) + throw err + }) + } catch (e) { + console.info('Retrying auth with device code flow') + return await authenticateDeviceCode(client, options) + } + + try { + const MineServicesResponse = await fetch(authConstants.MinecraftServicesLogWithXbox, { + method: 'post', + ...getFetchOptions, + body: JSON.stringify({ identityToken: `XBL3.0 x=${XAuthResponse.userHash};${XAuthResponse.XSTSToken}` }) + }).then(checkStatus) + + getFetchOptions.headers.Authorization = `Bearer ${MineServicesResponse.access_token}` + const MineEntitlements = await fetch(authConstants.MinecraftServicesEntitlement, getFetchOptions).then(checkStatus) + if (MineEntitlements.items.length === 0) throw Error('This user does not have any items on its accounts according to minecraft services.') + + await postAuthenticate(client, options, MineServicesResponse.access_token) + } catch (err) { + console.error(err) + client.emit('error', err) + } +} + +/** + * Authenticates to Minecraft via device code based Microsoft auth, + * then connects to the specified server in Client Options + * + * @function + * @param {object} client - The client passed to protocol + * @param {object} options - Client Options + */ +async function authenticateDeviceCode (client, options) { + try { + const flow = new MsAuthFlow(options.username, options.profilesFolder, options.onMsaCode) + + const token = await flow.getMinecraftToken() + debug('Acquired Minecraft token', token.slice(0, 16)) + + getFetchOptions.headers.Authorization = `Bearer ${token}` + await postAuthenticate(client, options, token, flow.msa) + } catch (err) { + console.error(err) + client.emit('error', err) + } +} + +function checkStatus (res) { + if (res.ok) { // res.status >= 200 && res.status < 300 + return res.json() + } else { + throw Error(res.statusText) + } +} + +module.exports = { + authenticatePassword, + authenticateDeviceCode } diff --git a/src/client/tokens.js b/src/client/tokens.js new file mode 100644 index 0000000..baceaff --- /dev/null +++ b/src/client/tokens.js @@ -0,0 +1,297 @@ +const msal = require('@azure/msal-node') +const XboxLiveAuth = require('@xboxreplay/xboxlive-auth') +const debug = require('debug')('minecraft-protocol') +const fs = require('fs') +const path = require('path') +const fetch = require('node-fetch') +const authConstants = require('./authConstants') + +// Manages Microsoft account tokens +class MsaTokenManager { + constructor (msalConfig, scopes, cacheLocation) { + this.msaClientId = msalConfig.auth.clientId + this.scopes = scopes + this.cacheLocation = cacheLocation || path.join(__dirname, './msa-cache.json') + + this.reloadCache() + + const beforeCacheAccess = async (cacheContext) => { + cacheContext.tokenCache.deserialize(await fs.promises.readFile(this.cacheLocation, 'utf-8')) + } + + const afterCacheAccess = async (cacheContext) => { + if (cacheContext.cacheHasChanged) { + await fs.promises.writeFile(this.cacheLocation, cacheContext.tokenCache.serialize()) + } + } + + const cachePlugin = { + beforeCacheAccess, + afterCacheAccess + } + + msalConfig.cache = { + cachePlugin + } + this.msalApp = new msal.PublicClientApplication(msalConfig) + this.msalConfig = msalConfig + } + + reloadCache () { + try { + this.msaCache = require(this.cacheLocation) + } catch (e) { + this.msaCache = {} + fs.writeFileSync(this.cacheLocation, JSON.stringify(this.msaCache)) + } + } + + getUsers () { + const accounts = this.msaCache.Account + const users = [] + if (!accounts) return users + for (const account of Object.values(accounts)) { + users.push(account) + } + return users + } + + getAccessToken () { + const tokens = this.msaCache.AccessToken + if (!tokens) return + const account = Object.values(tokens).filter(t => t.client_id === this.msaClientId)[0] + if (!account) { + debug('[msa] No valid access token found', tokens) + return + } + const until = new Date(account.expires_on * 1000) - Date.now() + const valid = until > 1000 + return { valid, until: until, token: account.secret } + } + + getRefreshToken () { + const tokens = this.msaCache.RefreshToken + if (!tokens) return + const account = Object.values(tokens).filter(t => t.client_id === this.msaClientId)[0] + if (!account) { + debug('[msa] No valid refresh token found', tokens) + return + } + return { token: account.secret } + } + + async refreshTokens () { + const rtoken = this.getRefreshToken() + if (!rtoken) { + throw new Error('Cannot refresh without refresh token') + } + const refreshTokenRequest = { + refreshToken: rtoken.token, + scopes: this.scopes + } + + return new Promise((resolve, reject) => { + this.msalApp.acquireTokenByRefreshToken(refreshTokenRequest).then((response) => { + debug('[msa] refreshed token', JSON.stringify(response)) + this.reloadCache() + resolve(response) + }).catch((error) => { + debug('[msa] failed to refresh', JSON.stringify(error)) + reject(error) + }) + }) + } + + async verifyTokens () { + const at = this.getAccessToken() + const rt = this.getRefreshToken() + if (!at || !rt || this.forceRefresh) { + return false + } + debug('[msa] have at, rt', at, rt) + if (at.valid && rt) { + return true + } else { + try { + await this.refreshTokens() + return true + } catch (e) { + return false + } + } + } + + // Authenticate with device_code flow + async authDeviceCode (dataCallback) { + const deviceCodeRequest = { + deviceCodeCallback: (resp) => { + debug('[msa] device_code response: ', resp) + dataCallback(resp) + }, + scopes: this.scopes + } + + return new Promise((resolve, reject) => { + this.msalApp.acquireTokenByDeviceCode(deviceCodeRequest).then((response) => { + debug('[msa] device_code resp', JSON.stringify(response)) + if (!this.msaCache.Account) this.msaCache.Account = { '': response.account } + resolve(response) + }).catch((error) => { + console.warn('[msa] Error getting device code') + console.debug(JSON.stringify(error)) + reject(error) + }) + }) + } +} + +// Manages Xbox Live tokens for xboxlive.com +class XboxTokenManager { + constructor (relyingParty, cacheLocation) { + this.relyingParty = relyingParty + this.cacheLocation = cacheLocation || path.join(__dirname, './xbl-cache.json') + try { + this.cache = require(this.cacheLocation) + } catch (e) { + this.cache = {} + } + } + + getCachedUserToken () { + const token = this.cache.userToken + if (!token) return + const until = new Date(token.NotAfter) + const dn = Date.now() + const remainingMs = until - dn + const valid = remainingMs > 1000 + return { valid, token: token.Token, data: token } + } + + getCachedXstsToken () { + const token = this.cache.xstsToken + if (!token) return + const until = new Date(token.expiresOn) + const dn = Date.now() + const remainingMs = until - dn + const valid = remainingMs > 1000 + return { valid, token: token.XSTSToken, data: token } + } + + setCachedUserToken (data) { + this.cache.userToken = data + fs.writeFileSync(this.cacheLocation, JSON.stringify(this.cache)) + } + + setCachedXstsToken (data) { + this.cache.xstsToken = data + fs.writeFileSync(this.cacheLocation, JSON.stringify(this.cache)) + } + + async verifyTokens () { + const ut = this.getCachedUserToken() + const xt = this.getCachedXstsToken() + if (!ut || !xt || this.forceRefresh) { + return false + } + debug('[xbl] have user, xsts', ut, xt) + if (ut.valid && xt.valid) { + return true + } else if (ut.valid && !xt.valid) { + try { + await this.getXSTSToken(ut.data) + return true + } catch (e) { + return false + } + } + return false + } + + async getUserToken (msaAccessToken) { + debug('[xbl] obtaining xbox token with ms token', msaAccessToken) + if (!msaAccessToken.startsWith('d=')) { msaAccessToken = 'd=' + msaAccessToken } + const xblUserToken = await XboxLiveAuth.exchangeRpsTicketForUserToken(msaAccessToken) + this.setCachedUserToken(xblUserToken) + debug('[xbl] user token:', xblUserToken) + return xblUserToken + } + + async getXSTSToken (xblUserToken) { + debug('[xbl] obtaining xsts token with xbox user token', xblUserToken.Token) + const xsts = await XboxLiveAuth.exchangeUserTokenForXSTSIdentity( + xblUserToken.Token, { XSTSRelyingParty: this.relyingParty, raw: false } + ) + this.setCachedXstsToken(xsts) + debug('[xbl] xsts', xsts) + return xsts + } +} + +// Manages Minecraft tokens for sessionserver.mojang.com +class MinecraftTokenManager { + constructor (cacheLocation) { + this.cacheLocation = cacheLocation || path.join(__dirname, './mca-cache.json') + try { + this.cache = require(this.cacheLocation) + } catch (e) { + this.cache = {} + } + } + + getCachedAccessToken () { + const token = this.cache.mca + debug('[mc] token cache', this.cache) + if (!token) return + const expires = token.obtainedOn + (token.expires_in * 1000) + const remaining = expires - Date.now() + const valid = remaining > 1000 + return { valid, until: expires, token: token.access_token, data: token } + } + + setCachedAccessToken (data) { + data.obtainedOn = Date.now() + this.cache.mca = data + fs.writeFileSync(this.cacheLocation, JSON.stringify(this.cache)) + } + + async verifyTokens () { + const at = this.getCachedAccessToken() + if (!at || this.forceRefresh) { + return false + } + debug('[mc] have user access token', at) + if (at.valid) { + return true + } + return false + } + + async getAccessToken (xsts) { + debug('[mc] authing to minecraft', xsts) + const getFetchOptions = { + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'node-minecraft-protocol' + } + } + const MineServicesResponse = await fetch(authConstants.MinecraftServicesLogWithXbox, { + method: 'post', + ...getFetchOptions, + body: JSON.stringify({ identityToken: `XBL3.0 x=${xsts.userHash};${xsts.XSTSToken}` }) + }).then(checkStatus) + + debug('[mc] mc auth response', MineServicesResponse) + this.setCachedAccessToken(MineServicesResponse) + return MineServicesResponse.access_token + } +} + +function checkStatus (res) { + if (res.ok) { // res.status >= 200 && res.status < 300 + return res.json() + } else { + throw Error(res.statusText) + } +} + +module.exports = { MsaTokenManager, XboxTokenManager, MinecraftTokenManager } diff --git a/src/createClient.js b/src/createClient.js index ffdc5ed..4dd16e7 100644 --- a/src/createClient.js +++ b/src/createClient.js @@ -35,7 +35,11 @@ function createClient (options) { tcpDns(client, options) if (options.auth === 'microsoft') { - microsoftAuth.authenticate(client, options) + if (options.password) { + microsoftAuth.authenticatePassword(client, options) + } else { + microsoftAuth.authenticateDeviceCode(client, options) + } } else { auth(client, options) }