Permalink
Browse files

OAuth RFC 5849 implementation

Assumes Node.js (for the `crypto` and `querystring` libraries). Tested against Twitter, no automated test cases yet.
  • Loading branch information...
1 parent 6351118 commit 34dcb038dc35282711888a46ceb72967f33c22a6 @novemberborn novemberborn committed Jan 2, 2011
Showing with 309 additions and 0 deletions.
  1. +271 −0 lib/oauth.js
  2. +38 −0 lib/util/querystring.js
View
@@ -0,0 +1,271 @@
+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)];
+ }
+};
+
+// 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._signRequest = this._signRequest.bind(bound);
+ bound.request = this.request.bind(bound);
+ 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/lib/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/lib/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 = [];
+ // 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.queryObj){
+ for(var i in request.queryObj){
+ if(request.queryObj.hasOwnProperty(i)){
+ querystring.addToArray(requestParams, i, request.queryObj[i]);
+ }
+ }
+ }
+ // 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);
+ });
+ }
+
+ // Sign the request and then actually make it.
+ return whenPromise(waitForBody, function(){
+ this._signRequest(request, requestParams);
+ return makeRequest(request);
+ }.bind(this));
+};
+
+Client.prototype._signRequest = function(request, requestParams){
+ // Calculate base URI string
+ var baseUri = request.protocol + "//" + request.hostname;
+ if(request.protocol == "http" && request.port && (request.port + "") != "80"){
+ baseUri += ":" + request.port;
+ }
+ if(request.protocol == "https" && request.port && (request.port + "") != "443"){
+ baseUri += ":" + request.port;
+ }
+ baseUri += request.pathname;
+
+ // Register OAuth parameters and add to the request parameters
+ // Additional parameters can be specified via the `request.oauthParams` object
+ 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 = Math.floor(Date.now() / 1000);
+ 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]);
+ }
+
+ // 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;
+ }
+ });
+ // Generate parameter string
+ var params = requestParams.map(function(pair){ return pair.join("="); }).join("&");
+
+ // Sign the base string
+ var baseString = [request.method, baseUri, params].map(encodeRfc3986).join("&");
+ 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, queryObj){
+ oauthParams = oauthParams || {};
+ if(this.callback && !oauthParams.oauth_callback){
+ oauthParams.oauth_callback = this.callback;
+ }
+
+ return this.request({
+ method: "POST",
+ url: this.tempRequestUrl,
+ oauthParams: oauthParams,
+ queryObj: queryObj || {}
+ }).then(parseResponse);
+};
+
+Client.prototype.obtainTokenCredentials = function(tokenIdentifier, tokenSecret, verifierToken, queryObj){
+ return this.bind(tokenIdentifier, tokenSecret).request({
+ method: "POST",
+ url: this.tokenRequestUrl,
+ oauthParams: { oauth_verifier: verifierToken },
+ queryObj: queryObj
+ }).then(parseResponse);
+};
View
@@ -0,0 +1,38 @@
+var querystring = require("querystring");
+for(var i in querystring){
+ exports[i] = querystring[i];
+}
+var type = Function.prototype.call.bind(Object.prototype.toString);
+
+// Parse the name/value pairs of the query string into a flattened array
+// Automatically munge the parameters
+exports.parseToArray = function(arr, qs){
+ var parsed = exports.parse(qs);
+ for(var i in parsed){
+ exports.addToArray(arr, i, parsed[i]);
+ }
+};
+
+// Add munged values with name/value pairs to the flattened array
+exports.addToArray = function(arr, name, value){
+ if(value === undefined || value === null){
+ value = "";
+ }
+
+ switch(type(value)){
+ case "[object String]":
+ case "[object Number]":
+ case "[object Boolean]":
+ arr.push(name, value + "");
+ break;
+ case "[object Array]":
+ value.forEach(function(value){
+ exports.addToArray(arr, name + "[]", value);
+ });
+ break;
+ case "[object Object]":
+ for(var k in value){
+ exports.addToArray(arr, name + "[" + k + "]", value);
+ }
+ };
+};

0 comments on commit 34dcb03

Please sign in to comment.