Created
May 2, 2018 10:21
-
-
Save jsopenrb/3b421c2e21bc74cab6b5b0afea9322d8 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
local Encdec = require("encdec") | |
local Url = require("socket.url") | |
local Ltn12 = require("ltn12") | |
local Http = require("socket.http") | |
local Https = require("ssl.https") | |
local table, string, os, print = table, string, os, print | |
local error, assert = error, assert | |
local pairs, tostring, type, next, setmetatable = pairs, tostring, type, next, setmetatable | |
local math = math | |
local _M = {} | |
local m_valid_http_methods = { | |
GET = true, | |
HEAD = true, | |
POST = true, | |
PUT = true, | |
DELETE = true | |
} | |
-- Joins table t1 and t2 | |
local function merge(t1, t2) | |
assert(t1) | |
if not t2 then return t1 end | |
for k,v in pairs(t2) do | |
t1[k] = v | |
end | |
return t1 | |
end | |
-- Generates a unix timestamp (epoch is 1970, etc) | |
local function generate_timestamp() | |
return tostring(os.time()) | |
end | |
-- Generates a nonce (number used once). | |
-- I'm not base64 encoding the resulting nonce because some providers rejects them (i.e. echo.lab.madgex.com) | |
local function generate_nonce() | |
local nonce = tostring(math.random()) .. "random" .. tostring(os.time()) | |
return Encdec.hmacsha1(nonce, "keyyyy") | |
end | |
-- Like URL-encoding, but following OAuth's specific semantics | |
local function oauth_encode(val) | |
return val:gsub('[^-._~a-zA-Z0-9]', function(letter) | |
return string.format("%%%02x", letter:byte()):upper() | |
end) | |
end | |
local function url_encode_arguments(arguments) | |
local body = {} | |
for k,v in pairs(arguments) do | |
body[#body + 1] = Url.escape(tostring(k)) .. "=" .. Url.escape(tostring(v)) | |
end | |
return table.concat(body, "&") | |
end | |
function _PerformRequestHelper (self, url, method, headers, arguments, post_body) | |
-- arguments have already been sanitized | |
-- this method screams "refactor me!" | |
local response_body = {} | |
local request_constructor = { | |
url = url, | |
method = method, | |
headers = headers, | |
sink = Ltn12.sink.table(response_body), | |
redirect = false | |
} | |
if method == "PUT" then | |
if type(arguments) == "table" then | |
error("unsupported table argument for PUT") | |
else | |
local string_data = tostring(arguments) | |
if string_data == "nil" then | |
error("data must be something convertible to a string") | |
end | |
request_constructor.headers["Content-Length"] = tostring(#string_data) | |
request_constructor.source = Ltn12.source.string(string_data) | |
end | |
elseif method == "POST" then | |
if type(arguments) == "table" then | |
request_constructor.headers["Content-Type"] = "application/x-www-form-urlencoded" | |
if not self.m_supportsAuthHeader then | |
-- send all parameters (oauth + custom) in the body | |
request_constructor.headers["Content-Length"] = tostring(#post_body) | |
request_constructor.source = Ltn12.source.string(post_body) | |
else | |
-- encode the custom parameters and send them in the body | |
local source = url_encode_arguments(arguments) | |
request_constructor.headers["Content-Length"] = tostring(#source) | |
request_constructor.source = Ltn12.source.string(source) | |
end | |
elseif arguments then | |
if not self.m_supportsAuthHeader then | |
error("can't send POST body if the server does not support 'Authorization' header") | |
end | |
local string_data = tostring(arguments) | |
if string_data == "nil" then | |
error("data must be something convertible to a string") | |
end | |
request_constructor.headers["Content-Length"] = tostring(#string_data) | |
request_constructor.source = Ltn12.source.string(string_data) | |
else | |
request_constructor.headers["Content-Length"] = "0" | |
end | |
elseif method == "GET" or method == "HEAD" or method == "DELETE" then | |
if self.m_supportsAuthHeader then | |
if arguments then | |
request_constructor.url = url .. "?" .. url_encode_arguments(arguments) | |
end | |
else | |
-- send all parameters (oauth + custom) in the url | |
request_constructor.url = url .. "?" .. post_body | |
end | |
end | |
local ok, response_code, response_headers, response_status_line | |
if url:match("^https://") then | |
ok, response_code, response_headers, response_status_line = Https.request(request_constructor) | |
elseif url:match("^http://") then | |
ok, response_code, response_headers, response_status_line = Http.request(request_constructor) | |
else | |
error( ("unsupported scheme '%s'"):format( tostring(url:match("^([^:]+)")) ) ) | |
end | |
if not ok then | |
return nil, response_code, response_headers, response_status_line, response_body | |
end | |
response_body = table.concat(response_body) | |
return true, response_code, response_headers, response_status_line, response_body | |
end | |
-- Given a url endpoint, a valid Http method, and a table of key/value args, build the query string and sign it, | |
-- returning the oauth_signature, the query string and the Authorization header (if supported) | |
function _M.Sign(self, httpMethod, baseUri, arguments, oauth_token_secret, authRealm) | |
assert(m_valid_http_methods[httpMethod], "method '" .. httpMethod .. "' not supported") | |
local consumer_secret = self.m_consumer_secret | |
local token_secret = oauth_token_secret or "" | |
-- oauth-encode each key and value, and get them set up for a Lua table sort. | |
local keys_and_values = { } | |
for key, val in pairs(arguments) do | |
table.insert(keys_and_values, { | |
key = oauth_encode(key), | |
val = oauth_encode(tostring(val)) | |
}) | |
end | |
-- Sort by key first, then value | |
table.sort(keys_and_values, function(a,b) | |
if a.key < b.key then | |
return true | |
elseif a.key > b.key then | |
return false | |
else | |
return a.val < b.val | |
end | |
end) | |
-- Now combine key and value into key=value | |
local key_value_pairs = { } | |
for _, rec in pairs(keys_and_values) do | |
table.insert(key_value_pairs, rec.key .. "=" .. rec.val) | |
end | |
-- Now we have the query string we use for signing, and, after we add the signature, for the final as well. | |
local query_string_except_signature = table.concat(key_value_pairs, "&") | |
-- Don't need it for Twitter, but if this routine is ever adapted for | |
-- general OAuth signing, we may need to massage a version of the url to | |
-- remove query elements, as described in http://oauth.net/core/1.0a#rfc.section.9.1.2 | |
-- | |
-- More on signing: | |
-- http://www.hueniverse.com/hueniverse/2008/10/beginners-gui-1.html | |
-- | |
local signature_base_string = httpMethod .. '&' .. oauth_encode(baseUri) .. '&' .. oauth_encode(query_string_except_signature) | |
local signature_key = oauth_encode(consumer_secret) .. '&' .. oauth_encode(token_secret) | |
-- Now have our text and key for HMAC-SHA1 signing | |
local hmac_binary = Encdec.hmacsha1(signature_base_string, signature_key, true) | |
-- Base64 encode it | |
local hmac_b64 = Encdec.base64enc(hmac_binary) | |
local oauth_signature = oauth_encode(hmac_b64) | |
local oauth_headers | |
-- Build the 'Authorization' header if the provider supports it | |
if self.m_supportsAuthHeader then | |
oauth_headers = { ([[OAuth realm="%s"]]):format(authRealm or "") } | |
for k,v in pairs(arguments) do | |
if k:match("^oauth_") then | |
table.insert(oauth_headers, k .. "=\"" .. oauth_encode(v) .. "\"") | |
end | |
end | |
table.insert(oauth_headers, "oauth_signature=\"" .. oauth_signature .. "\"") | |
oauth_headers = table.concat(oauth_headers, ", ") | |
end | |
return oauth_signature, query_string_except_signature .. '&oauth_signature=' .. oauth_signature, oauth_headers | |
end | |
-- Performs the actual http request, using LuaSocket or LuaSec (when using an https scheme) | |
local function PerformRequestHelper (self, url, method, headers, arguments, post_body, callback) | |
-- Remove oauth_related arguments | |
if type(arguments) == "table" then | |
for k,v in pairs(arguments) do | |
if type(k) == "string" and k:match("^oauth_") then | |
arguments[k] = nil | |
end | |
end | |
if not next(arguments) then | |
arguments = nil | |
end | |
end | |
return _PerformRequestHelper(self, url, method, headers, arguments, post_body, callback) | |
end | |
-- Requests an Unauthorized Request Token (http://tools.ietf.org/html/rfc5849#section-2.1) | |
function _M.RequestToken(self, arguments, headers, callback) | |
if type(arguments) == "function" then | |
callback = arguments | |
arguments, headers = nil, nil | |
elseif type(headers) == "function" then | |
callback = headers | |
headers = nil | |
end | |
local args = { | |
oauth_consumer_key = self.m_consumer_key, | |
oauth_nonce = generate_nonce(), | |
oauth_signature_method = self.m_signature_method, | |
oauth_timestamp = generate_timestamp(), | |
oauth_version = "1.0" -- optional mi trasero! | |
} | |
args = merge(args, arguments) | |
local endpoint = self.m_endpoints.RequestToken | |
local oauth_signature, post_body, authHeader = self:Sign(endpoint.method, endpoint.url, args) | |
local headers = merge({}, headers) | |
if self.m_supportsAuthHeader then | |
headers["Authorization"] = authHeader | |
end | |
if not callback then | |
local ok, response_code, response_headers, response_status_line, response_body = PerformRequestHelper(self, endpoint.url, endpoint.method, headers, arguments, post_body) | |
if not ok or response_code ~= 200 then | |
-- can't do much, the responses are not standard | |
return nil, response_code, response_headers, response_status_line, response_body | |
end | |
local values = {} | |
for key, value in string.gmatch(response_body, "([^&=]+)=([^&=]*)&?" ) do | |
-- The response parameters are url-encodeded per RFC 5849 so we need to decode them | |
values[key] = Url.unescape(value) | |
end | |
self.m_oauth_token_secret = values.oauth_token_secret | |
self.m_oauth_token = values.oauth_token | |
return values | |
else | |
local oauth_instance = self | |
PerformRequestHelper(self, endpoint.url, endpoint.method, headers, arguments, post_body, | |
function(err, response_code, response_headers, response_status_line, response_body) | |
if err then | |
callback(err) | |
return | |
end | |
if response_code ~= 200 then | |
-- can't do much, the responses are not standard | |
callback({ status = response_code, headers = response_headers, status_line = response_status_line, body = response_body}) | |
return | |
end | |
local values = {} | |
for key, value in string.gmatch(response_body, "([^&=]+)=([^&=]*)&?" ) do | |
values[key] = Url.unescape(value) | |
end | |
oauth_instance.m_oauth_token_secret = values.oauth_token_secret | |
oauth_instance.m_oauth_token = values.oauth_token | |
callback(nil, values) | |
end) | |
end | |
end | |
-- Requests Authorization from the User (http://tools.ietf.org/html/rfc5849#section-2.2) | |
-- Builds the URL used to issue a request to the Service Provider's User Authorization URL | |
function _M.BuildAuthorizationUrl(self, arguments) | |
local args = { } | |
args = merge(args, arguments) | |
args.oauth_token = (arguments and arguments.oauth_token) or self.m_oauth_token or error("no oauth_token") | |
-- oauth-encode each key and value | |
local keys_and_values = { } | |
for key, val in pairs(args) do | |
table.insert(keys_and_values, { | |
key = oauth_encode(key), | |
val = oauth_encode(tostring(val)) | |
}) | |
end | |
-- Now combine key and value into key=value | |
local key_value_pairs = { } | |
for _, rec in pairs(keys_and_values) do | |
table.insert(key_value_pairs, rec.key .. "=" .. rec.val) | |
end | |
local query_string = table.concat(key_value_pairs, "&") | |
local endpoint = self.m_endpoints.AuthorizeUser | |
return endpoint.url .. "?" .. query_string | |
end | |
-- Exchanges a request token for an Access token (http://tools.ietf.org/html/rfc5849#section-2.3) | |
function _M.GetAccessToken(self, arguments, headers, callback) | |
if type(arguments) == "function" then | |
callback = arguments | |
arguments, headers = nil, nil | |
elseif type(headers) == "function" then | |
callback = headers | |
headers = nil | |
end | |
local args = { | |
oauth_consumer_key = self.m_consumer_key, | |
oauth_nonce = generate_nonce(), | |
oauth_signature_method = self.m_signature_method, | |
oauth_timestamp = generate_timestamp(), | |
oauth_version = "1.0", | |
} | |
args = merge(args, arguments) | |
args.oauth_token = (arguments and arguments.oauth_token) or self.m_oauth_token or error("no oauth_token") | |
args.oauth_verifier = (arguments and arguments.oauth_verifier) or self.m_oauth_verifier -- or error("no oauth_verifier") | |
local endpoint = self.m_endpoints.AccessToken | |
local oauth_token_secret = (arguments and arguments.oauth_token_secret) or self.m_oauth_token_secret or error("no oauth_token_secret") | |
if arguments then | |
arguments.oauth_token_secret = nil -- this is never sent | |
end | |
args.oauth_token_secret = nil -- this is never sent | |
local oauth_signature, post_body, authHeader = self:Sign(endpoint.method, endpoint.url, args, oauth_token_secret) | |
local headers = merge({}, headers) | |
if self.m_supportsAuthHeader then | |
headers["Authorization"] = authHeader | |
end | |
if not callback then | |
local ok, response_code, response_headers, response_status_line, response_body = PerformRequestHelper(self, endpoint.url, endpoint.method, headers, arguments, post_body) | |
if not ok or response_code ~= 200 then | |
-- can't do much, the responses are not standard | |
return nil, response_code, response_headers, response_status_line, response_body | |
end | |
local values = {} | |
for key, value in string.gmatch(response_body, "([^&=]+)=([^&=]*)&?" ) do | |
values[key] = Url.unescape(value) | |
end | |
self.m_oauth_token_secret = values.oauth_token_secret | |
self.m_oauth_token = values.oauth_token | |
return values | |
else | |
local oauth_instance = self | |
PerformRequestHelper(self, endpoint.url, endpoint.method, headers, arguments, post_body, | |
function(err, response_code, response_headers, response_status_line, response_body) | |
if err then | |
callback(err) | |
return | |
end | |
if response_code ~= 200 then | |
-- can't do much, the responses are not standard | |
callback({ status = response_code, headers = response_headers, status_line = response_status_line, body = response_body}) | |
return | |
end | |
local values = {} | |
for key, value in string.gmatch(response_body, "([^&=]+)=([^&=]*)&?" ) do | |
values[key] = Url.unescape(value) | |
end | |
oauth_instance.m_oauth_token_secret = values.oauth_token_secret | |
oauth_instance.m_oauth_token = values.oauth_token | |
callback(nil, values) | |
end) | |
end | |
end | |
-- After retrieving an access token, this method is used to issue properly authenticated requests. | |
-- (see http://tools.ietf.org/html/rfc5849#section-3) | |
function _M.PerformRequest(self, method, url, arguments, headers, callback) | |
assert(type(method) == "string", "'method' must be a string") | |
method = method:upper() | |
if type(arguments) == "function" then | |
callback = arguments | |
arguments, headers = nil, nil | |
elseif type(headers) == "function" then | |
callback = headers | |
headers = nil | |
end | |
local headers, arguments, post_body = self:BuildRequest(method, url, arguments, headers) | |
if not callback then | |
local ok, response_code, response_headers, response_status_line, response_body = PerformRequestHelper(self, url, method, headers, arguments, post_body) | |
return response_code, response_headers, response_status_line, response_body | |
else | |
PerformRequestHelper(self, url, method, headers, arguments, post_body, callback) | |
end | |
end | |
-- After retrieving an access token, this method is used to build properly authenticated requests, allowing the user | |
-- to send them with the method she seems fit. | |
function _M.BuildRequest(self, method, url, arguments, headers) | |
assert(type(method) == "string", "'method' must be a string") | |
method = method:upper() | |
local args = { | |
oauth_consumer_key = self.m_consumer_key, | |
oauth_nonce = generate_nonce(), | |
oauth_signature_method = self.m_signature_method, | |
oauth_timestamp = generate_timestamp(), | |
oauth_version = "1.0" | |
} | |
local arguments_is_table = (type(arguments) == "table") | |
if arguments_is_table then | |
args = merge(args, arguments) | |
end | |
args.oauth_token = (arguments_is_table and arguments.oauth_token) or self.m_oauth_token or error("no oauth_token") | |
local oauth_token_secret = (arguments_is_table and arguments.oauth_token_secret) or self.m_oauth_token_secret or error("no oauth_token_secret") | |
if arguments_is_table then | |
arguments.oauth_token_secret = nil -- this is never sent | |
end | |
args.oauth_token_secret = nil -- this is never sent | |
local oauth_signature, post_body, authHeader = self:Sign(method, url, args, oauth_token_secret) | |
local headers = merge({}, headers) | |
if self.m_supportsAuthHeader then | |
headers["Authorization"] = authHeader | |
end | |
-- Remove oauth_related arguments | |
if type(arguments) == "table" then | |
for k,v in pairs(arguments) do | |
if type(k) == "string" and k:match("^oauth_") then | |
arguments[k] = nil | |
end | |
end | |
if not next(arguments) then | |
arguments = nil | |
end | |
end | |
return headers, arguments, post_body | |
end | |
-- | |
-- Sets / gets oauth_token | |
function _M.SetToken(self, value) | |
self.m_oauth_token = value | |
end | |
function _M.GetToken(self) | |
return self.m_oauth_token | |
end | |
-- | |
-- Sets / gets oauth_token_secret | |
function _M.SetTokenSecret(self, value) | |
self.m_oauth_token_secret = value | |
end | |
function _M.GetTokenSecret(self) | |
return self.m_oauth_token_secret | |
end | |
-- | |
-- Sets / gets oauth_verifier | |
function _M.SetVerifier(self, value) | |
self.m_oauth_verifier = value | |
end | |
function _M.GetVerifier(self) | |
return self.m_oauth_verifier | |
end | |
-- Builds a new OAuth client instance | |
function _M.new(consumer_key, consumer_secret, endpoints, params) | |
params = params or {} | |
local newInstance = { | |
m_consumer_key = consumer_key, | |
m_consumer_secret = consumer_secret, | |
m_endpoints = {}, | |
m_signature_method = params.SignatureMethod or "HMAC-SHA1", | |
m_supportsAuthHeader = true, | |
m_oauth_token = params.OAuthToken, | |
m_oauth_token_secret = params.OAuthTokenSecret, | |
m_oauth_verifier = params.OAuthVerifier | |
} | |
if type(params.UseAuthHeaders) == "boolean" then | |
newInstance.m_supportsAuthHeader = params.UseAuthHeaders | |
end | |
for k,v in pairs(endpoints or {}) do | |
if type(v) == "table" then | |
newInstance.m_endpoints[k] = { url = v[1], method = string.upper(v.method) } | |
else | |
newInstance.m_endpoints[k] = { url = v, method = "POST" } | |
end | |
end | |
setmetatable(newInstance, { __index = _M }) | |
return newInstance | |
end | |
return _M |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment