316 lines
11 KiB
JavaScript
Executable File
316 lines
11 KiB
JavaScript
Executable File
var parseUrl = require("url").parse;
|
|
var querystring = require("./util/querystring");
|
|
var whenPromise = require("./promise").whenPromise;
|
|
var makeRequest = require("./http-client").request;
|
|
var crypto = require("crypto");
|
|
|
|
function encodeRfc3986(str){
|
|
return !str ? "" : encodeURIComponent(str)
|
|
.replace(/\!/g, "%21")
|
|
.replace(/\'/g, "%27")
|
|
.replace(/\(/g, "%28")
|
|
.replace(/\)/g, "%29")
|
|
.replace(/\*/g, "%2A");
|
|
}
|
|
|
|
function parseResponse(response){
|
|
return response.body.join("").then(function(body){
|
|
if(response.status == 200){
|
|
return querystring.parse(body);
|
|
}else{
|
|
var err = new Error(response.status + ": " + body);
|
|
err.status = response.status;
|
|
err.headers = response.headers;
|
|
err.body = body;
|
|
throw err;
|
|
}
|
|
});
|
|
}
|
|
|
|
exports.Client = Client;
|
|
function Client(identifier, secret, tempRequestUrl, tokenRequestUrl, callback, version, signatureMethod, nonceGenerator, headers){
|
|
this.identifier = identifier;
|
|
this.tempRequestUrl = tempRequestUrl;
|
|
this.tokenRequestUrl = tokenRequestUrl;
|
|
this.callback = callback;
|
|
this.version = version || false;
|
|
// _createSignature actually uses the variable, not the instance property
|
|
this.signatureMethod = signatureMethod = signatureMethod || "HMAC-SHA1";
|
|
this.generateNonce = nonceGenerator || Client.makeNonceGenerator(32);
|
|
this.headers = headers || Client.Headers;
|
|
|
|
if(this.signatureMethod != "PLAINTEXT" && this.signatureMethod != "HMAC-SHA1"){
|
|
throw new Error("Unsupported signature method: " + this.signatureMethod);
|
|
}
|
|
|
|
// We don't store the secrets on the instance itself, that way it can
|
|
// be passed to other actors without leaking
|
|
secret = encodeRfc3986(secret);
|
|
this._createSignature = function(tokenSecret, baseString){
|
|
if(baseString === undefined){
|
|
baseString = tokenSecret;
|
|
tokenSecret = "";
|
|
}
|
|
|
|
var key = secret + "&" + tokenSecret;
|
|
if(signatureMethod == "PLAINTEXT"){
|
|
return key;
|
|
}else{
|
|
return crypto.createHmac("SHA1", key).update(baseString).digest("base64");
|
|
}
|
|
};
|
|
}
|
|
|
|
Client.Headers = {
|
|
Accept: "*/*",
|
|
Connection: "close",
|
|
"User-Agent": "promised-io/oauth"
|
|
};
|
|
// The default headers shouldn't change after clients have been created,
|
|
// but you're free to replace the object or pass headers to the Client
|
|
// constructor.
|
|
Object.freeze(Client.Headers);
|
|
|
|
Client.NonceChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
Client.makeNonceGenerator = function(nonceSize){
|
|
var nonce = Array(nonceSize + 1).join("-").split("");
|
|
var chars = Client.NonceChars.split("");
|
|
|
|
return function nonceGenerator(){
|
|
return nonce.map(getRandomChar).join("");
|
|
};
|
|
|
|
function getRandomChar(){
|
|
return chars[Math.floor(Math.random() * chars.length)];
|
|
}
|
|
};
|
|
|
|
Client.getTimestamp = function(){
|
|
return Math.floor(Date.now() / 1000).toString();
|
|
};
|
|
|
|
// Binds the client against a set of token credentials.
|
|
// The resulting object can be used to make signed requests.
|
|
// The secret won't be exposed on the object itself.
|
|
Client.prototype.bind = function(tokenIdentifier, tokenSecret){
|
|
var bound = {
|
|
identifier: this.identifier,
|
|
tokenIdentifier: tokenIdentifier,
|
|
signatureMethod: this.signatureMethod,
|
|
version: this.version,
|
|
headers: this.headers,
|
|
generateNonce: this.generateNonce
|
|
};
|
|
bound._createSignature = this._createSignature.bind(this, encodeRfc3986(tokenSecret));
|
|
bound._createSignatureBase = this._createSignatureBase;
|
|
bound._normalizeUrl = this._normalizeUrl;
|
|
bound._collectOAuthParams = this._collectOAuthParams;
|
|
bound._normalizeParams = this._normalizeParams;
|
|
bound._signRequest = this._signRequest;
|
|
bound.request = this.request;
|
|
return bound;
|
|
};
|
|
|
|
// Wrapper for `http-client.request` which signs the request
|
|
// with the client credentials, and optionally token credentials if bound.
|
|
Client.prototype.request = function(originalRequest){
|
|
var request = {};
|
|
for(var key in originalRequest){
|
|
if(originalRequest.hasOwnProperty(key)){
|
|
request[key] = originalRequest[key];
|
|
}
|
|
}
|
|
|
|
// Normalize the request. `engines/node/http-client.request` is
|
|
// quite flexible, but this should do it.
|
|
if(request.url){
|
|
var parsed = parseUrl(request.url);
|
|
parsed.pathInfo = parsed.pathname;
|
|
parsed.queryString = parsed.query;
|
|
for(var i in parsed){
|
|
request[i] = parsed[i];
|
|
}
|
|
}
|
|
request.pathname = request.pathname || request.pathInfo || "/";
|
|
request.queryString = request.queryString || request.query || "";
|
|
request.method = (request.method || "GET").toUpperCase();
|
|
request.protocol = request.protocol.toLowerCase();
|
|
request.hostname = (request.host || request.hostname).toLowerCase();
|
|
request.headers = {};
|
|
for(var h in this.headers){
|
|
request.headers[h] = this.headers[h];
|
|
}
|
|
for(var h in originalRequest.headers){
|
|
request.headers[h] = originalRequest.headers[h];
|
|
}
|
|
// We'll be setting the Authorization header; due to how `engines/node/http-client.request`
|
|
// is implemented we need to set the Host header as well.
|
|
request.headers.host = request.headers.host || request.hostname + (request.port ? ":" + request.port : "");
|
|
|
|
// Parse all request parameters into a flattened array of parameter pairs.
|
|
// Note that this array contains munged parameter names.
|
|
var requestParams = [], uncombinedRequestParams = [];
|
|
// Start with parameters that were defined in the query string
|
|
if(request.queryString){
|
|
querystring.parseToArray(requestParams, request.queryString);
|
|
}
|
|
// Allow parameters to be defined in object notation, this is *not* part of `http-client.request`!
|
|
// It saves an extra stringify+parse though.
|
|
if(request.requestParams){
|
|
for(var i in request.requestParams){
|
|
if(request.requestParams.hasOwnProperty(i)){
|
|
querystring.addToArray(uncombinedRequestParams, i, request.requestParams[i]);
|
|
}
|
|
}
|
|
}
|
|
// Send the parameters from `request.requestParams` in the query string
|
|
// for GET and DELETE requests. We immediately concat to the `requestParams` array,
|
|
// which is then built into the query string.
|
|
if(request.method == "GET" || request.method == "DELETE"){
|
|
requestParams = requestParams.concat(uncombinedRequestParams);
|
|
}
|
|
// Rebuild the query string
|
|
request.queryString = requestParams.reduce(function(qs, v, i){
|
|
return qs + (i % 2 ? "=" + querystring.escape(v) : (qs.length ? "&" : "") + querystring.escape(v));
|
|
}, "");
|
|
|
|
// Depending on the request content type, look for request parameters in the body
|
|
var waitForBody = false;
|
|
if(request.headers && request.headers["Content-Type"] == "application/x-www-form-urlencoded"){
|
|
waitForBody = whenPromise(request.body.join(""), function(body){
|
|
querystring.parseToArray(requestParams, body);
|
|
return body;
|
|
});
|
|
}
|
|
|
|
// If we're a POST or PUT and are not sending any content, or are sending urlencoded content,
|
|
// add the `request.request` to the request body. If we are sending non-urlencoded content through
|
|
// a POST or PUT, the `request.requestParams` are ignored.
|
|
if(request.requestParams && (request.method == "POST" || request.method == "PUT") && (!request.headers || !request.headers["Content-Type"] || request.headers["Content-Type"] == "application/x-www-form-urlencoded")){
|
|
waitForBody = whenPromise(waitForBody, function(body){
|
|
requestParams = requestParams.concat(uncombinedRequestParams);
|
|
body = (body ? body + "&" : "") + querystring.stringify(request.requestParams);
|
|
request.body = [body];
|
|
request.headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
});
|
|
}
|
|
|
|
// Sign the request and then actually make it.
|
|
return whenPromise(waitForBody, function(){
|
|
this._signRequest(request, requestParams);
|
|
return makeRequest(request);
|
|
}.bind(this));
|
|
};
|
|
|
|
Client.prototype._normalizeUrl = function(request){
|
|
var normalized = request.protocol + "//" + request.hostname;
|
|
if(request.protocol == "http:" && request.port && (request.port + "") != "80"){
|
|
normalized += ":" + request.port;
|
|
}
|
|
if(request.protocol == "https:" && request.port && (request.port + "") != "443"){
|
|
normalized += ":" + request.port;
|
|
}
|
|
return normalized + request.pathname;
|
|
};
|
|
|
|
Client.prototype._collectOAuthParams = function(request, requestParams){
|
|
var oauthParams = {};
|
|
if(request.oauthParams){
|
|
for(var p in request.oauthParams){
|
|
// Don't allow `request.oauthParams` to override standard values.
|
|
// `oauth_token` and `oauth_version` are conditionally added,
|
|
// the other parameters are always set. Hence we just test for
|
|
// the first two.
|
|
if(p != "oauth_token" && p != "oauth_version"){
|
|
oauthParams[p] = request.oauthParams[p];
|
|
}
|
|
}
|
|
}
|
|
oauthParams.oauth_consumer_key = this.identifier;
|
|
oauthParams.oauth_signature_method = this.signatureMethod;
|
|
oauthParams.oauth_timestamp = Client.getTimestamp();
|
|
oauthParams.oauth_nonce = this.generateNonce();
|
|
if(this.tokenIdentifier){
|
|
oauthParams.oauth_token = this.tokenIdentifier;
|
|
}
|
|
if(this.version){
|
|
oauthParams.oauth_version = this.version;
|
|
}
|
|
for(var i in oauthParams){
|
|
requestParams.push(i, oauthParams[i]);
|
|
}
|
|
return oauthParams;
|
|
};
|
|
|
|
Client.prototype._normalizeParams = function(requestParams){
|
|
// Encode requestParams
|
|
requestParams = requestParams.map(encodeRfc3986);
|
|
// Unflatten the requestParams for sorting
|
|
requestParams = requestParams.reduce(function(result, _, i, arr){
|
|
if(i % 2 == 0){
|
|
result.push(arr.slice(i, i + 2));
|
|
}
|
|
return result;
|
|
}, []);
|
|
// Sort the unflattened requestParams
|
|
requestParams.sort(function(a, b){
|
|
if(a[0] == b[0]){
|
|
return a[1] < b[1] ? -1 : 1;
|
|
}else{
|
|
return a[0] < b[0] ? -1 : 1;
|
|
}
|
|
});
|
|
return requestParams.map(function(pair){ return pair.join("="); }).join("&");
|
|
};
|
|
|
|
Client.prototype._createSignatureBase = function(requestMethod, baseUri, params){
|
|
return [requestMethod, baseUri, params].map(encodeRfc3986).join("&");
|
|
};
|
|
|
|
Client.prototype._signRequest = function(request, requestParams){
|
|
// Calculate base URI string
|
|
var baseUri = this._normalizeUrl(request);
|
|
|
|
// Register OAuth parameters and add to the request parameters
|
|
// Additional parameters can be specified via the `request.oauthParams` object
|
|
var oauthParams = this._collectOAuthParams(request, requestParams);
|
|
|
|
// Generate parameter string
|
|
var params = this._normalizeParams(requestParams);
|
|
|
|
// Sign the base string
|
|
var baseString = this._createSignatureBase(request.method, baseUri, params);
|
|
oauthParams.oauth_signature = this._createSignature(baseString);
|
|
|
|
// Add Authorization header
|
|
request.headers.authorization = "OAuth " + Object.keys(oauthParams).map(function(name){
|
|
return encodeRfc3986(name) + "=\"" + encodeRfc3986(oauthParams[name]) + "\"";
|
|
}).join(",");
|
|
|
|
// Now the request object can be used to make a signed request
|
|
return request;
|
|
};
|
|
|
|
Client.prototype.obtainTempCredentials = function(oauthParams, extraParams){
|
|
oauthParams = oauthParams || {};
|
|
if(this.callback && !oauthParams.oauth_callback){
|
|
oauthParams.oauth_callback = this.callback;
|
|
}
|
|
|
|
return this.request({
|
|
method: "POST",
|
|
url: this.tempRequestUrl,
|
|
oauthParams: oauthParams,
|
|
requestParams: extraParams || {}
|
|
}).then(parseResponse);
|
|
};
|
|
|
|
Client.prototype.obtainTokenCredentials = function(tokenIdentifier, tokenSecret, verifierToken, extraParams){
|
|
return this.bind(tokenIdentifier, tokenSecret).request({
|
|
method: "POST",
|
|
url: this.tokenRequestUrl,
|
|
oauthParams: { oauth_verifier: verifierToken },
|
|
requestParams: extraParams
|
|
}).then(parseResponse);
|
|
};
|