koala 0.4 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/test.yml +32 -0
  3. data/.gitignore +9 -0
  4. data/.rspec +1 -0
  5. data/.yardopts +3 -0
  6. data/Gemfile +25 -0
  7. data/ISSUE_TEMPLATE +25 -0
  8. data/LICENSE +22 -0
  9. data/Manifest +32 -5
  10. data/PULL_REQUEST_TEMPLATE +11 -0
  11. data/Rakefile +12 -12
  12. data/changelog.md +781 -0
  13. data/code_of_conduct.md +74 -0
  14. data/koala.gemspec +28 -24
  15. data/lib/koala/api/batch_operation.rb +86 -0
  16. data/lib/koala/api/graph_api_methods.rb +504 -0
  17. data/lib/koala/api/graph_batch_api.rb +167 -0
  18. data/lib/koala/api/graph_collection.rb +129 -0
  19. data/lib/koala/api/graph_error_checker.rb +72 -0
  20. data/lib/koala/api.rb +159 -0
  21. data/lib/koala/configuration.rb +56 -0
  22. data/lib/koala/errors.rb +126 -0
  23. data/lib/koala/http_service/request.rb +133 -0
  24. data/lib/koala/http_service/response.rb +20 -0
  25. data/lib/koala/http_service/uploadable_io.rb +183 -0
  26. data/lib/koala/http_service.rb +108 -0
  27. data/lib/koala/oauth.rb +342 -0
  28. data/lib/koala/realtime_updates.rb +151 -0
  29. data/lib/koala/test_users.rb +189 -0
  30. data/lib/koala/utils.rb +41 -0
  31. data/lib/koala/version.rb +3 -0
  32. data/lib/koala.rb +51 -291
  33. data/readme.md +269 -21
  34. data/spec/cases/api_spec.rb +362 -0
  35. data/spec/cases/configuration_spec.rb +11 -0
  36. data/spec/cases/error_spec.rb +143 -0
  37. data/spec/cases/graph_api_batch_spec.rb +788 -0
  38. data/spec/cases/graph_api_spec.rb +76 -0
  39. data/spec/cases/graph_collection_spec.rb +192 -0
  40. data/spec/cases/graph_error_checker_spec.rb +147 -0
  41. data/spec/cases/http_service/request_spec.rb +250 -0
  42. data/spec/cases/http_service/response_spec.rb +24 -0
  43. data/spec/cases/http_service_spec.rb +280 -0
  44. data/spec/cases/koala_spec.rb +57 -0
  45. data/spec/cases/koala_test_spec.rb +5 -0
  46. data/spec/cases/oauth_spec.rb +647 -0
  47. data/spec/cases/realtime_updates_spec.rb +327 -0
  48. data/spec/cases/test_users_spec.rb +383 -0
  49. data/spec/cases/uploadable_io_spec.rb +266 -0
  50. data/spec/cases/utils_spec.rb +55 -0
  51. data/spec/fixtures/beach.jpg +0 -0
  52. data/spec/fixtures/cat.m4v +0 -0
  53. data/spec/fixtures/facebook_data.yml +63 -0
  54. data/spec/fixtures/mock_facebook_responses.yml +483 -0
  55. data/spec/fixtures/vcr_cassettes/app_test_accounts.yml +97 -0
  56. data/spec/fixtures/vcr_cassettes/friend_list_next_page.yml +121 -0
  57. data/spec/integration/graph_collection_spec.rb +24 -0
  58. data/spec/spec_helper.rb +25 -0
  59. data/spec/support/custom_matchers.rb +28 -0
  60. data/spec/support/graph_api_shared_examples.rb +534 -0
  61. data/spec/support/koala_test.rb +251 -0
  62. data/spec/support/mock_http_service.rb +140 -0
  63. data/spec/support/uploadable_io_shared_examples.rb +70 -0
  64. metadata +206 -62
  65. data/CHANGELOG +0 -24
  66. data/init.rb +0 -2
  67. data/lib/http_services.rb +0 -60
  68. data/test/facebook_data.yml +0 -5
  69. data/test/koala/facebook_no_access_token_tests.rb +0 -119
  70. data/test/koala/facebook_with_access_token_tests.rb +0 -106
  71. data/test/koala_tests.rb +0 -30
@@ -0,0 +1,20 @@
1
+ module Koala
2
+ module HTTPService
3
+ class Response
4
+ attr_reader :status, :body, :headers
5
+
6
+ # Creates a new Response object, which standardizes the response received by Facebook for use within Koala.
7
+ def initialize(status, body, headers)
8
+ @status = status
9
+ @body = body
10
+ @headers = headers
11
+ end
12
+
13
+ def data
14
+ # quirks_mode is needed because Facebook sometimes returns a raw true or false value --
15
+ # in Ruby 2.4 we can drop that.
16
+ @data ||= JSON.parse(body, quirks_mode: true) unless body.empty?
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,183 @@
1
+ require "tempfile"
2
+
3
+ module Koala
4
+ module HTTPService
5
+ class UploadableIO
6
+ attr_reader :io_or_path, :content_type, :filename
7
+
8
+ def initialize(io_or_path_or_mixed, content_type = nil, filename = nil)
9
+ # see if we got the right inputs
10
+ parse_init_mixed_param io_or_path_or_mixed, content_type
11
+
12
+ # filename is used in the Ads API
13
+ # if it's provided, take precedence over the detected filename
14
+ # otherwise, fall back to a dummy name
15
+ @filename = filename || @filename || "koala-io-file.dum"
16
+
17
+ raise KoalaError.new("Invalid arguments to initialize an UploadableIO") unless @io_or_path
18
+ raise KoalaError.new("Unable to determine MIME type for UploadableIO") if !@content_type
19
+ end
20
+
21
+ def to_upload_io
22
+ UploadIO.new(@io_or_path, @content_type, @filename)
23
+ end
24
+
25
+ def to_file
26
+ @io_or_path.is_a?(String) ? File.open(@io_or_path) : @io_or_path
27
+ end
28
+
29
+ def self.binary_content?(content)
30
+ content.is_a?(UploadableIO) || DETECTION_STRATEGIES.detect {|method| send(method, content)}
31
+ end
32
+
33
+ private
34
+ DETECTION_STRATEGIES = [
35
+ :sinatra_param?,
36
+ :rails_3_param?,
37
+ :file_param?
38
+ ]
39
+
40
+ PARSE_STRATEGIES = [
41
+ :parse_rails_3_param,
42
+ :parse_sinatra_param,
43
+ :parse_file_object,
44
+ :parse_string_path,
45
+ :parse_io
46
+ ]
47
+
48
+ def parse_init_mixed_param(mixed, content_type = nil)
49
+ PARSE_STRATEGIES.each do |method|
50
+ send(method, mixed, content_type)
51
+ return if @io_or_path && @content_type
52
+ end
53
+ end
54
+
55
+ # Expects a parameter of type ActionDispatch::Http::UploadedFile
56
+ def self.rails_3_param?(uploaded_file)
57
+ uploaded_file.respond_to?(:content_type) and uploaded_file.respond_to?(:tempfile) and uploaded_file.tempfile.respond_to?(:path)
58
+ end
59
+
60
+ def parse_rails_3_param(uploaded_file, content_type = nil)
61
+ if UploadableIO.rails_3_param?(uploaded_file)
62
+ @io_or_path = uploaded_file.tempfile.path
63
+ @content_type = content_type || uploaded_file.content_type
64
+ @filename = uploaded_file.original_filename
65
+ end
66
+ end
67
+
68
+ # Expects a Sinatra hash of file info
69
+ def self.sinatra_param?(file_hash)
70
+ file_hash.kind_of?(Hash) and file_hash.has_key?(:type) and file_hash.has_key?(:tempfile)
71
+ end
72
+
73
+ def parse_sinatra_param(file_hash, content_type = nil)
74
+ if UploadableIO.sinatra_param?(file_hash)
75
+ @io_or_path = file_hash[:tempfile]
76
+ @content_type = content_type || file_hash[:type] || detect_mime_type(tempfile)
77
+ @filename = file_hash[:filename]
78
+ end
79
+ end
80
+
81
+ # takes a file object
82
+ def self.file_param?(file)
83
+ file.kind_of?(File) || file.kind_of?(Tempfile)
84
+ end
85
+
86
+ def parse_file_object(file, content_type = nil)
87
+ if UploadableIO.file_param?(file)
88
+ @io_or_path = file
89
+ @content_type = content_type || detect_mime_type(file.path)
90
+ @filename = File.basename(file.path)
91
+ end
92
+ end
93
+
94
+ def parse_string_path(path, content_type = nil)
95
+ if path.kind_of?(String)
96
+ @io_or_path = path
97
+ @content_type = content_type || detect_mime_type(path)
98
+ @filename = File.basename(path)
99
+ end
100
+ end
101
+
102
+ def parse_io(io, content_type = nil)
103
+ if io.respond_to?(:read)
104
+ @io_or_path = io
105
+ @content_type = content_type
106
+ end
107
+ end
108
+
109
+ MIME_TYPE_STRATEGIES = [
110
+ :use_mime_module,
111
+ :use_simple_detection
112
+ ]
113
+
114
+ def detect_mime_type(filename)
115
+ if filename
116
+ MIME_TYPE_STRATEGIES.each do |method|
117
+ result = send(method, filename)
118
+ return result if result
119
+ end
120
+ end
121
+ nil # if we can't find anything
122
+ end
123
+
124
+ def use_mime_module(filename)
125
+ # if the user has installed mime/types, we can use that
126
+ # if not, rescue and return nil
127
+ begin
128
+ type = MIME::Types.type_for(filename).first
129
+ type ? type.to_s : nil
130
+ rescue
131
+ nil
132
+ end
133
+ end
134
+
135
+ def use_simple_detection(filename)
136
+ # very rudimentary extension analysis for images
137
+ # first, get the downcased extension, or an empty string if it doesn't exist
138
+ extension = ((filename.match(/\.([a-zA-Z0-9]+)$/) || [])[1] || "").downcase
139
+ case extension
140
+ when ""
141
+ nil
142
+ # images
143
+ when "jpg", "jpeg"
144
+ "image/jpeg"
145
+ when "png"
146
+ "image/png"
147
+ when "gif"
148
+ "image/gif"
149
+
150
+ # video
151
+ when "3g2"
152
+ "video/3gpp2"
153
+ when "3gp", "3gpp"
154
+ "video/3gpp"
155
+ when "asf"
156
+ "video/x-ms-asf"
157
+ when "avi"
158
+ "video/x-msvideo"
159
+ when "flv"
160
+ "video/x-flv"
161
+ when "m4v"
162
+ "video/x-m4v"
163
+ when "mkv"
164
+ "video/x-matroska"
165
+ when "mod"
166
+ "video/mod"
167
+ when "mov", "qt"
168
+ "video/quicktime"
169
+ when "mp4", "mpeg4"
170
+ "video/mp4"
171
+ when "mpe", "mpeg", "mpg", "tod", "vob"
172
+ "video/mpeg"
173
+ when "nsv"
174
+ "application/x-winamp"
175
+ when "ogm", "ogv"
176
+ "video/ogg"
177
+ when "wmv"
178
+ "video/x-ms-wmv"
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,108 @@
1
+ require 'faraday'
2
+ require 'faraday/multipart' unless defined? Faraday::FilePart # hack for faraday < 1.9 to avoid warnings
3
+ require 'koala/http_service/uploadable_io'
4
+ require 'koala/http_service/response'
5
+ require 'koala/http_service/request'
6
+
7
+ module Koala
8
+ module HTTPService
9
+ class << self
10
+ # A customized stack of Faraday middleware that will be used to make each request.
11
+ attr_accessor :faraday_middleware
12
+ # A default set of HTTP options (see https://github.com/arsduo/koala/wiki/HTTP-Services)
13
+ attr_accessor :http_options
14
+ end
15
+
16
+ @http_options ||= {}
17
+
18
+ # Koala's default middleware stack.
19
+ # We encode requests in a Facebook-compatible multipart request,
20
+ # and use whichever adapter has been configured for this application.
21
+ DEFAULT_MIDDLEWARE = Proc.new do |builder|
22
+ builder.request :multipart
23
+ builder.request :url_encoded
24
+ builder.adapter Faraday.default_adapter
25
+ end
26
+
27
+ # Default server information for Facebook. These can be overridden by setting config values.
28
+ # See Koala.config.
29
+ DEFAULT_SERVERS = {
30
+ :graph_server => 'graph.facebook.com',
31
+ :dialog_host => 'www.facebook.com',
32
+ :host_path_matcher => /\.facebook/,
33
+ :video_replace => '-video.facebook',
34
+ :beta_replace => '.beta.facebook'
35
+ }
36
+
37
+ # Makes a request directly to Facebook.
38
+ # @note You'll rarely need to call this method directly.
39
+ #
40
+ # @see Koala::Facebook::API#api
41
+ # @see Koala::Facebook::GraphAPIMethods#graph_call
42
+ #
43
+ # @param request a Koala::HTTPService::Request object
44
+ #
45
+ # @raise an appropriate connection error if unable to make the request to Facebook
46
+ #
47
+ # @return [Koala::HTTPService::Response] a response object representing the results from Facebook
48
+ def self.make_request(request)
49
+ # set up our Faraday connection
50
+ conn = Faraday.new(request.server, faraday_options(request.options), &(faraday_middleware || DEFAULT_MIDDLEWARE))
51
+
52
+ filtered_args = request.raw_args.dup.transform_keys(&:to_s)
53
+
54
+ if Koala.config.mask_tokens
55
+ %w(access_token input_token).each do |arg_token|
56
+ if (token = filtered_args[arg_token])
57
+ filtered_args[arg_token] = token[0, 10] + '*****' + token[-5, 5]
58
+ end
59
+ end
60
+ end
61
+
62
+ Koala::Utils.debug "STARTED => #{request.verb.upcase}: #{request.path} params: #{filtered_args.inspect}"
63
+
64
+ if request.verb == "post" && request.json?
65
+ # JSON requires a bit more handling
66
+ # remember, all non-GET requests are turned into POSTs, so this covers everything but GETs
67
+ response = conn.post do |req|
68
+ req.path = request.path
69
+ req.headers["Content-Type"] = "application/json"
70
+ req.body = request.post_args.to_json
71
+ req
72
+ end
73
+ else
74
+ response = conn.send(request.verb, request.path, request.post_args)
75
+ end
76
+
77
+ Koala::Utils.debug "FINISHED => #{request.verb.upcase}: #{request.path} params: #{filtered_args.inspect}"
78
+ Koala::HTTPService::Response.new(response.status.to_i, response.body, response.headers)
79
+ end
80
+
81
+ # Encodes a given hash into a query string.
82
+ # This is used mainly by the Batch API nowadays, since Faraday handles this for regular cases.
83
+ #
84
+ # @param params_hash a hash of values to CGI-encode and appropriately join
85
+ #
86
+ # @example
87
+ # Koala.http_service.encode_params({:a => 2, :b => "My String"})
88
+ # => "a=2&b=My+String"
89
+ #
90
+ # @return the appropriately-encoded string
91
+ def self.encode_params(param_hash)
92
+ ((param_hash || {}).sort_by{|k, v| k.to_s}.collect do |key_and_value|
93
+ value = key_and_value[1]
94
+ unless value.is_a? String
95
+ value = value.to_json
96
+ end
97
+ "#{key_and_value[0].to_s}=#{CGI.escape value}"
98
+ end).join("&")
99
+ end
100
+
101
+ private
102
+
103
+ def self.faraday_options(options)
104
+ valid_options = [:request, :proxy, :ssl, :builder, :url, :parallel_manager, :params, :headers, :builder_class]
105
+ Hash[ options.select { |key,value| valid_options.include?(key) } ]
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,342 @@
1
+ # OpenSSL and Base64 are required to support signed_request
2
+ require 'openssl'
3
+ require 'base64'
4
+
5
+ module Koala
6
+ module Facebook
7
+ class OAuth
8
+ attr_reader :app_id, :app_secret, :oauth_callback_url
9
+
10
+ # Creates a new OAuth client.
11
+ #
12
+ # @param app_id [String, Integer] a Facebook application ID
13
+ # @param app_secret a Facebook application secret
14
+ # @param oauth_callback_url the URL in your app to which users authenticating with OAuth will be sent
15
+ def initialize(app_id = nil, app_secret = nil, oauth_callback_url = nil)
16
+ @app_id = app_id || Koala.config.app_id
17
+ @app_secret = app_secret || Koala.config.app_secret
18
+ @oauth_callback_url = oauth_callback_url || Koala.config.oauth_callback_url
19
+ end
20
+
21
+ # Parses the cookie set Facebook's JavaScript SDK.
22
+ #
23
+ # @note this method can only be called once per session, as the OAuth code
24
+ # Facebook supplies can only be redeemed once. Your application
25
+ # must handle cross-request storage of this information; you can no
26
+ # longer call this method multiple times. (This works out, as the
27
+ # method has to make a call to FB's servers anyway, which you don't
28
+ # want on every call.)
29
+ #
30
+ # @param cookie_hash a set of cookies that includes the Facebook cookie.
31
+ # You can pass Rack/Rails/Sinatra's cookie hash directly to this method.
32
+ #
33
+ # @return the authenticated user's information as a hash, or nil.
34
+ def get_user_info_from_cookies(cookie_hash)
35
+ if signed_cookie = cookie_hash["fbsr_#{@app_id}"]
36
+ parse_signed_cookie(signed_cookie)
37
+ elsif unsigned_cookie = cookie_hash["fbs_#{@app_id}"]
38
+ parse_unsigned_cookie(unsigned_cookie)
39
+ end
40
+ end
41
+ alias_method :get_user_info_from_cookie, :get_user_info_from_cookies
42
+
43
+ # URLs
44
+
45
+ # Builds an OAuth URL, where users will be prompted to log in and for any desired permissions.
46
+ # When the users log in, you receive a callback with their
47
+ # See http://developers.facebook.com/docs/authentication/.
48
+ #
49
+ # @see #url_for_access_token
50
+ #
51
+ # @note The server-side authentication and dialog methods should only be used
52
+ # if your application can't use the Facebook Javascript SDK,
53
+ # which provides a much better user experience.
54
+ # See http://developers.facebook.com/docs/reference/javascript/.
55
+ #
56
+ # @param options any query values to add to the URL, as well as any special/required values listed below.
57
+ # @option options permissions an array or comma-separated string of desired permissions
58
+ # @option options state a unique string to serve as a CSRF (cross-site request
59
+ # forgery) token -- highly recommended for security. See
60
+ # https://developers.facebook.com/docs/howtos/login/server-side-login/
61
+ #
62
+ # @raise ArgumentError if no OAuth callback was specified in OAuth#new or in options as :redirect_uri
63
+ #
64
+ # @return an OAuth URL you can send your users to
65
+ def url_for_oauth_code(options = {})
66
+ # for permissions, see http://developers.facebook.com/docs/authentication/permissions
67
+ if permissions = options.delete(:permissions)
68
+ options[:scope] = permissions.is_a?(Array) ? permissions.join(",") : permissions
69
+ end
70
+ url_options = {:client_id => @app_id}.merge(options)
71
+
72
+ # Creates the URL for oauth authorization for a given callback and optional set of permissions
73
+ build_url(:dialog_host, "/dialog/oauth", true, url_options)
74
+ end
75
+
76
+ # Once you receive an OAuth code, you need to redeem it from Facebook using an appropriate URL.
77
+ # (This is done by your server behind the scenes.)
78
+ # See http://developers.facebook.com/docs/authentication/.
79
+ #
80
+ # @see #url_for_oauth_code
81
+ #
82
+ # @note (see #url_for_oauth_code)
83
+ #
84
+ # @param code an OAuth code received from Facebook
85
+ # @param options any additional query parameters to add to the URL
86
+ #
87
+ # @raise (see #url_for_oauth_code)
88
+ #
89
+ # @return an URL your server can query for the user's access token
90
+ def url_for_access_token(code, options = {})
91
+ # Creates the URL for the token corresponding to a given code generated by Facebook
92
+ url_options = {
93
+ :client_id => @app_id,
94
+ :code => code,
95
+ :client_secret => @app_secret
96
+ }.merge(options)
97
+ build_url(:graph_server, "/oauth/access_token", true, url_options)
98
+ end
99
+
100
+ # Builds a URL for a given dialog (feed, friends, OAuth, pay, send, etc.)
101
+ # See http://developers.facebook.com/docs/reference/dialogs/.
102
+ #
103
+ # @note (see #url_for_oauth_code)
104
+ #
105
+ # @param dialog_type the kind of Facebook dialog you want to show
106
+ # @param options any additional query parameters to add to the URL
107
+ #
108
+ # @return an URL your server can query for the user's access token
109
+ def url_for_dialog(dialog_type, options = {})
110
+ # some endpoints require app_id, some client_id, supply both doesn't seem to hurt
111
+ url_options = {:app_id => @app_id, :client_id => @app_id}.merge(options)
112
+ build_url(:dialog_host, "/dialog/#{dialog_type}", true, url_options)
113
+ end
114
+
115
+ # Generates a 'client code' from a server side long-lived access token. With the generated
116
+ # code, it can be sent to a client application which can then use it to get a long-lived
117
+ # access token from Facebook. After which the clients can use that access token to make
118
+ # requests to Facebook without having to use the server token, yet the server access token
119
+ # remains valid.
120
+ # See https://developers.facebook.com/docs/facebook-login/access-tokens/#long-via-code
121
+ #
122
+ # @param access_token a user's long lived (server) access token
123
+ #
124
+ # @raise Koala::Facebook::ServerError if Facebook returns a server error (status >= 500)
125
+ # @raise Koala::Facebook::OAuthTokenRequestError if Facebook returns an error response (status >= 400)
126
+ # @raise Koala::Facebook::BadFacebookResponse if Facebook returns a blank response
127
+ # @raise Koala::KoalaError if response does not contain 'code' hash key
128
+ #
129
+ # @return a string of the generated 'code'
130
+ def generate_client_code(access_token)
131
+ response = fetch_token_string({:redirect_uri => @oauth_callback_url, :access_token => access_token}, false, 'client_code')
132
+
133
+ # Facebook returns an empty body in certain error conditions
134
+ if response == ''
135
+ raise BadFacebookResponse.new(200, '', 'generate_client_code received an error: empty response body')
136
+ else
137
+ result = JSON.parse(response)
138
+ end
139
+
140
+ result.has_key?('code') ? result['code'] : raise(Koala::KoalaError.new("Facebook returned a valid response without the expected 'code' in the body (response = #{response})"))
141
+ end
142
+
143
+ # access tokens
144
+
145
+ # Fetches an access token, token expiration, and other info from Facebook.
146
+ # Useful when you've received an OAuth code using the server-side authentication process.
147
+ # @see url_for_oauth_code
148
+ #
149
+ # @note (see #url_for_oauth_code)
150
+ #
151
+ # @param code (see #url_for_access_token)
152
+ # @param options any additional parameters to send to Facebook when redeeming the token
153
+ #
154
+ # @raise Koala::Facebook::OAuthTokenRequestError if Facebook returns an error response
155
+ #
156
+ # @return a hash of the access token info returned by Facebook (token, expiration, etc.)
157
+ def get_access_token_info(code, options = {})
158
+ # convenience method to get a parsed token from Facebook for a given code
159
+ # should this require an OAuth callback URL?
160
+ get_token_from_server({:code => code, :redirect_uri => options[:redirect_uri] || @oauth_callback_url}, false, options)
161
+ end
162
+
163
+ # Fetches the access token (ignoring expiration and other info) from Facebook.
164
+ # Useful when you've received an OAuth code using the server-side authentication process.
165
+ # @see get_access_token_info
166
+ #
167
+ # @note (see #url_for_oauth_code)
168
+ #
169
+ # @param (see #get_access_token_info)
170
+ #
171
+ # @raise (see #get_access_token_info)
172
+ #
173
+ # @return the access token
174
+ def get_access_token(code, options = {})
175
+ # upstream methods will throw errors if needed
176
+ if info = get_access_token_info(code, options)
177
+ string = info["access_token"]
178
+ end
179
+ end
180
+
181
+ # Fetches the application's access token, along with any other information provided by Facebook.
182
+ # See http://developers.facebook.com/docs/authentication/ (search for App Login).
183
+ #
184
+ # @param options any additional parameters to send to Facebook when redeeming the token
185
+ #
186
+ # @return the application access token and other information (expiration, etc.)
187
+ def get_app_access_token_info(options = {})
188
+ # convenience method to get a the application's sessionless access token
189
+ get_token_from_server({:grant_type => 'client_credentials'}, true, options)
190
+ end
191
+
192
+ # Fetches the application's access token (ignoring expiration and other info).
193
+ # @see get_app_access_token_info
194
+ #
195
+ # @param (see #get_app_access_token_info)
196
+ #
197
+ # @return the application access token
198
+ def get_app_access_token(options = {})
199
+ if info = get_app_access_token_info(options)
200
+ info["access_token"]
201
+ end
202
+ end
203
+
204
+ # Fetches an access_token with extended expiration time, along with any other information provided by Facebook.
205
+ # See https://developers.facebook.com/docs/offline-access-deprecation/#extend_token (search for fb_exchange_token).
206
+ #
207
+ # @param access_token the access token to exchange
208
+ # @param options any additional parameters to send to Facebook when exchanging tokens.
209
+ #
210
+ # @return the access token with extended expiration time and other information (expiration, etc.)
211
+ def exchange_access_token_info(access_token, options = {})
212
+ get_token_from_server({
213
+ :grant_type => 'fb_exchange_token',
214
+ :fb_exchange_token => access_token
215
+ }, true, options)
216
+ end
217
+
218
+ # Fetches an access token with extended expiration time (ignoring expiration and other info).
219
+
220
+ # @see exchange_access_token_info
221
+ #
222
+ # @param (see #exchange_access_token_info)
223
+ #
224
+ # @return A new access token or the existing one, set to expire in 60 days.
225
+ def exchange_access_token(access_token, options = {})
226
+ if info = exchange_access_token_info(access_token, options)
227
+ info["access_token"]
228
+ end
229
+ end
230
+
231
+ # Parses a signed request string provided by Facebook to canvas apps or in a secure cookie.
232
+ #
233
+ # @param input the signed request from Facebook
234
+ #
235
+ # @raise OAuthSignatureError if the signature is incomplete, invalid, or using an unsupported algorithm
236
+ #
237
+ # @return a hash of the validated request information
238
+ def parse_signed_request(input)
239
+ encoded_sig, encoded_envelope = input.split('.', 2)
240
+ raise OAuthSignatureError, 'Invalid (incomplete) signature data' unless encoded_sig && encoded_envelope
241
+
242
+ signature = base64_url_decode(encoded_sig).unpack("H*").first
243
+ envelope = JSON.parse(base64_url_decode(encoded_envelope))
244
+
245
+ raise OAuthSignatureError, "Unsupported algorithm #{envelope['algorithm']}" if envelope['algorithm'] != 'HMAC-SHA256'
246
+
247
+ # now see if the signature is valid (digest, key, data)
248
+ hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, @app_secret, encoded_envelope)
249
+ raise OAuthSignatureError, 'Invalid signature' if (signature != hmac)
250
+
251
+ envelope
252
+ end
253
+
254
+ protected
255
+
256
+ def get_token_from_server(args, post = false, options = {})
257
+ # fetch the result from Facebook's servers
258
+ response = fetch_token_string(args, post, "access_token", options)
259
+ parse_access_token(response)
260
+ end
261
+
262
+ def parse_access_token(response_text)
263
+ JSON.parse(response_text)
264
+ rescue JSON::ParserError
265
+ response_text.split("&").inject({}) do |hash, bit|
266
+ key, value = bit.split("=")
267
+ hash.merge!(key => value)
268
+ end
269
+ end
270
+
271
+ def parse_unsigned_cookie(fb_cookie)
272
+ # remove the opening/closing quote
273
+ fb_cookie = fb_cookie.gsub(/\"/, "")
274
+
275
+ # since we no longer get individual cookies, we have to separate out the components ourselves
276
+ components = {}
277
+ fb_cookie.split("&").map {|param| param = param.split("="); components[param[0]] = param[1]}
278
+
279
+ # generate the signature and make sure it matches what we expect
280
+ auth_string = components.keys.sort.collect {|a| a == "sig" ? nil : "#{a}=#{components[a]}"}.reject {|a| a.nil?}.join("")
281
+ sig = Digest::MD5.hexdigest(auth_string + @app_secret)
282
+ sig == components["sig"] && (components["expires"] == "0" || Time.now.to_i < components["expires"].to_i) ? components : nil
283
+ end
284
+
285
+ def parse_signed_cookie(fb_cookie)
286
+ components = parse_signed_request(fb_cookie)
287
+ if code = components["code"]
288
+ begin
289
+ token_info = get_access_token_info(code, :redirect_uri => '')
290
+ rescue Koala::Facebook::OAuthTokenRequestError => err
291
+ if err.fb_error_type == 'OAuthException' && err.fb_error_message =~ /Code was invalid or expired/
292
+ return nil
293
+ else
294
+ raise
295
+ end
296
+ end
297
+
298
+ components.merge(token_info) if token_info
299
+ else
300
+ Koala::Utils.logger.warn("Signed cookie didn't contain Facebook OAuth code! Components: #{components}")
301
+ nil
302
+ end
303
+ end
304
+
305
+ def fetch_token_string(args, post = false, endpoint = "access_token", options = {})
306
+ response = Koala.make_request("/oauth/#{endpoint}", {
307
+ :client_id => @app_id,
308
+ :client_secret => @app_secret
309
+ }.merge!(args), post ? "post" : "get", {:use_ssl => true}.merge!(options))
310
+
311
+ raise ServerError.new(response.status, response.body) if response.status >= 500
312
+ raise OAuthTokenRequestError.new(response.status, response.body) if response.status >= 400
313
+
314
+ response.body
315
+ end
316
+
317
+ # base 64
318
+ # directly from https://github.com/facebook/crypto-request-examples/raw/master/sample.rb
319
+ def base64_url_decode(str)
320
+ str += '=' * (4 - str.length.modulo(4))
321
+ Base64.decode64(str.tr('-_', '+/'))
322
+ end
323
+
324
+ def server_url(type)
325
+ url = "https://#{Koala.config.send(type)}"
326
+ if version = Koala.config.api_version
327
+ "#{url}/#{version}"
328
+ else
329
+ url
330
+ end
331
+ end
332
+
333
+ def build_url(type, path, require_redirect_uri = false, url_options = {})
334
+ if require_redirect_uri && !(url_options[:redirect_uri] ||= url_options.delete(:callback) || @oauth_callback_url)
335
+ raise ArgumentError, "build_url must get a callback either from the OAuth object or in the parameters!"
336
+ end
337
+ params = Koala::HTTPService.encode_params(url_options)
338
+ "#{server_url(type)}#{path}?#{params}"
339
+ end
340
+ end
341
+ end
342
+ end