diff --git a/README.md b/README.md index 877a1b361..f31ec4667 100644 --- a/README.md +++ b/README.md @@ -442,6 +442,13 @@ the following changes to the OAuth options object: * Instead of `consumer_secret`, specify a `private_key` string in [PEM format](http://how2ssl.com/articles/working_with_pem_files/) +To send OAuth parameters via query params or in a post body as described in The +[Consumer Request Parameters](http://oauth.net/core/1.0/#consumer_req_param) +section of the oauth1 spec: +* Pass `transport_method : 'query'` or `transport_method : 'body'` in the OAuth + options object. +* `transport_method` defaults to `'header'` + ## Custom HTTP Headers HTTP Headers, such as `User-Agent`, can be set in the `options` object. diff --git a/request.js b/request.js index 5e3b6db01..97e07e667 100644 --- a/request.js +++ b/request.js @@ -1635,17 +1635,27 @@ Request.prototype.hawk = function (opts) { Request.prototype.oauth = function (_oauth) { var self = this - var form, query + var form, query, contentType = '', formContentType = 'application/x-www-form-urlencoded' + if (self.hasHeader('content-type') && - self.getHeader('content-type').slice(0, 'application/x-www-form-urlencoded'.length) === - 'application/x-www-form-urlencoded' - ) { + self.getHeader('content-type').slice(0, formContentType.length) === formContentType) { + contentType = formContentType form = self.body } if (self.uri.query) { query = self.uri.query } + var transport = _oauth.transport_method || 'header' + if (transport === 'body' && ( + self.method !== 'POST' || contentType !== formContentType)) { + + throw new Error('oauth.transport_method of \'body\' requires \'POST\' ' + + 'and content-type \'' + formContentType + '\'') + } + + delete _oauth.transport_method + var oa = {} for (var i in _oauth) { oa['oauth_' + i] = _oauth[i] @@ -1683,11 +1693,27 @@ Request.prototype.oauth = function (_oauth) { consumer_secret_or_private_key, token_secret) - var realm = _oauth.realm ? 'realm="' + _oauth.realm + '",' : '' - var authHeader = 'OAuth ' + realm + - Object.keys(oa).sort().map(function (i) {return i + '="' + oauth.rfc3986(oa[i]) + '"'}).join(',') - authHeader += ',oauth_signature="' + oauth.rfc3986(signature) + '"' - self.setHeader('Authorization', authHeader) + var buildSortedParams = function (sep, wrap) { + wrap = wrap || '' + return Object.keys(oa).sort().map(function (i) { + return i + '=' + wrap + oauth.rfc3986(oa[i]) + wrap + }).join(sep) + sep + 'oauth_signature=' + wrap + oauth.rfc3986(signature) + wrap + } + + if (transport === 'header') { + var realm = _oauth.realm ? 'realm="' + _oauth.realm + '",' : '' + self.setHeader('Authorization', 'OAuth ' + realm + buildSortedParams(',', '"')) + } + else if (transport === 'query') { + self.path += (query ? '&' : '?') + buildSortedParams('&') + } + else if (transport === 'body') { + self.body = (form ? form + '&' : '') + buildSortedParams('&') + } + else { + throw new Error('oauth.transport_method invalid') + } + return self } Request.prototype.jar = function (jar) { diff --git a/tests/test-oauth.js b/tests/test-oauth.js index d0c6ed8e0..ce12dad76 100644 --- a/tests/test-oauth.js +++ b/tests/test-oauth.js @@ -301,3 +301,212 @@ tape('rfc5849 RSA example', function(t) { t.end() }) }) + +tape('invalid transport_method', function(t) { + t.throws( + function () { + request.post( + { url: 'http://example.com/' + , oauth: + { transport_method: 'some random string' + } + }) + }, /transport_method invalid/) + + t.throws( + function () { + request.post( + { url: 'http://example.com/' + , oauth: + { transport_method: 'headerquery' + } + }) + }, /transport_method invalid/) + t.end() +}) + +tape('invalid method while using transport_method \'body\'', function(t) { + t.throws( + function () { + request.get( + { url: 'http://example.com/' + , headers: { 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' } + , oauth: + { transport_method: 'body' + } + }) + }, /requires 'POST'/) + t.end() +}) + +tape('invalid content-type while using transport_method \'body\'', function(t) { + t.throws( + function () { + request.post( + { url: 'http://example.com/' + , headers: { 'content-type': 'application/json; charset=UTF-8' } + , oauth: + { transport_method: 'body' + } + }) + }, /requires 'POST'/) + t.end() +}) + +tape('query transport_method simple url', function(t) { + var r = request.post( + { url: 'https://api.twitter.com/oauth/access_token' + , oauth: + { consumer_key: 'GDdmIQH6jhtmLUypg82g' + , nonce: '9zWH6qe0qG7Lc1telCn7FhUbLyVdjEaL3MO5uHxn8' + , signature_method: 'HMAC-SHA1' + , token: '8ldIZyxQeVrFZXFOZH5tAwj6vzJYuLQpl0WUEYtWc' + , timestamp: '1272323047' + , verifier: 'pDNg57prOHapMbhv25RNf75lVRd6JDsni1AJJIDYoTY' + , version: '1.0' + , consumer_secret: 'MCD8BKwGdgPHvAuvgvz4EQpqDAtx89grbuNMRd7Eh98' + , token_secret: 'x6qpRnlEmW9JbQn4PQVVeVG8ZLPEx6A0TOebgwcuA' + , transport_method: 'query' + } + }) + + process.nextTick(function() { + t.notOk(r.headers.Authorization, 'oauth Authorization header should not be present with transport_method \'query\'') + t.equal(accsign, qs.parse(r.path).oauth_signature) + t.notOk(r.path.match(/\?&/), 'there should be no ampersand at the beginning of the query') + r.abort() + t.end() + }) +}) + +tape('query transport_method with prexisting url params', function(t) { + var r = request.post( + { url: 'http://example.com/request?b5=%3D%253D&a3=a&c%40=&a2=r%20b' + , oauth: + { consumer_key: '9djdj82h48djs9d2' + , nonce: '7d8f3e4a' + , signature_method: 'HMAC-SHA1' + , token: 'kkk9d7dh3k39sjv7' + , timestamp: '137131201' + , consumer_secret: 'j49sk3j29djd' + , token_secret: 'dh893hdasih9' + , realm: 'Example' + , transport_method: 'query' + } + , form: { + c2: '', + a3: '2 q' + } + }) + + process.nextTick(function() { + t.notOk(r.headers.Authorization, 'oauth Authorization header should not be present with transport_method \'query\'') + t.notOk(r.path.match(/\?&/), 'there should be no ampersand at the beginning of the query') + t.equal('OB33pYjWAnf+xtOHN4Gmbdil168=', qs.parse(r.path).oauth_signature) + r.abort() + t.end() + }) +}) + +tape('query transport_method with qs parameter and existing query string in url', function(t) { + var r = request.post( + { url: 'http://example.com/request?a2=r%20b' + , oauth: + { consumer_key: '9djdj82h48djs9d2' + , nonce: '7d8f3e4a' + , signature_method: 'HMAC-SHA1' + , token: 'kkk9d7dh3k39sjv7' + , timestamp: '137131201' + , consumer_secret: 'j49sk3j29djd' + , token_secret: 'dh893hdasih9' + , realm: 'Example' + , transport_method: 'query' + } + , qs: { + b5: '=%3D', + a3: ['a', '2 q'], + 'c@': '', + c2: '' + } + }) + + process.nextTick(function() { + t.notOk(r.headers.Authorization, 'oauth Authorization header should not be present with transport_method \'query\'') + t.notOk(r.path.match(/\?&/), 'there should be no ampersand at the beginning of the query') + t.equal('OB33pYjWAnf+xtOHN4Gmbdil168=', qs.parse(r.path).oauth_signature) + var matches = r.path.match(/\?(.*?)&(oauth.*$)/) + t.ok(matches, 'regex to split oauth parameters from qs parameters matched successfully') + var qsParams = qs.parse(matches[1]) + var oauthParams = qs.parse(matches[2]) + + var i, paramNames = ['a2', 'a3[0]', 'a3[1]', 'c@', 'b5', 'c2'] + for (i = 0; i < paramNames.length; i++) { + t.ok(qsParams.hasOwnProperty(paramNames[i]), 'Non-oauth query params should be first in query string: ' + paramNames[i]) + } + + paramNames = ['consumer_key', 'nonce', 'timestamp', 'version', 'signature', 'token', 'signature_method'] + for (i = 0; i < paramNames.length; i++) { + var paramName = 'oauth_' + paramNames[i] + t.ok(oauthParams[paramName], 'OAuth query params should be included after request specific parameters: ' + paramName) + } + + r.abort() + t.end() + }) +}) + +tape('body transport_method empty body', function(t) { + var r = request.post( + { url: 'https://api.twitter.com/oauth/access_token' + , headers: { 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8' } + , oauth: + { consumer_key: 'GDdmIQH6jhtmLUypg82g' + , nonce: '9zWH6qe0qG7Lc1telCn7FhUbLyVdjEaL3MO5uHxn8' + , signature_method: 'HMAC-SHA1' + , token: '8ldIZyxQeVrFZXFOZH5tAwj6vzJYuLQpl0WUEYtWc' + , timestamp: '1272323047' + , verifier: 'pDNg57prOHapMbhv25RNf75lVRd6JDsni1AJJIDYoTY' + , version: '1.0' + , consumer_secret: 'MCD8BKwGdgPHvAuvgvz4EQpqDAtx89grbuNMRd7Eh98' + , token_secret: 'x6qpRnlEmW9JbQn4PQVVeVG8ZLPEx6A0TOebgwcuA' + , transport_method: 'body' + } + }) + + process.nextTick(function() { + t.notOk(r.headers.Authorization, 'oauth Authorization header should not be present with transport_method \'body\'') + t.equal(accsign, qs.parse(r.body.toString()).oauth_signature) + t.notOk(r.body.toString().match(/^&/), 'there should be no ampersand at the beginning of the body') + r.abort() + t.end() + }) +}) + +tape('body transport_method with prexisting body params', function(t) { + var r = request.post( + { url: 'http://example.com/request?b5=%3D%253D&a3=a&c%40=&a2=r%20b' + , oauth: + { consumer_key: '9djdj82h48djs9d2' + , nonce: '7d8f3e4a' + , signature_method: 'HMAC-SHA1' + , token: 'kkk9d7dh3k39sjv7' + , timestamp: '137131201' + , consumer_secret: 'j49sk3j29djd' + , token_secret: 'dh893hdasih9' + , realm: 'Example' + , transport_method: 'body' + } + , form: { + c2: '', + a3: '2 q' + } + }) + + process.nextTick(function() { + t.notOk(r.headers.Authorization, 'oauth Authorization header should not be present with transport_method \'body\'') + t.notOk(r.body.toString().match(/^&/), 'there should be no ampersand at the beginning of the body') + t.equal('OB33pYjWAnf+xtOHN4Gmbdil168=', qs.parse(r.body.toString()).oauth_signature) + r.abort() + t.end() + }) +})