├── .npmignore ├── .github └── workflows │ ├── npm-test.yml │ └── npm-publish.yml ├── fordHeaders.js ├── package.json ├── README.md ├── LICENSE ├── .gitignore └── index.js /.npmignore: -------------------------------------------------------------------------------- 1 | *.tgz 2 | notes.txt 3 | /node_modules 4 | .github 5 | .vscode 6 | -------------------------------------------------------------------------------- /.github/workflows/npm-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node 2 | 3 | name: Test npm package 4 | 5 | on: [push, pull_request] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 16 15 | - run: npm ci 16 | - run: npm test 17 | -------------------------------------------------------------------------------- /fordHeaders.js: -------------------------------------------------------------------------------- 1 | // setup header objects 2 | const defaultHeaders = new Map([ 3 | ['Accept', '*/*'], 4 | ['User-Agent', 'FordPass/5 CFNetwork/1327.0.4 Darwin/21.2.0'], 5 | ['Accept-Language', 'en-US,en;q=0.9'], 6 | ['Accept-Encoding', 'gzip, deflate, br'] 7 | ]) 8 | 9 | module.exports.defaultHeaders = defaultHeaders 10 | 11 | module.exports.fordHeaders = fordHeaders = new Map([ 12 | ...defaultHeaders, 13 | ['Application-Id', "71A3AD0A-CF46-4CCF-B473-FC7FE5BC4592"] 14 | ]) 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ffpass", 3 | "version": "1.1.2", 4 | "description": "Interact with your FordPass enabled vehicle with JavaScript", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "node index.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/d4v3y0rk/ffpass-module.git" 12 | }, 13 | "author": "Dave York (https://d4v3y0rk.com)", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/d4v3y0rk/ffpass-module/issues" 17 | }, 18 | "homepage": "https://github.com/d4v3y0rk/ffpass-module#readme", 19 | "engines" : { 20 | "node" : ">=14.0.0 <15.0.0 || >=16.0.0" 21 | }, 22 | "dependencies": { 23 | "axios": "^0.21.2", 24 | "axios-cookiejar-support": "^4.0.3", 25 | "pkce-challenge": "^3.0.0", 26 | "tough-cookie": "^4.1.2" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Publish npm package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 16 18 | - run: npm ci 19 | - run: npm test 20 | 21 | publish-npm: 22 | needs: build 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | - uses: actions/setup-node@v3 27 | with: 28 | node-version: 16 29 | registry-url: https://registry.npmjs.org/ 30 | - run: npm ci 31 | - run: npm publish 32 | env: 33 | NODE_AUTH_TOKEN: ${{secrets.NPM_SECRET}} 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FFPass NPM module 2 | [![npm](https://img.shields.io/npm/v/ffpass)](https://www.npmjs.com/package/ffpass) 3 | [![build](https://github.com/d4v3y0rk/ffpass-module/actions/workflows/npm-publish.yml/badge.svg)](https://github.com/d4v3y0rk/ffpass-module/actions/workflows/npm-publish.yml) 4 | 5 | This module will allow you to control a FordPass Enabled vehicle from your javascript code. 6 | It requires a node version >=14. 7 | 8 | 9 | ## Usage 10 | 11 | `npm install ffpass --save` 12 | 13 | ```javascript 14 | const fordApi = require('ffpass') 15 | const car = new fordApi.vehicle(process.env.FORD_USERNAME, process.env.FORD_PASSWORD, process.env.VIN) 16 | 17 | async function main() { 18 | await car.auth() 19 | 20 | // to view current vehicle information including location 21 | var vehicleData = await car.status() 22 | console.log(JSON.stringify(vehicleData)) 23 | 24 | } 25 | main() 26 | ``` 27 | 28 | ## More Examples 29 | 30 | A fully functional implementation of the usage of this module can be found here: [ffpass](https://github.com/d4v3y0rk/ffpass) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Dave York 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const request = require('axios') 2 | const wrapper = require('axios-cookiejar-support') 3 | const CookieJar = require('tough-cookie') 4 | const pkceChallenge = require('pkce-challenge').default; 5 | 6 | // https://github.com/axios/axios/issues/41#issuecomment-484546457 7 | // By default, axios throws errors for http request.status < 200 || request.status >= 300 8 | // This makes sure that it does not treat said status codes as errors = rejects the promise 9 | //request.defaults.validateStatus = function () { return true; }; 10 | 11 | const { fordHeaders, defaultHeaders} = require('./fordHeaders'); 12 | const { start } = require('repl'); 13 | 14 | const fordAPIUrl = 'https://usapi.cv.ford.com' 15 | const authUrl = 'https://sso.ci.ford.com' 16 | const tokenUrl = "https://api.mps.ford.com" 17 | const client_id = "9fb503e0-715b-47e8-adfd-ad4b7770f73b" 18 | const app_id = "71A3AD0A-CF46-4CCF-B473-FC7FE5BC4592" 19 | 20 | class vehicle { 21 | constructor(username, password, vin) { 22 | this.username = username, 23 | this.password = password, 24 | this.vin = vin, 25 | this.token = "", 26 | this.outdatedAfterSeconds = 5 * 60, 27 | this.maxRefreshTrials = 20 28 | } 29 | 30 | findRegexMatch(regex, html) { 31 | const match = regex.exec(html); 32 | if (match) { 33 | return match[1]; 34 | } 35 | return undefined; 36 | } 37 | 38 | async auth() { 39 | const jar = new CookieJar.CookieJar(); 40 | const client = wrapper.wrapper(request.create({jar})); 41 | const pkce = pkceChallenge(); 42 | 43 | const webSession = await this.initializeWebSession(client, pkce.code_challenge) 44 | .then(async (authURL) => { 45 | return this.attemptLogin(authURL, this.username, this.password, client).then(async (url) => { 46 | return this.fetchAuthorizationCode(url, client).then((data) => data 47 | ); 48 | }); 49 | }) 50 | .catch(err => { 51 | throw err; 52 | }); 53 | 54 | const data = { 55 | client_id: client_id, 56 | grant_type: 'authorization_code', 57 | code: webSession.code, 58 | redirect_uri: 'fordapp://userauthorized', 59 | grant_id: webSession.grantId, 60 | code_verifier: pkce.code_verifier, 61 | }; 62 | 63 | const access_token = await this.requestAccessToken(data); 64 | this.token = access_token; 65 | return access_token; 66 | } 67 | 68 | async initializeWebSession(client, code_challenge) { 69 | const headers = Object.fromEntries(defaultHeaders); 70 | return client.get( 71 | `https://sso.ci.ford.com/v1.0/endpoint/default/authorize?redirect_uri=fordapp://userauthorized&response_type=code&scope=openid&max_age=3600&client_id=9fb503e0-715b-47e8-adfd-ad4b7770f73b&code_challenge=${code_challenge}%3D&code_challenge_method=S256`, 72 | { 73 | headers: headers, 74 | } 75 | ) 76 | .then(async res => { 77 | if (res.status === 200) { 78 | const authURL = 'https://sso.ci.ford.com' + this.findRegexMatch(/data-ibm-login-url="(.*)" /gm, res.data); 79 | if (authURL) return authURL; 80 | throw new Error('Could not find auth URL'); 81 | } 82 | throw new Error('Initialize WebSession: Unhandled success status code'); 83 | }) 84 | .catch(err => {throw err;}); 85 | } 86 | 87 | async attemptLogin(url, username, password, client) { 88 | const headers = Object.fromEntries(defaultHeaders); 89 | return client.post( 90 | url, 91 | new URLSearchParams({ 92 | operation: 'verify', 93 | 'login-form-type': 'pwd', 94 | username: username, 95 | password: password, 96 | }).toString(), 97 | { 98 | maxRedirects: 0, 99 | headers: { 100 | 'Content-Type': 'application/x-www-form-urlencoded', 101 | ...headers, 102 | }, 103 | } 104 | ) 105 | .then(() => { 106 | throw new Error('Attempt Login: Unhandled success status code'); 107 | }) 108 | .catch(err => { 109 | if (err?.response?.status === 302) { 110 | return err.response.headers.location; 111 | } 112 | throw new Error('Attempt Login: Unhandled Error Code'); 113 | }); 114 | } 115 | 116 | async fetchAuthorizationCode(url, client) { 117 | const headers = Object.fromEntries(defaultHeaders); 118 | return client.get(url, { 119 | maxRedirects: 0, 120 | headers: { 121 | 'Content-Type': 'application/x-www-form-urlencoded', 122 | ...headers, 123 | }, 124 | }) 125 | .then(() => { 126 | throw new Error('Fetch Authorization Code: Unhandled Success Code'); 127 | }) 128 | .catch(err => { 129 | if (err.response.status === 302) { 130 | const code = this.findRegexMatch(/code=(.*)&/gm, err.response.headers.location); 131 | const grantId = this.findRegexMatch(/&grant_id=(.*)/gm, err.response.headers.location); 132 | 133 | if (code && grantId) return {code, grantId}; 134 | throw new Error('Fetch Authorization Code: Missing Code or Grant ID'); 135 | } 136 | throw new Error('Fetch Authorization Code: Unhandled Error Code'); 137 | }); 138 | } 139 | 140 | async requestAccessToken(data) { 141 | const headers = Object.fromEntries(defaultHeaders); 142 | const accessToken = await request.post( 143 | `https://sso.ci.ford.com/oidc/endpoint/default/token`, 144 | new URLSearchParams(data).toString(), 145 | { 146 | headers: { 147 | ...headers, 148 | 'Content-Type': 'application/x-www-form-urlencoded', 149 | }, 150 | } 151 | ) 152 | .then(async res => { 153 | if (res.status === 200 && res.data.access_token) { 154 | return await request.post( 155 | 'https://api.mps.ford.com/api/token/v2/cat-with-ci-access-token', 156 | { 157 | ciToken: res.data.access_token, 158 | }, 159 | { 160 | headers: { 161 | ...headers, 162 | 'Content-Type': 'application/json', 163 | 'Application-Id': app_id, 164 | }, 165 | } 166 | ) 167 | .then(res => { 168 | return res.data.access_token; 169 | //return { 170 | // "access_token": res.data.access_token, 171 | // "expires_in": res.data.expires_in, 172 | // "refresh_token": res.data.refresh_token 173 | //}; 174 | }) 175 | .catch(err => { 176 | throw err; 177 | }); 178 | } else throw new Error('Access Token was not returned'); 179 | }) 180 | .catch(err => { 181 | let status = err.response.status; 182 | let message = err.message; 183 | if (err.response.data.status) status = err.response.data.status; 184 | if (err.response.data.message) message = err.response.data.message; 185 | throw new Error(message); 186 | }); 187 | return accessToken; 188 | } 189 | 190 | status() { 191 | return new Promise(async (resolve, reject) => { 192 | fordHeaders.set('auth-token', this.token) 193 | var options = { 194 | baseURL: fordAPIUrl, 195 | url: `/api/vehicles/v5/${this.vin}/status`, 196 | headers: Object.fromEntries(fordHeaders), 197 | } 198 | 199 | try { 200 | var result = await request(options) 201 | } catch (err) { 202 | console.log(err) 203 | return reject(err.result.status) 204 | } 205 | 206 | if (result.status == 200) { 207 | // Check if the last update timestamp is too old 208 | // The lastRefresh timestamp is given in UTC. In order to parse the unix time correctly 209 | // We must add a "Z" so that it gets parsed as UTC 210 | var vehicleStatus = result.data.vehiclestatus 211 | var lastUpdate = Date.parse(vehicleStatus.lastRefresh + "Z") 212 | var dateNow = Date.now() 213 | var diffInSeconds = (dateNow - lastUpdate) / 1000 214 | 215 | if (diffInSeconds > this.outdatedAfterSeconds) { 216 | console.log("Updating status!") 217 | try { 218 | vehicleStatus = await this.requestStatusRefreshSync() 219 | } catch (err) { 220 | console.log(err) 221 | return reject(err) 222 | } 223 | } 224 | 225 | return resolve(vehicleStatus) 226 | } else { 227 | return reject(result.status) 228 | } 229 | }) 230 | } 231 | 232 | issueCommand(command) { 233 | return new Promise(async (resolve, reject) => { 234 | fordHeaders.set('auth-token', this.token) 235 | var method = "" 236 | var url = "" 237 | if (command == 'start') { 238 | method = 'PUT' 239 | url = `/api/vehicles/v2/${this.vin}/engine/start` 240 | } else if (command == 'stop') { 241 | method = 'DELETE' 242 | url = `/api/vehicles/v2/${this.vin}/engine/start` 243 | } else if (command == 'lock') { 244 | method = 'PUT' 245 | url = `/api/vehicles/v2/${this.vin}/doors/lock` 246 | } else if (command == 'unlock') { 247 | method = 'DELETE' 248 | url = `/api/vehicles/v2/${this.vin}/doors/lock` 249 | } else { 250 | return reject('No command specified for issueCommand!') 251 | } 252 | var options = { 253 | method: method, 254 | baseURL: fordAPIUrl, 255 | url: url, 256 | headers: Object.fromEntries(fordHeaders), 257 | } 258 | 259 | try { 260 | var result = await request(options) 261 | } catch (err) { 262 | console.log(err) 263 | return reject(err.result.status) 264 | } 265 | 266 | if (result.status == 200) { 267 | return resolve(result.data) 268 | } else { 269 | return reject(result.status) 270 | } 271 | }) 272 | } 273 | 274 | commandStatus(command, commandId) { 275 | return new Promise(async (resolve, reject) => { 276 | var url = "" 277 | if (command == 'start' || command == 'stop') { 278 | url = `/api/vehicles/v2/${this.vin}/engine/start/${commandId}` 279 | } else if (command == 'lock' || command == 'unlock') { 280 | url = `/api/vehicles/v2/${this.vin}/doors/lock/${commandId}` 281 | } else { 282 | return reject('no command specified for commandStatus') 283 | } 284 | fordHeaders.set('auth-token', this.token) 285 | var options = { 286 | baseURL: fordAPIUrl, 287 | url: url, 288 | headers: Object.fromEntries(fordHeaders), 289 | } 290 | 291 | try { 292 | var result = await request(options) 293 | } catch (err) { 294 | console.log(err) 295 | return reject(err.result.status) 296 | } 297 | 298 | if (result.status == 200) { 299 | return resolve(result.data.status) 300 | } else { 301 | return reject(result.status) 302 | } 303 | }) 304 | } 305 | 306 | /** 307 | * Requests the Ford API to contact the vehicle for updated status data 308 | * Promise only resolves after the status was updated, an error occurred or 20 trials without success passed 309 | * @returns updated status 310 | */ 311 | requestStatusRefreshSync() { 312 | return new Promise(async (resolve, reject) => { 313 | var commandId = await this.requestStatusRefresh() 314 | fordHeaders.set('auth-token', this.token) 315 | var options = { 316 | baseURL: fordAPIUrl, 317 | url: `/api/vehicles/v5/${this.vin}/statusrefresh/${commandId}`, 318 | headers: Object.fromEntries(fordHeaders) 319 | } 320 | 321 | var api_status = 0; 322 | for (let counter = 0; counter < this.maxRefreshTrials; counter++) { 323 | try { 324 | var result = await request(options) 325 | api_status = result.data.status 326 | } catch (err) { 327 | console.log(err) 328 | } 329 | 330 | if (api_status == 200) { 331 | return resolve(result.data.vehiclestatus) 332 | } else { 333 | console.log(`Waiting for the status to refresh - sleeping for 1500ms - ${api_status}`) 334 | await new Promise((resolve_sleep) => {setTimeout(resolve_sleep, 1500);}); 335 | } 336 | } 337 | 338 | reject("Refresh failed!") 339 | }) 340 | } 341 | 342 | /** 343 | * Requests the Ford API to contact the vehicle for updated status data 344 | * Does not wait until the refreshed status data is available! Use requestStatusRefreshSync for that. 345 | * @returns commandId to track the request 346 | */ 347 | requestStatusRefresh() { 348 | return new Promise(async (resolve, reject) => { 349 | fordHeaders.set('auth-token', this.token) 350 | var options = { 351 | method: 'PUT', 352 | baseURL: fordAPIUrl, 353 | url: `/api/vehicles/v2/${this.vin}/status`, 354 | headers: Object.fromEntries(fordHeaders) 355 | } 356 | 357 | try { 358 | var result = await request(options) 359 | } catch (err) { 360 | console.log(err) 361 | reject(err.result.status) 362 | } 363 | 364 | if (result.status == 200) { 365 | return resolve(result.data.commandId) 366 | } else { 367 | return reject(result.status) 368 | } 369 | }) 370 | } 371 | } 372 | 373 | exports.vehicle = vehicle 374 | --------------------------------------------------------------------------------