Support xbox title + live.com auth (#86)

* preliminary support for xbox title + live.com auth

* cleanup

* export title list

* add to api docs

* Verify that minecraft token has titleId if did titleAuth

* Minor changes
This commit is contained in:
extremeheat 2021-05-19 09:53:55 -04:00 committed by GitHub
commit 76febb29f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 371 additions and 34 deletions

View file

@ -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 => {

View file

@ -13,6 +13,7 @@ Returns a `Client` instance and connects to the server.
| version | *optional* | Version to connect as. <br/>(Future feature, see [#69][1]) If not specified, should automatically match server version. <br/>(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. |

View file

@ -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"
```
```
## Kicked during login
Some servers can kick you if you don't set `authTitle` as explained in the README.

View file

@ -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.

2
index.d.ts vendored
View file

@ -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,

View file

@ -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
}

View file

@ -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": {

View file

@ -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)

View file

@ -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'
}

View file

@ -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)
}

4
src/client/titles.js Normal file
View file

@ -0,0 +1,4 @@
module.exports = {
MinecraftNintendoSwitch: '00000000441cc96b',
MinecraftJava: '00000000402b5328'
}

View file

@ -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 }