diff --git a/lib/bmw.js b/lib/bmw.js index 8f5f476..8a35df3 100644 --- a/lib/bmw.js +++ b/lib/bmw.js @@ -25,10 +25,11 @@ SOFTWARE. 'use strict' +const { randomUUID, createHash } = require('crypto'); +const { URLSearchParams } = require('url'); const https = require('https'); const debug = require('debug')('bmw'); const fetch = require('node-fetch'); -const querystring = require('querystring'); const STATE_LOGGED_OUT = 0; @@ -114,7 +115,7 @@ class Bmw { static _getUnitFormat(unit){ switch (unit){ case Bmw.UNIT_IMPERIAL: return "d=MI;v=G"; - default: return "d=KM;v=L"; + default: return "d=KM;v=L;p=B;ec=KWH100KM;fc=L100KM;em=GKM;"; } } @@ -143,6 +144,12 @@ class Bmw { } } + static _getAPIKeys(region) { + switch (region) { + case Bmw.REGION_USA: return 'MzFlMTAyZjUtNmY3ZS03ZWYzLTkwNDQtZGRjZTYzODkxMzYy'; + default: return 'NGYxYzg1YTMtNzU4Zi1hMzdkLWJiYjYtZjg3MDQ0OTRhY2Zh'; + } + } /** * Helper function which generates a random string. @@ -189,6 +196,53 @@ class Bmw { return tags['__json'] || tags; } + /** + * Returns the correlation ID headers. + * @returns {Object} header object + */ + static _correlationIdHeader() { + let id = randomUUID(); + return { + 'X-Identity-Provider': 'gcdm', + 'X-Correlation-Id': id, + 'Bmw-Correlation-Id': id, + }; + } + + /** + * Returns the user agent headers for all request types. + * @returns {Object} header object + */ + static _userAgentHeader() { + const androidVersion = 'android(TQ2A.230405.003.B2)'; + const agentVersion = '3.11.1(29513)'; + const ua = 'Dart/3.0 (dart:io)'; + return { + 'User-Agent': ua, + 'X-User-Agent': `${androidVersion};bmw;${agentVersion}`, + }; + } + + /** + * Create S256 code_challenge with the given code_verifier. + * @param {string} code_verifier + * @returns {Object} the S256 challenge url data object + */ + static _codeChallengeParams(code_verifier) { + return { + 'code_challenge': createHash('sha256').update(code_verifier).digest('base64url'), + 'code_challenge_method': 'S256', + }; + } + + /** + * Creates URLSearchParams from one or more objects. + * @param {...any} objects param objects + * @returns {URLSearchParams} of the merged objects list + */ + static _params(...objects) { + return new URLSearchParams(Object.assign({}, ...objects)); + } /** * Code to request an authentication token. @@ -196,42 +250,63 @@ class Bmw { * https://github.com/bluewalk/BMWConnecteDrive/blob/master/ConnectedDrive.php * * @static - * @param {string} hostname - The BMW server address providing the API. There are different servers for different regions. + * @param {string} region - The auth region * @param {string} username - The username to authenticate against. Id of the connected-drive account. * @param {string} password - The password of the username. * @returns {Promise.} A promise that returns error resolves on successfull authentication. * @memberof Bmw */ - static async _authenticate(hostname, username, password) { + static async _authenticate(region, username, password) { + // + // State 0 - Get the oauth config + // + let result0 = await fetch(`https://${Bmw._getApiServerNew(region)}/eadrax-ucs/v1/presentation/oauth/config`, { + headers: Object.assign({ + 'ocp-apim-subscription-key': Buffer.from(Bmw._getAPIKeys(region), 'base64').toString('ascii'), + 'bmw-session-id': randomUUID(), + }, Bmw._correlationIdHeader(), Bmw._userAgentHeader()), + }); + + if (result0.status != 200) { + throw new Error(`Server sent http statusCode ${result0.status} on stage 0`); + } + + const config = await result0.json(); + if (!config.tokenEndpoint) { + debug(JSON.stringify(config)) + throw new Error(`Missing tokenEndpoint on stage 0`); + } - const client_id = '31c357a0-7a1d-4590-aa99-33b97244d048'; - const client_password = 'c0e3393d-70a2-4f6f-9d3c-8530af64d552'; // - // Stage 1 - Request authorization code + // Parameters for stages 1-3 // + const headers = Object.assign({'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'}, Bmw._userAgentHeader()); + const token_url = config.tokenEndpoint; + const authenticate_url = token_url.replace("/token", "/authenticate"); + const code_challenge = Bmw._randomString(86); - const state = Bmw._randomString(22); + const oauth_params = Object.assign({ + 'client_id': config.clientId, + 'response_type': 'code', + 'scope': config.scopes.join(' '), + 'redirect_uri': config.returnUrl, + 'state': Bmw._randomString(22), + 'nonce': Bmw._randomString(22), + }, Bmw._codeChallengeParams(code_challenge)); - let result1 = await fetch(`https://${hostname}/gcdm/oauth/authenticate`, { + + // + // Stage 1 - Request authorization code + // + let result1 = await fetch(authenticate_url, { method: "POST", - body: new URLSearchParams({ - 'client_id': client_id, - 'response_type': 'code', - 'scope': 'openid profile email offline_access smacc vehicle_data perseus dlm svds cesim vsapi remote_services fupo authenticate_user', - 'redirect_uri': 'com.bmw.connected://oauth', - 'state': state, - 'nonce': 'login_nonce', - 'code_challenge': code_challenge, - 'code_challenge_method': 'plain', + body: Bmw._params({ 'username': username, 'password': password, 'grant_type': 'authorization_code' - }), - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Mobile/15E148 Safari/604.1', - } + }, oauth_params), + headers: headers, }); if (result1.status < 200 || result1.status > 299) { @@ -253,29 +328,15 @@ class Bmw { // // Stage 2 - No idea, it's required to get the code // - let result2 = await fetch(`https://${hostname}/gcdm/oauth/authenticate`, { + let result2 = await fetch(authenticate_url, { method: 'POST', - body: new URLSearchParams({ - 'client_id': client_id, - 'response_type': 'code', - 'scope': 'openid profile email offline_access smacc vehicle_data perseus dlm svds cesim vsapi remote_services fupo authenticate_user', - 'redirect_uri': 'com.bmw.connected://oauth', - 'state': state, - 'nonce': 'login_nonce', - 'code_challenge': code_challenge, - 'code_challenge_method': 'plain', - 'authorization': authorization - }), - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Mobile/15E148 Safari/604.1', - 'Cookie': `GCDMSSO=${authorization}` - }, - redirect: 'manual' + body: Bmw._params({'authorization': authorization}, oauth_params), + headers: Object.assign({'Cookie': `GCDMSSO=${authorization}`}, headers), + redirect: 'manual', }); if (result2.status != 302) { - throw new Error(`Server send http statusCode ${result1.status} on stage 2`); + throw new Error(`Server send http statusCode ${result2.status} on stage 2`); } // Extract 'code' from response header @@ -293,19 +354,17 @@ class Bmw { // // Stage 3 - Get Token // - let result3 = await fetch(`https://${hostname}/gcdm/oauth/token`, { + let result3 = await fetch(token_url, { method: 'POST', - body: new URLSearchParams({ + body: Bmw._params({ 'code': code, 'code_verifier': code_challenge, - 'redirect_uri': 'com.bmw.connected://oauth', + 'redirect_uri': config.returnUrl, 'grant_type': 'authorization_code' }), - headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - 'Authorization': 'Basic ' + Buffer.from(`${client_id}:${client_password}`).toString('base64') - }, - redirect: 'manual', + headers: Object.assign({ + 'Authorization': 'Basic ' + Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64') + }, headers), }); if (result1.status < 200 || result1.status > 299) { @@ -321,7 +380,7 @@ class Bmw { * Query data from the connected drive service. * * @param {string} hostname - The BMW server address providing the API. (e.g. 'www.bmw-connecteddrive.com') - * @param {string} path - The API endpoint. + * @param {string|Array} path - The API endpoint as '/path' or {path: '/path', method: 'POST'} * @param {string} token - An access token. * @param {string} tokenType - The type of the token. Usually "Bearer". * @param {function} callback(err, data) - Returns error or requested data. @@ -329,16 +388,22 @@ class Bmw { */ static _request(hostname, path, headers, token, tokenType, callback) { - let options = { + path = { + method: path.method || 'GET', + path: path.path || path, + }; + + const options = Object.assign({ hostname: hostname, port: '443', - path: path, - method: 'GET', - headers: { - 'x-user-agent': 'android(SP1A.210812.016.C1);bmw;2.5.2(14945)', - 'Authorization': tokenType + " " + token - } - }; + headers: Object.assign({ + 'Authorization': tokenType + " " + token, + 'Accept': 'application/json', + 'Accept-Language': 'en', + 'X-Raw-Locale': 'en_US', + '24-hour-format': 'true', + }, Bmw._userAgentHeader(), Bmw._correlationIdHeader()) + }, path); if (headers) { Object.assign(options.headers, headers); @@ -383,17 +448,21 @@ class Bmw { */ static _execute(hostname, path, token, tokenType, queryParameters, payloadBody, callback) { - let query = queryParameters != null ? querystring.stringify(queryParameters) : null; + let query = queryParameters != null ? `?${Bmw._params(queryParameters)}` : ''; // when no payload body is provided then query parameters are sent also in the payload (backward compatibility) let payload = payloadBody != null ? payloadBody : (queryParameters || null); let options = { hostname: hostname, port: '443', - path: path + (query ? "?" + query : ''), + path: path + query, method: 'POST', - headers: { - 'Authorization': tokenType + " " + token - } + headers: Object.assign({ + 'Authorization': tokenType + " " + token, + 'Accept': 'application/json', + 'Accept-Language': 'en', + 'X-Raw-Locale': 'en_US', + '24-hour-format': 'true', + }, Bmw._userAgentHeader(), Bmw._correlationIdHeader()) }; let postData; @@ -471,7 +540,7 @@ class Bmw { this._state = STATE_AUTHENTICATING; - Bmw._authenticate(Bmw._getAuthServer(this._region), this._username, this._password).then((data) => { + Bmw._authenticate(this._region, this._username, this._password).then((data) => { // Error: Content not as expected if(typeof(data.token_type) === 'undefined' || typeof(data.access_token) === 'undefined' || typeof(data.expires_in) === 'undefined') { @@ -568,6 +637,7 @@ class Bmw { let headers = {}; if (vin) { headers['bmw-vin'] = vin; + headers['bmw-current-date'] = new Date().toISOString(); } if (this._unit) { headers['bmw-units-preferences'] = Bmw._getUnitFormat(this._unit); @@ -669,7 +739,7 @@ class Bmw { path = `/eadrax-crccs/v1/vehicles/${vin}/${service}`; break; default: - path = `/eadrax-vrccs/v2/presentation/remote-commands/${vin}/${service}`; + path = `/eadrax-vrccs/v3/presentation/remote-commands/${vin}/${service}`; break; } @@ -707,6 +777,18 @@ class Bmw { }); } + /** + * Returns the default URL params for V4 and V5 services. + * @returns {URLSearchParams} + */ + static _defaultGetParams(...others) { + let now = new Date(); + return Bmw._params({ + 'apptimezone': now.getTimezoneOffset() * -1, + 'appDateTime': now.getTime(), + }, ...others); + } + /** * Retrieve the status of a remote service execution. * @@ -717,7 +799,8 @@ class Bmw { * @memberof Bmw */ async queryEventStatus(eventId) { - let path = `/eadrax-vrccs/v2/presentation/remote-commands/eventStatus?eventId=${eventId}`; + let params = Bmw._params({'eventId': eventId}); + let path = `/eadrax-vrccs/v3/presentation/remote-commands/eventStatus?${params}`; // TODO } @@ -729,7 +812,12 @@ class Bmw { * @memberof Bmw */ async getCarList() { - return this.get(undefined, Bmw._getApiServerNew(this._region), `/eadrax-vcs/v4/vehicles?apptimezone=0&appDateTime=${new Date().getTime()}&tireGuardMode=ENABLED`); + return this + .get(undefined, Bmw._getApiServerNew(this._region), { + method: 'POST', + path: `/eadrax-vcs/v5/vehicle-list?${Bmw._defaultGetParams()}` + }) + .then(carList => carList.mappingInfos); } /** @@ -741,22 +829,37 @@ class Bmw { * @memberof Bmw */ async getCarInfo(vin, service) { + let params; switch (service) { // new services ('my BMW') case Bmw.GET_CHARGING_STATISTICS: - return this.get(undefined, Bmw._getApiServerNew(this._region), `/eadrax-chs/v1/charging-statistics?vin=${vin}¤tDate=${encodeURIComponent(new Date().toISOString())}`); + params = Bmw._params({ + 'vin': vin, + 'currentDate': new Date().toISOString() + }); + return this.get(undefined, Bmw._getApiServerNew(this._region), `/eadrax-chs/v1/charging-statistics?${params}`); case Bmw.GET_CHARGING_SESSIONS: - return this.get(undefined, Bmw._getApiServerNew(this._region), `/eadrax-chs/v1/charging-sessions?vin=${vin}&maxResults=40&include_date_picker=true`); + params = Bmw._params({ + 'vin': vin, + 'maxResults': 40, + 'include_date_picker': true + }); + return this.get(undefined, Bmw._getApiServerNew(this._region), `/eadrax-chs/v1/charging-sessions?${params}`); case Bmw.GET_CHARGING_PROFILE: - return this.get(vin, Bmw._getApiServerNew(this._region), '/eadrax-crccs/v2/vehicles?fields=charging-profile&has_charging_settings_capabilities=True'); + params = Bmw._params({ + 'fields': 'charging-profile', + 'has_charging_settings_capabilities': true // should match capabilities from GET_STATE + }); + return this.get(vin, Bmw._getApiServerNew(this._region), `/eadrax-crccs/v2/vehicles?${params}`); // v4 service case Bmw.GET_STATE: - return this.get(vin, Bmw._getApiServerNew(this._region), `/eadrax-vcs/v4/vehicles/state?apptimezone=0&appDateTime=${new Date().getTime()}&tireGuardMode=ENABLED`); + params = Bmw._defaultGetParams({'tireGuardMode': 'ENABLED'}); + return this.get(vin, Bmw._getApiServerNew(this._region), `/eadrax-vcs/v4/vehicles/state?${params}`); // 'discontinued' services case Bmw.GET_STATISTICS_ALL_TRIPS: diff --git a/test/develop.js b/test/develop.js index 7ca253d..c3d9eab 100644 --- a/test/develop.js +++ b/test/develop.js @@ -77,15 +77,10 @@ async function testReadAll() { // Print the different car infos var list = [ - Bmw.GET_DYNAMIC, - Bmw.GET_SPECS, - Bmw.GET_NAVIGATION, - Bmw.GET_EFFICIENCY, + Bmw.GET_STATE, Bmw.GET_CHARGING_PROFILE, - Bmw.GET_SERVICE, - Bmw.GET_SERVICE_PARTNER, - Bmw.GET_STATISTICS_ALL_TRIPS, - Bmw.GET_STATISTICS_LAST_TRIP + //Bmw.GET_CHARGING_STATISTICS, + //Bmw.GET_CHARGING_SESSIONS, ]; for (let key in list) { @@ -94,7 +89,7 @@ async function testReadAll() { let data = await bmw.getCarInfo(getVin(), list[key]); console.log(JSON.stringify(data)); } catch (error) { - console.log('FAILED'); + console.log('FAILED', error); } } @@ -127,7 +122,7 @@ async function testSimple() { await bmw.requestNewToken(); console.log("Successfully authenticated"); - let data = await bmw.getCarInfo(getVin(), Bmw.GET_STATISTICS_LAST_TRIP); + let data = await bmw.getCarInfo(getVin(), Bmw.GET_STATE); console.log(JSON.stringify(data)); } catch (err) { @@ -143,7 +138,7 @@ async function develop() { await bmw.requestNewToken(); console.log("Successfully authenticated"); - let data = await bmw.executeRemoteService(getVin(), Bmw.SERVICE_FLASH_HEADLIGHTS); + let data = await bmw.executeRemoteService(getVin(), Bmw.SERVICE_DOOR_LOCK); console.log(JSON.stringify(data)); } catch (err) {