-
Notifications
You must be signed in to change notification settings - Fork 467
/
oauth.rb
executable file
·360 lines (314 loc) · 15.2 KB
/
oauth.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
# OpenSSL and Base64 are required to support signed_request
require 'openssl'
require 'base64'
module Koala
module Facebook
DIALOG_HOST = "www.facebook.com"
class OAuth
attr_reader :app_id, :app_secret, :oauth_callback_url
# Creates a new OAuth client.
#
# @param app_id [String, Integer] a Facebook application ID
# @param app_secret a Facebook application secret
# @param oauth_callback_url the URL in your app to which users authenticating with OAuth will be sent
def initialize(app_id, app_secret, oauth_callback_url = nil)
@app_id = app_id
@app_secret = app_secret
@oauth_callback_url = oauth_callback_url
end
# Parses the cookie set Facebook's JavaScript SDK.
#
# @note this method can only be called once per session, as the OAuth code
# Facebook supplies can only be redeemed once. Your application
# must handle cross-request storage of this information; you can no
# longer call this method multiple times. (This works out, as the
# method has to make a call to FB's servers anyway, which you don't
# want on every call.)
#
# @param cookie_hash a set of cookies that includes the Facebook cookie.
# You can pass Rack/Rails/Sinatra's cookie hash directly to this method.
#
# @return the authenticated user's information as a hash, or nil.
def get_user_info_from_cookies(cookie_hash)
if signed_cookie = cookie_hash["fbsr_#{@app_id}"]
parse_signed_cookie(signed_cookie)
elsif unsigned_cookie = cookie_hash["fbs_#{@app_id}"]
parse_unsigned_cookie(unsigned_cookie)
end
end
alias_method :get_user_info_from_cookie, :get_user_info_from_cookies
# Parses the cookie set Facebook's JavaScript SDK and returns only the user ID.
#
# @note (see #get_user_info_from_cookie)
#
# @param (see #get_user_info_from_cookie)
#
# @return the authenticated user's Facebook ID, or nil.
def get_user_from_cookies(cookies)
Koala::Utils.deprecate("Due to Facebook changes, you can only redeem an OAuth code once; it is therefore recommended not to use this method, as it will consume the code without providing you the access token. See https://developers.facebook.com/roadmap/completed-changes/#december-2012.")
if signed_cookie = cookies["fbsr_#{@app_id}"]
if components = parse_signed_request(signed_cookie)
components["user_id"]
end
elsif info = get_user_info_from_cookies(cookies)
# Parsing unsigned cookie
info["uid"]
end
end
alias_method :get_user_from_cookie, :get_user_from_cookies
# URLs
# Builds an OAuth URL, where users will be prompted to log in and for any desired permissions.
# When the users log in, you receive a callback with their
# See http://developers.facebook.com/docs/authentication/.
#
# @see #url_for_access_token
#
# @note The server-side authentication and dialog methods should only be used
# if your application can't use the Facebook Javascript SDK,
# which provides a much better user experience.
# See http://developers.facebook.com/docs/reference/javascript/.
#
# @param options any query values to add to the URL, as well as any special/required values listed below.
# @option options permissions an array or comma-separated string of desired permissions
#
# @raise ArgumentError if no OAuth callback was specified in OAuth#new or in options as :redirect_uri
#
# @return an OAuth URL you can send your users to
def url_for_oauth_code(options = {})
# for permissions, see http://developers.facebook.com/docs/authentication/permissions
if permissions = options.delete(:permissions)
options[:scope] = permissions.is_a?(Array) ? permissions.join(",") : permissions
end
url_options = {:client_id => @app_id}.merge(options)
# Creates the URL for oauth authorization for a given callback and optional set of permissions
build_url("https://#{GRAPH_SERVER}/oauth/authorize", true, url_options)
end
# Once you receive an OAuth code, you need to redeem it from Facebook using an appropriate URL.
# (This is done by your server behind the scenes.)
# See http://developers.facebook.com/docs/authentication/.
#
# @see #url_for_oauth_code
#
# @note (see #url_for_oauth_code)
#
# @param code an OAuth code received from Facebook
# @param options any additional query parameters to add to the URL
#
# @raise (see #url_for_oauth_code)
#
# @return an URL your server can query for the user's access token
def url_for_access_token(code, options = {})
# Creates the URL for the token corresponding to a given code generated by Facebook
url_options = {
:client_id => @app_id,
:code => code,
:client_secret => @app_secret
}.merge(options)
build_url("https://#{GRAPH_SERVER}/oauth/access_token", true, url_options)
end
# Builds a URL for a given dialog (feed, friends, OAuth, pay, send, etc.)
# See http://developers.facebook.com/docs/reference/dialogs/.
#
# @note (see #url_for_oauth_code)
#
# @param dialog_type the kind of Facebook dialog you want to show
# @param options any additional query parameters to add to the URL
#
# @return an URL your server can query for the user's access token
def url_for_dialog(dialog_type, options = {})
# some endpoints require app_id, some client_id, supply both doesn't seem to hurt
url_options = {:app_id => @app_id, :client_id => @app_id}.merge(options)
build_url("http://#{DIALOG_HOST}/dialog/#{dialog_type}", true, url_options)
end
# access tokens
# Fetches an access token, token expiration, and other info from Facebook.
# Useful when you've received an OAuth code using the server-side authentication process.
# @see url_for_oauth_code
#
# @note (see #url_for_oauth_code)
#
# @param code (see #url_for_access_token)
# @param options any additional parameters to send to Facebook when redeeming the token
#
# @raise Koala::Facebook::OAuthTokenRequestError if Facebook returns an error response
#
# @return a hash of the access token info returned by Facebook (token, expiration, etc.)
def get_access_token_info(code, options = {})
# convenience method to get a parsed token from Facebook for a given code
# should this require an OAuth callback URL?
get_token_from_server({:code => code, :redirect_uri => options[:redirect_uri] || @oauth_callback_url}, false, options)
end
# Fetches the access token (ignoring expiration and other info) from Facebook.
# Useful when you've received an OAuth code using the server-side authentication process.
# @see get_access_token_info
#
# @note (see #url_for_oauth_code)
#
# @param (see #get_access_token_info)
#
# @raise (see #get_access_token_info)
#
# @return the access token
def get_access_token(code, options = {})
# upstream methods will throw errors if needed
if info = get_access_token_info(code, options)
string = info["access_token"]
end
end
# Fetches the application's access token, along with any other information provided by Facebook.
# See http://developers.facebook.com/docs/authentication/ (search for App Login).
#
# @param options any additional parameters to send to Facebook when redeeming the token
#
# @return the application access token and other information (expiration, etc.)
def get_app_access_token_info(options = {})
# convenience method to get a the application's sessionless access token
get_token_from_server({:type => 'client_cred'}, true, options)
end
# Fetches the application's access token (ignoring expiration and other info).
# @see get_app_access_token_info
#
# @param (see #get_app_access_token_info)
#
# @return the application access token
def get_app_access_token(options = {})
if info = get_app_access_token_info(options)
string = info["access_token"]
end
end
# Fetches an access_token with extended expiration time, along with any other information provided by Facebook.
# See https://developers.facebook.com/docs/offline-access-deprecation/#extend_token (search for fb_exchange_token).
#
# @param access_token the access token to exchange
# @param options any additional parameters to send to Facebook when exchanging tokens.
#
# @return the access token with extended expiration time and other information (expiration, etc.)
def exchange_access_token_info(access_token, options = {})
get_token_from_server({
:grant_type => 'fb_exchange_token',
:fb_exchange_token => access_token
}, true, options)
end
# Fetches an access token with extended expiration time (ignoring expiration and other info).
# @see exchange_access_token_info
#
# @param (see #exchange_access_token_info)
#
# @return A new access token or the existing one, set to expire in 60 days.
def exchange_access_token(access_token, options = {})
if info = exchange_access_token_info(access_token, options)
info["access_token"]
end
end
# Parses a signed request string provided by Facebook to canvas apps or in a secure cookie.
#
# @param input the signed request from Facebook
#
# @raise OAuthSignatureError if the signature is incomplete, invalid, or using an unsupported algorithm
#
# @return a hash of the validated request information
def parse_signed_request(input)
encoded_sig, encoded_envelope = input.split('.', 2)
raise OAuthSignatureError, 'Invalid (incomplete) signature data' unless encoded_sig && encoded_envelope
signature = base64_url_decode(encoded_sig).unpack("H*").first
envelope = MultiJson.load(base64_url_decode(encoded_envelope))
raise OAuthSignatureError, "Unsupported algorithm #{envelope['algorithm']}" if envelope['algorithm'] != 'HMAC-SHA256'
# now see if the signature is valid (digest, key, data)
hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, @app_secret, encoded_envelope)
raise OAuthSignatureError, 'Invalid signature' if (signature != hmac)
envelope
end
# Old session key code
# @deprecated Facebook no longer provides session keys.
def get_token_info_from_session_keys(sessions, options = {})
Koala::Utils.deprecate("Facebook no longer provides session keys. The relevant OAuth methods will be removed in the next release.")
# fetch the OAuth tokens from Facebook
response = fetch_token_string({
:type => 'client_cred',
:sessions => sessions.join(",")
}, true, "exchange_sessions", options)
# Facebook returns an empty body in certain error conditions
if response == ""
raise BadFacebookResponse.new(200, '', "get_token_from_session_key received an error (empty response body) for sessions #{sessions.inspect}!")
end
MultiJson.load(response)
end
# @deprecated (see #get_token_info_from_session_keys)
def get_tokens_from_session_keys(sessions, options = {})
# get the original hash results
results = get_token_info_from_session_keys(sessions, options)
# now recollect them as just the access tokens
results.collect { |r| r ? r["access_token"] : nil }
end
# @deprecated (see #get_token_info_from_session_keys)
def get_token_from_session_key(session, options = {})
# convenience method for a single key
# gets the overlaoded strings automatically
get_tokens_from_session_keys([session], options)[0]
end
protected
def get_token_from_server(args, post = false, options = {})
# fetch the result from Facebook's servers
response = fetch_token_string(args, post, "access_token", options)
parse_access_token(response)
end
def parse_access_token(response_text)
components = response_text.split("&").inject({}) do |hash, bit|
key, value = bit.split("=")
hash.merge!(key => value)
end
components
end
def parse_unsigned_cookie(fb_cookie)
# remove the opening/closing quote
fb_cookie = fb_cookie.gsub(/\"/, "")
# since we no longer get individual cookies, we have to separate out the components ourselves
components = {}
fb_cookie.split("&").map {|param| param = param.split("="); components[param[0]] = param[1]}
# generate the signature and make sure it matches what we expect
auth_string = components.keys.sort.collect {|a| a == "sig" ? nil : "#{a}=#{components[a]}"}.reject {|a| a.nil?}.join("")
sig = Digest::MD5.hexdigest(auth_string + @app_secret)
sig == components["sig"] && (components["expires"] == "0" || Time.now.to_i < components["expires"].to_i) ? components : nil
end
def parse_signed_cookie(fb_cookie)
components = parse_signed_request(fb_cookie)
if code = components["code"]
begin
token_info = get_access_token_info(code, :redirect_uri => '')
rescue Koala::Facebook::OAuthTokenRequestError => err
if err.fb_error_type == 'OAuthException' && err.fb_error_message =~ /Code was invalid or expired/
return nil
else
raise
end
end
components.merge(token_info) if token_info
else
Koala::Utils.logger.warn("Signed cookie didn't contain Facebook OAuth code! Components: #{components}")
nil
end
end
def fetch_token_string(args, post = false, endpoint = "access_token", options = {})
response = Koala.make_request("/oauth/#{endpoint}", {
:client_id => @app_id,
:client_secret => @app_secret
}.merge!(args), post ? "post" : "get", {:use_ssl => true}.merge!(options))
raise ServerError.new(response.status, response.body) if response.status >= 500
raise OAuthTokenRequestError.new(response.status, response.body) if response.status >= 400
response.body
end
# base 64
# directly from https://github.com/facebook/crypto-request-examples/raw/master/sample.rb
def base64_url_decode(str)
str += '=' * (4 - str.length.modulo(4))
Base64.decode64(str.tr('-_', '+/'))
end
def build_url(base, require_redirect_uri = false, url_options = {})
if require_redirect_uri && !(url_options[:redirect_uri] ||= url_options.delete(:callback) || @oauth_callback_url)
raise ArgumentError, "url_for_dialog must get a callback either from the OAuth object or in the parameters!"
end
"#{base}?#{Koala::HTTPService.encode_params(url_options)}"
end
end
end
end