From 2c5bbb7d3f68456bd2f055f575160e79dfceda7c Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 24 Dec 2015 14:38:58 +0000 Subject: [PATCH 1/5] Implement oAuth There are two ways to use oauth, by passing in a valid access token when instantiating Node-Harvest or using the oAuth flow, calling this.parseAccessCode() with an access code provided by Harvest after hitting the URL in this.getAccessTokenURL(). Example usage: ``` var harvest_with_token = new Harvest({ subdomain: config.harvest_oauth.subdomain, access_token: access_token }); var TimeTracking = harvest.TimeTracking; TimeTracking.daily({}, function(err, tasks) { if (err) throw new Error(err); console.log('Loaded tasks using passed in auth_token!'); }); ``` or ``` var harvest = new Harvest({ subdomain: config.harvest_oauth.subdomain, identifier: config.harvest_oauth.client_id, secret: config.harvest_oauth.secret, redirect_uri: config.harvest_oauth.redirect_uri }); console.log('Visit the following and paste the access code param from the url here', harvest.getAccessTokenURL()); rl.question("Code from the URL: ", function(answer) { rl.close(); harvest.parseAccessCode(answer.trim(), function(access_token) { console.log('Grabbed the access token', access_token); var TimeTracking = harvest.TimeTracking; TimeTracking.daily({}, function(err, tasks) { if (err) throw new Error(err); console.log('Loaded tasks using oauth!'); }); }) }); ``` --- index.js | 49 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/index.js b/index.js index c85a2a5a..e2eaecc4 100644 --- a/index.js +++ b/index.js @@ -12,13 +12,13 @@ module.exports = Harvest = function (opts) { throw new Error('The Harvest API client requires a subdomain'); } - this.use_oauth = (opts.identifier !== undefined && - opts.secret !== undefined); + this.use_oauth = ((opts.identifier !== undefined && opts.secret !== undefined && opts.redirect_uri !== undefined) + || (opts.access_token !== undefined)); this.use_basic_auth = (opts.email !== undefined && opts.password !== undefined); if (!this.use_oauth && !this.use_basic_auth) { - throw new Error('The Harvest API client requires credentials for basic authentication or an identifier and secret for OAuth'); + throw new Error('The Harvest API client requires credentials for basic authentication or an identifier, secret and redirect_uri (or an access_token) for OAuth'); } this.subdomain = opts.subdomain; @@ -27,6 +27,8 @@ module.exports = Harvest = function (opts) { this.password = opts.password; this.identifier = opts.identifier; this.secret = opts.secret; + this.redirect_uri = opts.redirect_uri; + this.access_token = opts.access_token || false; this.user_agent = opts.user_agent; this.debug = opts.debug || false; this.throttle_concurrency = opts.throttle_concurrency || null; @@ -38,6 +40,13 @@ module.exports = Harvest = function (opts) { baseURL: self.host }, { run: function (type, url, data) { + if(self.use_oauth) { + if(!self.access_token) { + throw new Error("An access token is required if using oAuth, use parseAccessCode or pass an access_token before making any requests"); + } + url = url + "?access_token=" + self.access_token; + } + if (self.debug) { console.log('run', type, url, data); } @@ -104,7 +113,41 @@ module.exports = Harvest = function (opts) { } }; + this.getAccessTokenURL = function() { + return this.host + + "/oauth2/authorize?client_id=" + this.identifier + + "&redirect_uri=" + encodeURIComponent(this.redirect_uri) + + "&response_type=code"; + } + + this.parseAccessCode = function(access_code, cb) { + var self = this; + this.access_code = access_code; + + var options = { + "code": this.access_code, + "client_id": this.identifier, + "client_secret": this.secret, + "redirect_uri": this.redirect_uri, + "grant_type": "authorization_code" + } + if (self.debug) { + console.log('request token', options); + } + + restler.post(this.host + '/oauth2/token', { + data: options + }).on('complete', function(response) { + if(!response.access_token) { + throw new Error("Provided access code was rejected by Harvest, no token was returned"); + } + + self.access_token = response.access_token; + + cb(self.access_token); + }); + } var Account = require('./lib/account'); var TimeTracking = require('./lib/time-tracking'); From ba88e4407bd67f7f5e213fc3790dcf01419707e1 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 24 Dec 2015 14:58:35 +0000 Subject: [PATCH 2/5] Add details on using auth to the readme --- README.md | 88 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 70 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 5a87e14f..d46b4a55 100644 --- a/README.md +++ b/README.md @@ -10,34 +10,86 @@ Harvest is a tool that enables businesses to track time, track projects, manage # Usage - var Harvest = require('harvest'), - harvest = new Harvest({ - subdomain: config.harvest.subdomain, - email: config.harvest.email, - password: config.harvest.password - }), - TimeTracking = harvest.TimeTracking; +## Basic Authentication + +```js +var Harvest = require('harvest'), + harvest = new Harvest({ + subdomain: config.harvest.subdomain, + email: config.harvest.email, + password: config.harvest.password + }), + TimeTracking = harvest.TimeTracking; + +TimeTracking.daily({}, function(err, tasks) { + if (err) throw new Error(err); + +// work with tasks +}); +``` +## OAuth Authentication + +### When you already have an access token + +```js +var harvest_with_token = new Harvest({ + subdomain: config.harvest_oauth.subdomain, + access_token: stored_access_token +}); + +var TimeTracking = harvest.TimeTracking; + +TimeTracking.daily({}, function(err, tasks) { + if (err) throw new Error(err); + + console.log('Loaded tasks using passed in auth_token!'); +}); +``` + +### To get an access token which can then be stored and used in future + +```js +// See https://platform.harvestapp.com/oauth2_clients to get these +var harvest = new Harvest({ + subdomain: config.harvest_oauth.subdomain, + redirect_uri: config.harvest_oauth.redirect_uri, + identifier: config.harvest_oauth.client_id, + secret: config.harvest_oauth.secret +}); + +// Send the user to harvest.getAccessTokenURL()) and grab the access code passed as a get parameter +// e.g. By running an express.js server at redirect_url +var access_code = req.query.code; + +harvest.parseAccessCode(access_code, function(access_token) { + console.log('Grabbed the access token to save', access_token); + + var TimeTracking = harvest.TimeTracking; TimeTracking.daily({}, function(err, tasks) { if (err) throw new Error(err); - // work with tasks + console.log('Loaded tasks using oauth!'); }); +}); +``` # Testing In order to test you will need to create a config file that uses your credentials inside `config/default.json` - { - "harvest": { - "subdomain": "...", - "email": "...", - "password": "...", - "identifier": "...", - "secret": "...", - "user_agent": "node-harvest test runner" - } - } +```js +{ + "harvest": { + "subdomain": "...", + "email": "...", + "password": "...", + "identifier": "...", + "secret": "...", + "user_agent": "node-harvest test runner" + } +} +``` This API is designed to work either using HTTP Basic authentication, or OAuth so either set of credentials will work. Subdomain is always required. From af67aa0c200baa8ea8c9c0583e354972d05def68 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 24 Dec 2015 13:47:18 +0000 Subject: [PATCH 3/5] Implement file uploads using the node request module Usage example: ``` var options = { id: config.expense_id, file: { path: 'path/to-my-image.jpeg', originalname: 'original-name-of-my-image.jpeg' } } Expenses.attachReceipt(options, function(err, reply) { if (err) throw new Error(err); console.log(reply); }); ``` --- lib/expenses.js | 61 +++++++++++++++++++++++++++++++------------------ 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/lib/expenses.js b/lib/expenses.js index 9ee51da9..cb665e94 100644 --- a/lib/expenses.js +++ b/lib/expenses.js @@ -1,4 +1,4 @@ -var Expenses, _isUndefined = require('../mixin'); +var Expenses, _isUndefined = require('../mixin'), fs = require('fs'), request = require('request'); module.exports = Expenses = function (api) { this.api = api; @@ -44,29 +44,46 @@ Expenses.prototype.delete = function (options, cb) { this.client.delete(url, {}, cb); }; -Expenses.prototype.attachReceipt = function (options, cb) { - if (_isUndefined(option, 'id')) { +Expenses.prototype.attachReceipt = function(options, cb) { + if (_isUndefined(options, 'id')) { return cb(new Error('attaching a receipt requires an id')); } - - // TODO post image data - // POST /expenses/#{expense_id}/receipt HTTP/1.1 - // User-Agent: #{Your app name here} - // Host: #{yoursubdomain}.harvestapp.com - // Accept: application/xml - // Authorization: Basic (insert your authentication string here) - // Content-Length: 47899 - // Content-Type: multipart/form-data; boundary=------------------------------b7edea381b46 - // ------------------------------b7edea381b46 - // Content-Disposition: form-data; name="expense[receipt]"; filename="hotel.jpg" - // Content-Type: image/jpeg - // - // #{ BINARY IMAGE DATA } - // - // ------------------------------b7edea381b46 - - var url = '/expenses/' + options.id + '/receipt'; - this.client.post(url, {}, cb); + if (_isUndefined(options, 'file')) { + return cb(new Error('attaching a receipt requires a file object')); + } + if (_isUndefined(options.file, 'path') || _isUndefined(options.file, 'originalname')) { + return cb(new Error('file object must have a path and an originalname')); + } + var formData = { + 'expense[receipt]': { + value: fs.createReadStream(options.file.path), + options: { + filename: options.file.originalname + } + } + }; + var options = { + url: this.api.host + '/expenses/' + options.id + "/receipt", + headers: { + 'Accept': 'application/json', + 'User-Agent': 'Expenses' + }, + formData: formData + }; + if (this.api.use_basic_auth) { + options.auth = { + user: this.api.email, + pass: this.api.password + } + } else { + return cb(new Error("File uploads are not supported by OAuth yet")); + } + request.post(options, function callback(err, httpResponse, body) { + if (err) { + return cb(err, {}); + } + return cb(null, body); + }); }; Expenses.prototype.getReceipt = function (options, cb) { From 75114c24af65d29016f0a3cb0b87e51f229ac462 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 24 Dec 2015 15:23:46 +0000 Subject: [PATCH 4/5] Add oauth support to file uploads --- lib/expenses.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/expenses.js b/lib/expenses.js index cb665e94..c00aa61b 100644 --- a/lib/expenses.js +++ b/lib/expenses.js @@ -76,7 +76,10 @@ Expenses.prototype.attachReceipt = function(options, cb) { pass: this.api.password } } else { - return cb(new Error("File uploads are not supported by OAuth yet")); + if(!this.api.access_token) { + throw new Error("An access token is required if using oAuth to upload files"); + } + options.url += "?access_token=" + this.api.access_token; } request.post(options, function callback(err, httpResponse, body) { if (err) { From 056a7c2efbe3f96d1cd85099cf669bfd00456887 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 24 Dec 2015 16:53:09 +0000 Subject: [PATCH 5/5] Fix typo in Expenses.get --- lib/expenses.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/expenses.js b/lib/expenses.js index c00aa61b..3e575bbd 100644 --- a/lib/expenses.js +++ b/lib/expenses.js @@ -11,7 +11,7 @@ Expenses.prototype.list = function (options, cb) { }; Expenses.prototype.get = function (options, cb) { - if (_isUndefined(option, 'id')) { + if (_isUndefined(options, 'id')) { return cb(new Error('retrieving a single exspense requires an id')); }