diff --git a/README.md b/README.md index 8eb7dce..1c87815 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,9 @@ const bedrock = require('bedrock-protocol') const server = new bedrock.createServer({ host: '0.0.0.0', // optional. host to bind as. port: 19132, // optional - version: '1.16.220' // optional. The server version, latest if not specified. + version: '1.16.220', // optional. The server version, latest if not specified. + // Optional for some servers which verify the title ID: + // authTitle: bedrock.title.MinecraftNintendoSwitch }) server.on('connect', client => { diff --git a/docs/API.md b/docs/API.md index 071cb6b..23de8f1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -13,6 +13,7 @@ Returns a `Client` instance and connects to the server. | version | *optional* | Version to connect as.
(Future feature, see [#69][1]) If not specified, should automatically match server version.
(Current feature) Defaults to latest version. | | offline | *optional* | default to **false**. Set this to true to disable Microsoft/Xbox auth. | | username | Conditional | Required if `offline` set to true : Username to connect to server as. | +| authTitle | *optional* | The title ID to connect as, see the README for usage. | | connectTimeout | *optional* | default to **9000ms**. How long to wait in milliseconds while trying to connect to server. | | onMsaCode | *optional* | Callback called when signing in with a microsoft account with device code auth, `data` is an object documented [here](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code#device-authorization-response) | | profilesFolder | *optional* | Where to store cached authentication tokens. Defaults to .minecraft, or the node_modules folder if not found. | diff --git a/docs/FAQ.md b/docs/FAQ.md index c4a5f06..556eedf 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -4,4 +4,7 @@ This issue occurs due to loopback restrictions on Windows 10 UWP apps. To lift t ```ps CheckNetIsolation LoopbackExempt -a -n="Microsoft.MinecraftUWP_8wekyb3d8bbwe" -``` \ No newline at end of file +``` +## Kicked during login + +Some servers can kick you if you don't set `authTitle` as explained in the README. \ No newline at end of file diff --git a/examples/clientReadmeExample.js b/examples/clientReadmeExample.js index 32cffaf..09a29b5 100644 --- a/examples/clientReadmeExample.js +++ b/examples/clientReadmeExample.js @@ -4,7 +4,9 @@ const client = bedrock.createClient({ host: 'localhost', // optional port: 19132, // optional, default 19132 username: 'Notch', // the username you want to join as, optional if online mode - offline: false // optional, default false. if true, do not login with Xbox Live. You will not be asked to sign-in if set to true. + offline: false, // optional, default false. if true, do not login with Xbox Live. You will not be asked to sign-in if set to true. + // Optional for some servers which verify the title ID: + // authTitle: bedrock.title.MinecraftNintendoSwitch }) client.on('text', (packet) => { // Listen for chat messages and echo them back. diff --git a/index.d.ts b/index.d.ts index 26b764a..b08e207 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3,6 +3,8 @@ import EventEmitter from "events" declare module "bedrock-protocol" { type Version = '1.16.220' | '1.16.210' | '1.16.201' + enum title { MinecraftNintendoSwitch, MinecraftJava } + export interface Options { // The string version to start the client or server as version: number, diff --git a/index.js b/index.js index a4e2f49..e3241a5 100644 --- a/index.js +++ b/index.js @@ -9,6 +9,7 @@ const { Server } = require('./src/server') const { Relay } = require('./src/relay') const { createClient, ping } = require('./src/createClient') const { createServer } = require('./src/createServer') +const Title = require('./src/client/titles') module.exports = { Client, @@ -16,5 +17,6 @@ module.exports = { Relay, createClient, ping, - createServer + createServer, + title: Title } diff --git a/package.json b/package.json index 1022e71..ba7d7d6 100644 --- a/package.json +++ b/package.json @@ -21,15 +21,17 @@ ], "license": "MIT", "dependencies": { - "@azure/msal-node": "^1.0.0-beta.6", + "@azure/msal-node": "^1.1.0", "@xboxreplay/xboxlive-auth": "^3.3.3", "debug": "^4.3.1", + "jose-node-cjs-runtime": "^3.12.1", "jsonwebtoken": "^8.5.1", "jsp-raknet": "^2.1.0", "minecraft-folder-path": "^1.1.0", "node-fetch": "^2.6.1", "prismarine-nbt": "^1.5.0", "protodef": "extremeheat/node-protodef#patch-1", + "smart-buffer": "^4.1.0", "uuid-1345": "^1.0.2" }, "optionalDependencies": { diff --git a/src/client/auth.js b/src/client/auth.js index 2d8d30e..d7ffc8d 100644 --- a/src/client/auth.js +++ b/src/client/auth.js @@ -66,7 +66,7 @@ async function authenticatePassword (client, options) { */ async function authenticateDeviceCode (client, options) { try { - const flow = new MsAuthFlow(options.username, options.profilesFolder, options.onMsaCode) + const flow = new MsAuthFlow(options.username, options.profilesFolder, options, options.onMsaCode) const chain = await flow.getMinecraftToken(client.clientX509) // console.log('Chain', chain) diff --git a/src/client/authConstants.js b/src/client/authConstants.js index 65c2575..1f5066f 100644 --- a/src/client/authConstants.js +++ b/src/client/authConstants.js @@ -1,4 +1,10 @@ module.exports = { XSTSRelyingParty: 'https://multiplayer.minecraft.net/', - MinecraftAuth: 'https://multiplayer.minecraft.net/authentication' + MinecraftAuth: 'https://multiplayer.minecraft.net/authentication', + XboxDeviceAuth: 'https://device.auth.xboxlive.com/device/authenticate', + XboxTitleAuth: 'https://title.auth.xboxlive.com/title/authenticate', + XstsAuthorize: 'https://xsts.auth.xboxlive.com/xsts/authorize', + + LiveDeviceCodeRequest: 'https://login.live.com/oauth20_connect.srf', + LiveTokenRequest: 'https://login.live.com/oauth20_token.srf' } diff --git a/src/client/authFlow.js b/src/client/authFlow.js index 7e496ba..1f67aa2 100644 --- a/src/client/authFlow.js +++ b/src/client/authFlow.js @@ -4,7 +4,7 @@ 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') +const { LiveTokenManager, 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 @@ -30,7 +30,8 @@ async function retry (methodFn, beforeRetry, times) { } class MsAuthFlow { - constructor (username, cacheDir, codeCallback) { + constructor (username, cacheDir, options = {}, codeCallback) { + this.options = options this.initTokenCaches(username, cacheDir) this.codeCallback = codeCallback } @@ -50,14 +51,22 @@ class MsAuthFlow { } const cachePaths = { + live: path.join(cachePath, `./${hash}_live-cache.json`), msa: path.join(cachePath, `./${hash}_msa-cache.json`), xbl: path.join(cachePath, `./${hash}_xbl-cache.json`), bed: path.join(cachePath, `./${hash}_bed-cache.json`) } - const scopes = ['XboxLive.signin', 'offline_access'] - this.msa = new MsaTokenManager(msalConfig, scopes, cachePaths.msa) - this.xbl = new XboxTokenManager(authConstants.XSTSRelyingParty, cachePaths.xbl) + if (this.options.authTitle) { // Login with login.live.com + const scopes = ['service::user.auth.xboxlive.com::MBI_SSL'] + this.msa = new LiveTokenManager(this.options.authTitle, scopes, cachePaths.live) + } else { // Login with microsoftonline.com + const scopes = ['XboxLive.signin', 'offline_access'] + this.msa = new MsaTokenManager(msalConfig, scopes, cachePaths.msa) + } + + const keyPair = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }) + this.xbl = new XboxTokenManager(authConstants.XSTSRelyingParty, keyPair, cachePaths.xbl) this.mca = new MinecraftTokenManager(cachePaths.bed) } @@ -87,7 +96,11 @@ class MsAuthFlow { if (this.codeCallback) this.codeCallback(response) }) - console.info(`[msa] Signed in as ${ret.account.username}`) + if (ret.account) { + console.info(`[msa] Signed in as ${ret.account.username}`) + } else { // We don't get extra account data here per scope + console.info('[msa] Signed in with Microsoft') + } debug('[msa] got auth result', ret) return ret.accessToken @@ -96,15 +109,23 @@ class MsAuthFlow { async getXboxToken () { if (await this.xbl.verifyTokens()) { - debug('[xbl] Using existing tokens') + debug('[xbl] Using existing XSTS token') 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 + const ut = await this.xbl.getUserToken(msaToken, !this.options.authTitle) + + if (this.options.authTitle) { + const deviceToken = await this.xbl.getDeviceToken({ DeviceType: 'Nintendo', Version: '0.0.0' }) + const titleToken = await this.xbl.getTitleToken(msaToken, deviceToken) + const xsts = await this.xbl.getXSTSToken(ut, deviceToken, titleToken) + return xsts + } else { + const xsts = await this.xbl.getXSTSToken(ut) + return xsts + } }, () => { this.msa.forceRefresh = true }, 2) } } @@ -122,6 +143,12 @@ class MsAuthFlow { const xsts = await this.getXboxToken() debug('[xbl] xsts data', xsts) const token = await this.mca.getAccessToken(publicKey, xsts) + // If we want to auth with a title ID, make sure there's a TitleID in the response + const body = JSON.parse(Buffer.from(token.chain[1].split('.')[1], 'base64').toString()) + console.log(this.options.authTitle) + if (!body.extraData.titleId && this.options.authTitle) { + throw Error('missing titleId in response') + } return token.chain }, () => { this.xbl.forceRefresh = true }, 2) } diff --git a/src/client/titles.js b/src/client/titles.js new file mode 100644 index 0000000..66ca06c --- /dev/null +++ b/src/client/titles.js @@ -0,0 +1,4 @@ +module.exports = { + MinecraftNintendoSwitch: '00000000441cc96b', + MinecraftJava: '00000000402b5328' +} diff --git a/src/client/tokens.js b/src/client/tokens.js index 5dda941..f4436d6 100644 --- a/src/client/tokens.js +++ b/src/client/tokens.js @@ -5,6 +5,166 @@ const fs = require('fs') const path = require('path') const fetch = require('node-fetch') const authConstants = require('./authConstants') +const crypto = require('crypto') +const { nextUUID } = require('../datatypes/util') +const { SmartBuffer } = require('smart-buffer') +const jose = require('jose-node-cjs-runtime/jwk/from_key_like') + +class LiveTokenManager { + constructor (clientId, scopes, cacheLocation) { + this.clientId = clientId + this.scopes = scopes + this.cacheLocation = cacheLocation + this.reloadCache() + } + + reloadCache () { + try { + this.cache = require(this.cacheLocation) + } catch (e) { + this.cache = {} + fs.writeFileSync(this.cacheLocation, JSON.stringify(this.cache)) + } + } + + async verifyTokens () { + if (this.forceRefresh) try { await this.refreshTokens() } catch { } + const at = this.getAccessToken() + const rt = this.getRefreshToken() + if (!at || !rt) { + return false + } + debug('[live] have at, rt', at, rt) + if (at.valid && rt) { + return true + } else { + try { + await this.refreshTokens() + return true + } catch (e) { + console.warn('Error refreshing token', e) // TODO: looks like an error happens here + return false + } + } + } + + async refreshTokens () { + const rtoken = this.getRefreshToken() + if (!rtoken) { + throw new Error('Cannot refresh without refresh token') + } + + const codeRequest = { + method: 'post', + body: new URLSearchParams({ scope: this.scopes, client_id: this.clientId, grant_type: 'refresh_token', refresh_token: rtoken.token }).toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + credentials: 'include' // This cookie handler does not work on node-fetch ... + } + + const token = await fetch(authConstants.LiveTokenRequest, codeRequest).then(checkStatus) + this.updateCachce(token) + return token + } + + getAccessToken () { + const token = this.cache.token + if (!token) return + const until = new Date(token.obtainedOn + token.expires_in) - Date.now() + const valid = until > 1000 + return { valid, until: until, token: token.access_token } + } + + getRefreshToken () { + const token = this.cache.token + if (!token) return + const until = new Date(token.obtainedOn + token.expires_in) - Date.now() + const valid = until > 1000 + return { valid, until: until, token: token.refresh_token } + } + + updateCachce (data) { + data.obtainedOn = Date.now() + this.cache.token = data + fs.writeFileSync(this.cacheLocation, JSON.stringify(this.cache)) + } + + async authDeviceCode (deviceCodeCallback) { + const acquireTime = Date.now() + const codeRequest = { + method: 'post', + body: new URLSearchParams({ scope: this.scopes, client_id: this.clientId, response_type: 'device_code' }).toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + credentials: 'include' // This cookie handler does not work on node-fetch ... + } + + debug('Requesting live device token', codeRequest) + + const cookies = [] + + const res = await fetch(authConstants.LiveDeviceCodeRequest, codeRequest) + .then(res => { + if (res.status !== 200) { + res.text().then(console.warn) + throw Error('Failed to request live.com device code') + } + for (const cookie of Object.values(res.headers.raw()['set-cookie'])) { + const [keyval] = cookie.split(';') + cookies.push(keyval) + } + return res + }) + .then(checkStatus).then(resp => { + resp.message = `To sign in, use a web browser to open the page ${resp.verification_uri} and enter the code ${resp.user_code} to authenticate.` + deviceCodeCallback(resp) + return resp + }) + const expireTime = acquireTime + (res.expires_in * 1000) - 100 /* for safety */ + + this.polling = true + while (this.polling && expireTime > Date.now()) { + await new Promise(resolve => setTimeout(resolve, res.interval * 1000)) + try { + const verifi = { + method: 'post', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Cookie: cookies.join('; ') + }, + body: new URLSearchParams({ + client_id: this.clientId, + device_code: res.device_code, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code' + }).toString() + } + + const token = await fetch(authConstants.LiveTokenRequest + '?client_id=' + this.clientId, verifi) + .then(res => res.json()).then(res => { + if (res.error) { + if (res.error === 'authorization_pending') { + debug('[live] Still waiting:', res.error_description) + } else { + throw Error(`Failed to acquire authorization code from device token (${res.error}) - ${res.error_description}`) + } + } else { + return res + } + }) + if (!token) continue + this.updateCachce(token) + this.polling = false + return { accessToken: token.access_token } + } catch (e) { + console.debug(e) + } + } + this.polling = false + throw Error('Authenitcation failed, timed out') + } +} // Manages Microsoft account tokens class MsaTokenManager { @@ -103,7 +263,7 @@ class MsaTokenManager { } async verifyTokens () { - if (this.forceRefresh) try { await this.refreshTokens() } catch {} + if (this.forceRefresh) try { await this.refreshTokens() } catch { } const at = this.getAccessToken() const rt = this.getRefreshToken() if (!at || !rt) { @@ -149,14 +309,20 @@ class MsaTokenManager { // Manages Xbox Live tokens for xboxlive.com class XboxTokenManager { - constructor (relyingParty, cacheLocation) { + constructor (relyingParty, ecKey, cacheLocation) { this.relyingParty = relyingParty + this.key = ecKey + jose.fromKeyLike(ecKey.publicKey).then(jwk => { + this.jwk = { ...jwk, alg: 'ES256', use: 'sig' } + }) this.cacheLocation = cacheLocation || path.join(__dirname, './xbl-cache.json') try { this.cache = require(this.cacheLocation) } catch (e) { this.cache = {} } + + this.headers = { 'Cache-Control': 'no-store, must-revalidate, no-cache', 'x-xbl-contract-version': 1 } } getCachedUserToken () { @@ -209,24 +375,145 @@ class XboxTokenManager { return false } - async getUserToken (msaAccessToken) { + async getUserToken (msaAccessToken, azure) { debug('[xbl] obtaining xbox token with ms token', msaAccessToken) - if (!msaAccessToken.startsWith('d=')) { msaAccessToken = 'd=' + msaAccessToken } + msaAccessToken = (azure ? 'd=' : 't=') + 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 } - ) + // Make signature for the data being sent to server with our private key; server is sent our public key in plaintext + sign (url, authorizationToken, payload) { + // Their backend servers use Windows epoch timestamps, account for that. The server is very picky, + // bad percision or wrong epoch may fail the request. + const windowsTimestamp = (BigInt((Date.now() / 1000) | 0) + 11644473600n) * 10000000n + // Only the /uri?and-query-string + const pathAndQuery = new URL(url).pathname + + // Allocate the buffer for signature, TS, path, tokens and payload and NUL termination + const allocSize = /* sig */ 5 + /* ts */ 9 + /* POST */ 5 + pathAndQuery.length + 1 + authorizationToken.length + 1 + payload.length + 1 + const buf = SmartBuffer.fromSize(allocSize) + buf.writeInt32BE(1) // Policy Version + buf.writeUInt8(0) + buf.writeBigUInt64BE(windowsTimestamp) + buf.writeUInt8(0) // null term + buf.writeStringNT('POST') + buf.writeStringNT(pathAndQuery) + buf.writeStringNT(authorizationToken) + buf.writeStringNT(payload) + + // Get the signature from the payload + const signature = crypto.sign('SHA256', buf.toBuffer(), { key: this.key.privateKey, dsaEncoding: 'ieee-p1363' }) + + const header = SmartBuffer.fromSize(signature.length + 12) + header.writeInt32BE(1) // Policy Version + header.writeBigUInt64BE(windowsTimestamp) + header.writeBuffer(signature) // Add signature at end of header + + return header.toBuffer() + } + + // If we don't need Xbox Title Authentication, we can have xboxreplay lib + // handle the auth, otherwise we need to build the request ourselves with + // the extra token data. + async getXSTSToken (xblUserToken, deviceToken, titleToken) { + if (deviceToken && titleToken) return this.getXSTSTokenWithTitle(xblUserToken, deviceToken, titleToken) + + debug('[xbl] obtaining xsts token with xbox user token (with XboxReplay)', xblUserToken.Token) + const xsts = await XboxLiveAuth.exchangeUserTokenForXSTSIdentity(xblUserToken.Token, { XSTSRelyingParty: this.relyingParty, raw: false }) this.setCachedXstsToken(xsts) debug('[xbl] xsts', xsts) return xsts } + + async getXSTSTokenWithTitle (xblUserToken, deviceToken, titleToken, optionalDisplayClaims) { + const userToken = xblUserToken.Token + debug('[xbl] obtaining xsts token with xbox user token', userToken) + + const payload = { + RelyingParty: this.relyingParty, + TokenType: 'JWT', + Properties: { + UserTokens: [userToken], + DeviceToken: deviceToken, + TitleToken: titleToken, + OptionalDisplayClaims: optionalDisplayClaims, + ProofKey: this.jwk, + SandboxId: 'RETAIL' + } + } + + const body = JSON.stringify(payload) + const signature = this.sign(authConstants.XstsAuthorize, '', body).toString('base64') + + const headers = { ...this.headers, Signature: signature } + + const ret = await fetch(authConstants.XstsAuthorize, { method: 'post', headers, body }).then(checkStatus) + const xsts = { + userXUID: ret.DisplayClaims.xui[0].xid || null, + userHash: ret.DisplayClaims.xui[0].uhs, + XSTSToken: ret.Token, + expiresOn: ret.NotAfter + } + + this.setCachedXstsToken(xsts) + debug('[xbl] xsts', xsts) + return xsts + } + + /** + * Requests an Xbox Live-related device token that uniquely links the XToken (aka xsts token) + * @param {{ DeviceType, Version }} asDevice The hardware type and version to auth as, for example Android or Nintendo + */ + async getDeviceToken (asDevice) { + const payload = { + Properties: { + AuthMethod: 'ProofOfPossession', + Id: `{${nextUUID()}}`, + DeviceType: asDevice.DeviceType || 'Android', + SerialNumber: `{${nextUUID()}}`, + Version: asDevice.Version || '10', + ProofKey: this.jwk + }, + RelyingParty: 'http://auth.xboxlive.com', + TokenType: 'JWT' + } + + const body = JSON.stringify(payload) + + const signature = this.sign(authConstants.XboxDeviceAuth, '', body).toString('base64') + + const headers = { ...this.headers, Signature: signature } + + const ret = await fetch(authConstants.XboxDeviceAuth, { method: 'post', headers, body }).then(checkStatus) + debug('Xbox Device Token', ret) + return ret.Token + } + + // This *only* works with live.com auth + async getTitleToken (msaAccessToken, deviceToken) { + const payload = { + Properties: { + AuthMethod: 'RPS', + DeviceToken: deviceToken, + RpsTicket: 't=' + msaAccessToken, + SiteName: 'user.auth.xboxlive.com', + ProofKey: this.jwk + }, + RelyingParty: 'http://auth.xboxlive.com', + TokenType: 'JWT' + } + const body = JSON.stringify(payload) + const signature = this.sign(authConstants.XboxTitleAuth, '', body).toString('base64') + + const headers = { ...this.headers, Signature: signature } + + const ret = await fetch(authConstants.XboxTitleAuth, { method: 'post', headers, body }).then(checkStatus) + debug('Xbox Title Token', ret) + return ret.Token + } } // Manages Minecraft tokens for sessionserver.mojang.com @@ -276,16 +563,14 @@ class MinecraftTokenManager { async getAccessToken (clientPublicKey, xsts) { debug('[mc] authing to minecraft', clientPublicKey, xsts) - const getFetchOptions = { - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'node-minecraft-protocol', - Authorization: `XBL3.0 x=${xsts.userHash};${xsts.XSTSToken}` - } + const headers = { + 'Content-Type': 'application/json', + 'User-Agent': 'node-minecraft-protocol', + Authorization: `XBL3.0 x=${xsts.userHash};${xsts.XSTSToken}` } const MineServicesResponse = await fetch(authConstants.MinecraftAuth, { method: 'post', - ...getFetchOptions, + headers, body: JSON.stringify({ identityPublicKey: clientPublicKey }) }).then(checkStatus) @@ -299,8 +584,9 @@ function checkStatus (res) { if (res.ok) { // res.status >= 200 && res.status < 300 return res.json() } else { + debug('Request fail', res) throw Error(res.statusText) } } -module.exports = { MsaTokenManager, XboxTokenManager, MinecraftTokenManager } +module.exports = { LiveTokenManager, MsaTokenManager, XboxTokenManager, MinecraftTokenManager }