Codebase list ruby-omniauth-facebook / 36afea7
add support for facebook canvas Mark Dodwell 12 years ago
5 changed file(s) with 254 addition(s) and 96 deletion(s). Raw diff Collapse all Expand all
3535 * `display`: The display context to show the authentication page. Options are: `page`, `popup`, `iframe`, `touch` and `wap`. Read the Facebook docs for more details: http://developers.facebook.com/docs/reference/dialogs#display. Default: `page`
3636
3737 For example, to request `email`, `offline_access` and `read_stream` permissions and display the authentication page in a popup window:
38
38
3939 ```ruby
4040 Rails.application.config.middleware.use OmniAuth::Builder do
4141 provider :facebook, ENV['FACEBOOK_KEY'], ENV['FACEBOOK_SECRET'],
66 GEM
77 remote: http://rubygems.org/
88 specs:
9 addressable (2.2.6)
10 faraday (0.7.5)
11 addressable (~> 2.2.6)
12 multipart-post (~> 1.1.3)
13 rack (>= 1.1.0, < 2)
9 addressable (2.2.7)
10 faraday (0.7.6)
11 addressable (~> 2.2)
12 multipart-post (~> 1.1)
13 rack (~> 1.1)
1414 hashie (1.2.0)
15 multi_json (1.0.4)
16 multipart-post (1.1.4)
15 multi_json (1.1.0)
16 multipart-post (1.1.5)
1717 oauth2 (0.5.2)
1818 faraday (~> 0.7)
1919 multi_json (~> 1.0)
20 omniauth (1.0.1)
20 omniauth (1.0.2)
2121 hashie (~> 1.2)
2222 rack
2323 omniauth-oauth2 (1.0.0)
11 require 'sinatra/base'
22 require 'omniauth-facebook'
33
4 # https://github.com/intridea/omniauth-oauth2/pull/9
5 require 'timeout'
6
47 SCOPE = 'email,read_stream'
58
69 class App < Sinatra::Base
10 # turn off sinatra default X-Frame-Options for FB canvas
11 set :protection, :except => :frame_options
12
713 # server-side flow
814 get '/' do
915 # NOTE: you would just hit this endpoint directly from the browser
10 # in a real app. the redirect is just here to setup the root
16 # in a real app. the redirect is just here to setup the root
1117 # path in this example sinatra app.
1218 redirect '/auth/facebook'
1319 end
14
20
1521 # client-side flow
1622 get '/client-side' do
1723 content_type 'text/html'
4652 js.src = "//connect.facebook.net/en_US/all.js";
4753 d.getElementsByTagName('head')[0].appendChild(js);
4854 }(document));
49
55
5056 $(function() {
5157 $('a').click(function(e) {
5258 e.preventDefault();
53
59
5460 FB.login(function(response) {
5561 if (response.authResponse) {
5662 $('#connect').html('Connected! Hitting OmniAuth callback (GET /auth/facebook/callback)...');
57
58 // since we have cookies enabled, this request will allow omniauth to parse
63
64 // since we have cookies enabled, this request will allow omniauth to parse
5965 // out the auth code from the signed request in the fbsr_XXX cookie
6066 $.getJSON('/auth/facebook/callback', function(json) {
6167 $('#connect').html('Connected! Callback complete.');
6672 });
6773 });
6874 </script>
69
75
7076 <p id="connect">
7177 <a href="#">Connect to FB</a>
7278 </p>
73
79
7480 <p id="results" />
7581 </body>
7682 </html>
7783 END
7884 end
7985
86 # auth via FB canvas and signed request param
87 post '/canvas/' do
88 # we just redirect to /auth/facebook here which will parse the
89 # signed_request FB sends us, asking for auth if the user has
90 # not already granted access, or simply moving straight to the
91 # callback where they have already granted access.
92 #
93 # we pass the state parameter which we detect in our callback
94 # to do custom rendering/redirection for the canvas app page
95 redirect "/auth/facebook?signed_request=#{request.params['signed_request']}&state=canvas"
96 end
97
8098 get '/auth/:provider/callback' do
99 # we can do something special here is +state+ param is canvas
100 # (see notes abovein /canvas/ method for more details)
81101 content_type 'application/json'
82102 MultiJson.encode(request.env)
83103 end
84
104
85105 get '/auth/failure' do
86106 content_type 'application/json'
87107 MultiJson.encode(request.env)
00 require 'omniauth/strategies/oauth2'
11 require 'base64'
22 require 'openssl'
3 require 'rack/utils'
34
45 module OmniAuth
56 module Strategies
67 class Facebook < OmniAuth::Strategies::OAuth2
8 class NoAuthorizationCodeError < StandardError; end
9
710 DEFAULT_SCOPE = 'email,offline_access'
8
11
912 option :client_options, {
1013 :site => 'https://graph.facebook.com',
1114 :token_url => '/oauth/access_token'
1922 :header_format => 'OAuth %s',
2023 :param_name => 'access_token'
2124 }
22
25
2326 option :authorize_options, [:scope, :display]
24
27
2528 uid { raw_info['id'] }
26
29
2730 info do
2831 prune!({
2932 'nickname' => raw_info['username'],
4043 'location' => (raw_info['location'] || {})['name']
4144 })
4245 end
43
46
4447 credentials do
4548 prune!({
4649 'expires' => access_token.expires?,
4750 'expires_at' => access_token.expires_at
4851 })
4952 end
50
53
5154 extra do
5255 prune!({
5356 'raw_info' => raw_info
5457 })
5558 end
56
59
5760 def raw_info
5861 @raw_info ||= access_token.get('/me').parsed
5962 end
6063
6164 def build_access_token
62 with_authorization_code { super }.tap do |token|
63 token.options.merge!(access_token_options)
64 end
65 end
66
67 # NOTE if we're using code from the signed request cookie
65 if signed_request_contains_access_token?
66 hash = signed_request.clone
67 ::OAuth2::AccessToken.new(
68 client,
69 hash.delete('oauth_token'),
70 hash.merge!(access_token_options)
71 )
72 else
73 with_authorization_code! { super }.tap do |token|
74 token.options.merge!(access_token_options)
75 end
76 end
77 end
78
79 def request_phase
80 if signed_request_contains_access_token?
81 # if we already have an access token, we can just hit the
82 # callback URL directly and pass the signed request along
83 params = { :signed_request => raw_signed_request }
84 params[:state] = request.params['state'] if request.params['state']
85 query = Rack::Utils.build_query(params)
86
87 url = callback_url
88 url << "?" unless url.match(/\?/)
89 url << "&" unless url.match(/[\&\?]$/)
90 url << query
91
92 redirect url
93 else
94 super
95 end
96 end
97
98 # NOTE if we're using code from the signed request
6899 # then FB sets the redirect_uri to '' during the authorize
69100 # phase + it must match during the access_token phase:
70101 # https://github.com/facebook/php-sdk/blob/master/src/base_facebook.php#L348
71102 def callback_url
72 if @authorization_code_from_cookie
103 if @authorization_code_from_signed_request
73104 ''
74105 else
75106 options[:callback_url] || super
79110 def access_token_options
80111 options.access_token_options.inject({}) { |h,(k,v)| h[k.to_sym] = v; h }
81112 end
82
113
83114 ##
84115 # You can pass +display+, +state+ or +scope+ params to the auth request, if
85116 # you need to set them dynamically. You can also set these options
89120 #
90121 def authorize_params
91122 super.tap do |params|
92 params.merge!(:display => request.params['display']) if request.params['display']
93 params.merge!(:state => request.params['state']) if request.params['state']
94 params.merge!(:scope => request.params['scope']) if request.params['scope']
123 %w[display state scope].each { |v| params[v.to_sym] = request.params[v] if request.params[v] }
95124 params[:scope] ||= DEFAULT_SCOPE
96125 end
97126 end
98127
128 ##
129 # Parse signed request in order, from:
130 #
131 # 1. the request 'signed_request' param (server-side flow from canvas pages) or
132 # 2. a cookie (client-side flow via JS SDK)
133 #
99134 def signed_request
100 @signed_request ||= begin
101 cookie = request.cookies["fbsr_#{client.id}"] and
102 parse_signed_request(cookie)
103 end
104 end
105
135 @signed_request ||= raw_signed_request &&
136 parse_signed_request(raw_signed_request)
137 end
138
106139 private
107
108 # picks the authorization code in order, from:
109 # 1. the request param
110 # 2. a signed cookie
111 def with_authorization_code
140
141 def raw_signed_request
142 request.params['signed_request'] ||
143 request.cookies["fbsr_#{client.id}"]
144 end
145
146 def signed_request_contains_access_token?
147 signed_request &&
148 signed_request['oauth_token']
149 end
150
151 ##
152 # Picks the authorization code in order, from:
153 #
154 # 1. the request 'code' param (manual callback from standard server-side flow)
155 # 2. a signed request (see #signed_request for more)
156 #
157 def with_authorization_code!
112158 if request.params.key?('code')
113159 yield
114 else code_from_cookie = signed_request && signed_request['code']
115 request.params['code'] = code_from_cookie
116 @authorization_code_from_cookie = true
160 elsif code_from_signed_request = signed_request && signed_request['code']
161 request.params['code'] = code_from_signed_request
162 @authorization_code_from_signed_request = true
117163 begin
118164 yield
119165 ensure
120166 request.params.delete('code')
121 @authorization_code_from_cookie = false
167 @authorization_code_from_signed_request = false
122168 end
123 end
124 end
125
169 else
170 raise NoAuthorizationCodeError, 'must pass either a `code` parameter or a signed request (via `signed_request` parameter or a `fbsr_XXX` cookie)'
171 end
172 end
173
126174 def prune!(hash)
127 hash.delete_if do |_, value|
175 hash.delete_if do |_, value|
128176 prune!(value) if value.is_a?(Hash)
129177 value.nil? || (value.respond_to?(:empty?) && value.empty?)
130178 end
131179 end
132
180
133181 def parse_signed_request(value)
134182 signature, encoded_payload = value.split('.')
135183
77 @request = double('Request')
88 @request.stub(:params) { {} }
99 @request.stub(:cookies) { {} }
10
10
1111 @client_id = '123'
1212 @client_secret = '53cr3tz'
1313 end
14
14
1515 subject do
1616 args = [@client_id, @client_secret, @options].compact
1717 OmniAuth::Strategies::Facebook.new(nil, *args).tap do |strategy|
4242 subject.stub(:script_name) { '' } # as not to depend on Rack env
4343 subject.callback_url.should eq("#{url_base}/auth/facebook/callback")
4444 end
45
45
4646 it "returns path from callback_path option" do
4747 @options = { :callback_path => "/auth/FB/done"}
4848 url_base = 'http://auth.request.com'
5050 subject.stub(:script_name) { '' } # as not to depend on Rack env
5151 subject.callback_url.should eq("#{url_base}/auth/FB/done")
5252 end
53
53
5454 it "returns url from callback_url option" do
5555 url = 'https://auth.myapp.com/auth/fb/callback'
5656 @options = { :callback_url => url }
6363 subject.authorize_params.should be_a(Hash)
6464 subject.authorize_params[:scope].should eq('email,offline_access')
6565 end
66
66
6767 it 'includes display parameter from request when present' do
6868 @request.stub(:params) { { 'display' => 'touch' } }
6969 subject.authorize_params.should be_a(Hash)
9898 subject.access_token_options[:header_format].should eq('OAuth %s')
9999 end
100100 end
101
101
102102 describe '#uid' do
103103 before :each do
104104 subject.stub(:raw_info) { { 'id' => '123' } }
105105 end
106
106
107107 it 'returns the id from raw_info' do
108108 subject.uid.should eq('123')
109109 end
110110 end
111
111
112112 describe '#info' do
113113 before :each do
114114 @raw_info ||= { 'name' => 'Fred Smith' }
115115 subject.stub(:raw_info) { @raw_info }
116116 end
117
117
118118 context 'when optional data is not present in raw info' do
119119 it 'has no email key' do
120120 subject.info.should_not have_key('email')
123123 it 'has no nickname key' do
124124 subject.info.should_not have_key('nickname')
125125 end
126
126
127127 it 'has no first name key' do
128128 subject.info.should_not have_key('first_name')
129129 end
130
130
131131 it 'has no last name key' do
132132 subject.info.should_not have_key('last_name')
133133 end
134
134
135135 it 'has no location key' do
136136 subject.info.should_not have_key('location')
137137 end
138
138
139139 it 'has no description key' do
140140 subject.info.should_not have_key('description')
141141 end
142
142
143143 it 'has no urls' do
144144 subject.info.should_not have_key('urls')
145145 end
146146 end
147
147
148148 context 'when data is present in raw info' do
149149 it 'returns the name' do
150150 subject.info['name'].should eq('Fred Smith')
151151 end
152
152
153153 it 'returns the email' do
154154 @raw_info['email'] = 'fred@smith.com'
155155 subject.info['email'].should eq('fred@smith.com')
159159 @raw_info['username'] = 'fredsmith'
160160 subject.info['nickname'].should eq('fredsmith')
161161 end
162
162
163163 it 'returns the first name' do
164164 @raw_info['first_name'] = 'Fred'
165165 subject.info['first_name'].should eq('Fred')
166166 end
167
167
168168 it 'returns the last name' do
169169 @raw_info['last_name'] = 'Smith'
170170 subject.info['last_name'].should eq('Smith')
171171 end
172
172
173173 it 'returns the location name as location' do
174174 @raw_info['location'] = { 'id' => '104022926303756', 'name' => 'Palo Alto, California' }
175175 subject.info['location'].should eq('Palo Alto, California')
176176 end
177
177
178178 it 'returns bio as description' do
179179 @raw_info['bio'] = 'I am great'
180180 subject.info['description'].should eq('I am great')
181181 end
182
182
183183 it 'returns the square format facebook avatar url' do
184184 @raw_info['id'] = '321'
185185 subject.info['image'].should eq('http://graph.facebook.com/321/picture?type=square')
186186 end
187
187
188188 it 'returns the Facebook link as the Facebook url' do
189189 @raw_info['link'] = 'http://www.facebook.com/fredsmith'
190190 subject.info['urls'].should be_a(Hash)
191191 subject.info['urls']['Facebook'].should eq('http://www.facebook.com/fredsmith')
192192 end
193
193
194194 it 'returns website url' do
195195 @raw_info['website'] = 'https://my-wonderful-site.com'
196196 subject.info['urls'].should be_a(Hash)
197197 subject.info['urls']['Website'].should eq('https://my-wonderful-site.com')
198198 end
199
199
200200 it 'return both Facebook link and website urls' do
201201 @raw_info['link'] = 'http://www.facebook.com/fredsmith'
202202 @raw_info['website'] = 'https://my-wonderful-site.com'
206206 end
207207 end
208208 end
209
209
210210 describe '#raw_info' do
211211 before :each do
212212 @access_token = double('OAuth2::AccessToken')
213213 subject.stub(:access_token) { @access_token }
214214 end
215
215
216216 it 'performs a GET to https://graph.facebook.com/me' do
217217 @access_token.stub(:get) { double('OAuth2::Response').as_null_object }
218218 @access_token.should_receive(:get).with('/me')
219219 subject.raw_info
220220 end
221
221
222222 it 'returns a Hash' do
223223 @access_token.stub(:get).with('/me') do
224224 raw_response = double('Faraday::Response')
241241 @access_token.stub(:refresh_token)
242242 subject.stub(:access_token) { @access_token }
243243 end
244
244
245245 it 'returns a Hash' do
246246 subject.credentials.should be_a(Hash)
247247 end
248
248
249249 it 'returns the token' do
250250 @access_token.stub(:token) { '123' }
251251 subject.credentials['token'].should eq('123')
252252 end
253
253
254254 it 'returns the expiry status' do
255255 @access_token.stub(:expires?) { true }
256256 subject.credentials['expires'].should eq(true)
257
257
258258 @access_token.stub(:expires?) { false }
259259 subject.credentials['expires'].should eq(false)
260260 end
261
261
262262 it 'returns the refresh token and expiry time when expiring' do
263263 ten_mins_from_now = (Time.now + 600).to_i
264264 @access_token.stub(:expires?) { true }
267267 subject.credentials['refresh_token'].should eq('321')
268268 subject.credentials['expires_at'].should eq(ten_mins_from_now)
269269 end
270
270
271271 it 'does not return the refresh token when it is nil and expiring' do
272272 @access_token.stub(:expires?) { true }
273273 @access_token.stub(:refresh_token) { nil }
274274 subject.credentials['refresh_token'].should be_nil
275275 subject.credentials.should_not have_key('refresh_token')
276276 end
277
277
278278 it 'does not return the refresh token when not expiring' do
279279 @access_token.stub(:expires?) { false }
280280 @access_token.stub(:refresh_token) { 'XXX' }
282282 subject.credentials.should_not have_key('refresh_token')
283283 end
284284 end
285
285
286286 describe '#extra' do
287287 before :each do
288288 @raw_info = { 'name' => 'Fred Smith' }
289289 subject.stub(:raw_info) { @raw_info }
290290 end
291
291
292292 it 'returns a Hash' do
293293 subject.extra.should be_a(Hash)
294294 end
295
295
296296 it 'contains raw info' do
297297 subject.extra.should eq({ 'raw_info' => @raw_info })
298298 end
299299 end
300300
301301 describe '#signed_request' do
302 context 'cookie not present' do
302 context 'cookie/param not present' do
303303 it 'is nil' do
304304 subject.send(:signed_request).should be_nil
305305 end
306306 end
307
307
308308 context 'cookie present' do
309309 before :each do
310310 @payload = {
321321
322322 it 'parses the access code out from the cookie' do
323323 subject.send(:signed_request).should eq(@payload)
324 end
325 end
326
327 context 'param present' do
328 before :each do
329 @payload = {
330 'algorithm' => 'HMAC-SHA256',
331 'oauth_token' => 'XXX',
332 'issued_at' => Time.now.to_i,
333 'user_id' => '123456'
334 }
335
336 @request.stub(:params) do
337 { 'signed_request' => signed_request(@payload, @client_secret) }
338 end
339 end
340
341 it 'parses the access code out from the param' do
342 subject.send(:signed_request).should eq(@payload)
343 end
344 end
345
346 context 'cookie + param present' do
347 before :each do
348 @payload_from_cookie = {
349 'algorithm' => 'HMAC-SHA256',
350 'from' => 'cookie'
351 }
352
353 @request.stub(:cookies) do
354 { "fbsr_#{@client_id}" => signed_request(@payload_from_cookie, @client_secret) }
355 end
356
357 @payload_from_param = {
358 'algorithm' => 'HMAC-SHA256',
359 'from' => 'param'
360 }
361
362 @request.stub(:params) do
363 { 'signed_request' => signed_request(@payload_from_param, @client_secret) }
364 end
365 end
366
367 it 'picks param over cookie' do
368 subject.send(:signed_request).should eq(@payload_from_param)
369 end
370 end
371 end
372
373 describe '#request_phase' do
374 describe 'params contain a signed request with an access token' do
375 before do
376 payload = {
377 'algorithm' => 'HMAC-SHA256',
378 'oauth_token' => 'm4c0d3z'
379 }
380 @raw_signed_request = signed_request(payload, @client_secret)
381 @request.stub(:params) do
382 { "signed_request" => @raw_signed_request }
383 end
384
385 subject.stub(:callback_url) { '/' }
386 end
387
388 it 'redirects to callback passing along signed request' do
389 subject.should_receive(:redirect).with("/?signed_request=#{Rack::Utils.escape(@raw_signed_request)}").once
390 subject.request_phase
391 end
392 end
393 end
394
395 describe '#build_access_token' do
396 describe 'params contain a signed request with an access token' do
397 before do
398 @payload = {
399 'algorithm' => 'HMAC-SHA256',
400 'oauth_token' => 'm4c0d3z'
401 }
402 @raw_signed_request = signed_request(@payload, @client_secret)
403 @request.stub(:params) do
404 { "signed_request" => @raw_signed_request }
405 end
406
407 subject.stub(:callback_url) { '/' }
408 end
409
410 it 'returns a new access token from the signed request' do
411 result = subject.build_access_token
412 result.should be_an_instance_of(::OAuth2::AccessToken)
413 result.token.should eq(@payload['oauth_token'])
324414 end
325415 end
326416 end
337427 Base64.encode64(value).tr('+/', '-_').gsub(/\n/, '')
338428 end
339429
340 def signature(payload, secret, algorithm = OpenSSL::Digest::SHA256.new)
430 def signature(payload, secret, algorithm = OpenSSL::Digest::SHA256.new)
341431 OpenSSL::HMAC.digest(algorithm, secret, payload)
342432 end
343433 end