Merge branch 'client_side_flow_support_via_signed_cookie' [closes #10]
Mark Dodwell
12 years ago
1 | 1 | |
2 | 2 | This gem contains the Facebook strategy for OmniAuth 1.0. |
3 | 3 | |
4 | Supports the OAuth 2.0 server-side flow. Read the Facebook docs for more details: http://developers.facebook.com/docs/authentication | |
4 | Supports the OAuth 2.0 server-side and client-side flows. Read the Facebook docs for more details: http://developers.facebook.com/docs/authentication | |
5 | 5 | |
6 | 6 | ## Installing |
7 | 7 | |
24 | 24 | provider :facebook, ENV['FACEBOOK_KEY'], ENV['FACEBOOK_SECRET'] |
25 | 25 | end |
26 | 26 | ``` |
27 | ||
28 | See a full example of both server and client-side flows in the example Sinatra app in the `example/` folder above. | |
27 | 29 | |
28 | 30 | ## Configuring |
29 | 31 | |
87 | 89 | |
88 | 90 | The precise information available may depend on the permissions which you request. |
89 | 91 | |
92 | ## Client-side Flow | |
93 | ||
94 | The client-side flow supports parsing the authorization code from the signed request which Facebook puts into a cookie. This means you can to use the Facebook Javascript SDK as you would normally, and you just hit the callback endpoint (`/auth/facebook/callback` by default) once the user has authenticated in the `FB.login` success callback. | |
95 | ||
96 | See the example Sinatra app under `example/` for more details. | |
97 | ||
90 | 98 | ## Supported Rubies |
91 | 99 | |
92 | 100 | Actively tested with the following Ruby versions: |
95 | 103 | - MRI 1.9.2 |
96 | 104 | - MRI 1.8.7 |
97 | 105 | - JRuby 1.6.5 |
106 | ||
107 | *NB.* For JRuby, you'll need to install the `jruby-openssl` gem. There's no way to automatically specify this in a Rubygem gemspec, so you need to manually add it your project's own Gemfile: | |
108 | ||
109 | ```ruby | |
110 | gem 'jruby-openssl', :platform => :jruby | |
111 | ``` | |
98 | 112 | |
99 | 113 | ## License |
100 | 114 |
1 | 1 | require 'sinatra/base' |
2 | 2 | require 'omniauth-facebook' |
3 | 3 | |
4 | SCOPE = 'email,read_stream' | |
5 | ||
4 | 6 | class App < Sinatra::Base |
7 | # server-side flow | |
5 | 8 | get '/' do |
9 | # 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 | |
11 | # path in this example sinatra app. | |
6 | 12 | redirect '/auth/facebook' |
13 | end | |
14 | ||
15 | # client-side flow | |
16 | get '/client-side' do | |
17 | content_type 'text/html' | |
18 | # NOTE: when you enable cookie below in the FB.init call | |
19 | # the GET request in the FB.login callback will send | |
20 | # a signed request in a cookie back the OmniAuth callback | |
21 | # which will parse out the authorization code and obtain | |
22 | # the access_token. This will be the exact same access_token | |
23 | # returned to the client in response.authResponse.accessToken. | |
24 | <<-END | |
25 | <html> | |
26 | <head> | |
27 | <title>Client-side Flow Example</title> | |
28 | <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.7.0/jquery.min.js" type="text/javascript"></script> | |
29 | </head> | |
30 | <body> | |
31 | <div id="fb-root"></div> | |
32 | ||
33 | <script type="text/javascript"> | |
34 | window.fbAsyncInit = function() { | |
35 | FB.init({ | |
36 | appId : '#{ENV['APP_ID']}', | |
37 | status : true, // check login status | |
38 | cookie : true, // enable cookies to allow the server to access the session | |
39 | oauth : true, // enable OAuth 2.0 | |
40 | xfbml : true // parse XFBML | |
41 | }); | |
42 | }; | |
43 | ||
44 | (function(d) { | |
45 | var js, id = 'facebook-jssdk'; if (d.getElementById(id)) {return;} | |
46 | js = d.createElement('script'); js.id = id; js.async = true; | |
47 | js.src = "//connect.facebook.net/en_US/all.js"; | |
48 | d.getElementsByTagName('head')[0].appendChild(js); | |
49 | }(document)); | |
50 | ||
51 | $(function() { | |
52 | $('a').click(function(e) { | |
53 | e.preventDefault(); | |
54 | ||
55 | FB.login(function(response) { | |
56 | if (response.authResponse) { | |
57 | $.get('/auth/facebook/callback'); | |
58 | } | |
59 | }, { scope: '#{SCOPE}' }); | |
60 | }); | |
61 | }); | |
62 | </script> | |
63 | ||
64 | <p> | |
65 | <a href="#">Connect to FB</a> | |
66 | </p> | |
67 | </body> | |
68 | </html> | |
69 | END | |
7 | 70 | end |
8 | 71 | |
9 | 72 | get '/auth/:provider/callback' do |
20 | 83 | use Rack::Session::Cookie |
21 | 84 | |
22 | 85 | use OmniAuth::Builder do |
23 | provider :facebook, ENV['APP_ID'], ENV['APP_SECRET'], :scope => 'email,read_stream', :display => 'popup' | |
86 | provider :facebook, ENV['APP_ID'], ENV['APP_SECRET'], :scope => SCOPE | |
24 | 87 | end |
25 | 88 | |
26 | 89 | run App.new |
0 | 0 | require 'omniauth/strategies/oauth2' |
1 | require 'base64' | |
2 | require 'openssl' | |
1 | 3 | |
2 | 4 | module OmniAuth |
3 | 5 | module Strategies |
56 | 58 | @raw_info ||= access_token.get('/me').parsed |
57 | 59 | end |
58 | 60 | |
59 | def callback_url | |
60 | if options.authorize_options.respond_to? :callback_url | |
61 | options.authorize_options.callback_url | |
62 | else | |
63 | super | |
61 | def build_access_token | |
62 | with_authorization_code { super }.tap do |token| | |
63 | token.options.merge!(access_token_options) | |
64 | 64 | end |
65 | 65 | end |
66 | ||
67 | def build_access_token | |
68 | super.tap do |token| | |
69 | token.options.merge!(access_token_options) | |
66 | ||
67 | # NOTE if we're using code from the signed request cookie | |
68 | # then FB sets the redirect_uri to '' during the authorize | |
69 | # phase + it must match during the access_token phase: | |
70 | # https://github.com/facebook/php-sdk/blob/master/src/base_facebook.php#L348 | |
71 | def callback_url | |
72 | if @authorization_code_from_cookie | |
73 | '' | |
74 | else | |
75 | if options.authorize_options.respond_to?(:callback_url) | |
76 | options.authorize_options.callback_url | |
77 | else | |
78 | super | |
79 | end | |
70 | 80 | end |
71 | 81 | end |
72 | 82 | |
80 | 90 | params[:scope] ||= DEFAULT_SCOPE |
81 | 91 | end |
82 | 92 | end |
93 | ||
94 | def signed_request | |
95 | @signed_request ||= begin | |
96 | cookie = request.cookies["fbsr_#{client.id}"] and | |
97 | parse_signed_request(cookie) | |
98 | end | |
99 | end | |
83 | 100 | |
84 | 101 | private |
102 | ||
103 | # picks the authorization code in order, from: | |
104 | # 1. the request param | |
105 | # 2. a signed cookie | |
106 | def with_authorization_code | |
107 | if request.params.key?('code') | |
108 | yield | |
109 | else code_from_cookie = signed_request && signed_request['code'] | |
110 | request.params['code'] = code_from_cookie | |
111 | @authorization_code_from_cookie = true | |
112 | begin | |
113 | yield | |
114 | ensure | |
115 | request.params.delete('code') | |
116 | @authorization_code_from_cookie = false | |
117 | end | |
118 | end | |
119 | end | |
85 | 120 | |
86 | 121 | def prune!(hash) |
87 | 122 | hash.delete_if do |_, value| |
89 | 124 | value.nil? || (value.respond_to?(:empty?) && value.empty?) |
90 | 125 | end |
91 | 126 | end |
127 | ||
128 | def parse_signed_request(value) | |
129 | signature, encoded_payload = value.split('.') | |
130 | ||
131 | decoded_hex_signature = base64_decode_url(signature)#.unpack('H*') | |
132 | decoded_payload = MultiJson.decode(base64_decode_url(encoded_payload)) | |
133 | ||
134 | unless decoded_payload['algorithm'] == 'HMAC-SHA256' | |
135 | raise NotImplementedError, "unkown algorithm: #{decoded_payload['algorithm']}" | |
136 | end | |
137 | ||
138 | if valid_signature?(client.secret, decoded_hex_signature, encoded_payload) | |
139 | decoded_payload | |
140 | end | |
141 | end | |
142 | ||
143 | def valid_signature?(secret, signature, payload, algorithm = OpenSSL::Digest::SHA256.new) | |
144 | OpenSSL::HMAC.digest(algorithm, secret, payload) == signature | |
145 | end | |
146 | ||
147 | def base64_decode_url(value) | |
148 | value += '=' * (4 - value.size.modulo(4)) | |
149 | Base64.decode64(value.tr('-_', '+/')) | |
150 | end | |
92 | 151 | end |
93 | 152 | end |
94 | 153 | end |
0 | 0 | require 'spec_helper' |
1 | 1 | require 'omniauth-facebook' |
2 | require 'openssl' | |
3 | require 'base64' | |
2 | 4 | |
3 | 5 | describe OmniAuth::Strategies::Facebook do |
4 | 6 | before :each do |
5 | 7 | @request = double('Request') |
6 | 8 | @request.stub(:params) { {} } |
9 | @request.stub(:cookies) { {} } | |
10 | ||
11 | @client_id = '123' | |
12 | @client_secret = '53cr3tz' | |
7 | 13 | end |
8 | 14 | |
9 | 15 | subject do |
10 | OmniAuth::Strategies::Facebook.new(nil, @options || {}).tap do |strategy| | |
16 | args = [@client_id, @client_secret, @options].compact | |
17 | OmniAuth::Strategies::Facebook.new(nil, *args).tap do |strategy| | |
11 | 18 | strategy.stub(:request) { @request } |
12 | 19 | end |
13 | 20 | end |
31 | 38 | describe '#callback_url' do |
32 | 39 | it "returns value from #authorize_options" do |
33 | 40 | url = 'http://auth.myapp.com/auth/fb/callback' |
34 | @options = {:authorize_options => { :callback_url => url }} | |
35 | subject.callback_url.should == url | |
36 | end | |
37 | ||
38 | it " callback_url from request" do | |
41 | @options = { :authorize_options => { :callback_url => url } } | |
42 | subject.callback_url.should eq(url) | |
43 | end | |
44 | ||
45 | it "callback_url from request" do | |
39 | 46 | url_base = 'http://auth.request.com' |
40 | @request.stub(:url){ url_base + "/page/path" } | |
47 | @request.stub(:url) { "#{url_base}/page/path" } | |
41 | 48 | subject.stub(:script_name) { "" } # to not depend from Rack env |
42 | subject.callback_url.should == url_base + "/auth/facebook/callback" | |
49 | subject.callback_url.should eq("#{url_base}/auth/facebook/callback") | |
43 | 50 | end |
44 | 51 | end |
45 | 52 | |
270 | 277 | subject.extra.should eq({ 'raw_info' => @raw_info }) |
271 | 278 | end |
272 | 279 | end |
280 | ||
281 | describe '#signed_request' do | |
282 | context 'cookie not present' do | |
283 | it 'is nil' do | |
284 | subject.send(:signed_request).should be_nil | |
285 | end | |
286 | end | |
287 | ||
288 | context 'cookie present' do | |
289 | before :each do | |
290 | @payload = { | |
291 | 'algorithm' => 'HMAC-SHA256', | |
292 | 'code' => 'm4c0d3z', | |
293 | 'issued_at' => Time.now.to_i, | |
294 | 'user_id' => '123456' | |
295 | } | |
296 | ||
297 | @request.stub(:cookies) do | |
298 | { "fbsr_#{@client_id}" => signed_request(@payload, @client_secret) } | |
299 | end | |
300 | end | |
301 | ||
302 | it 'parses the access code out from the cookie' do | |
303 | subject.send(:signed_request).should eq(@payload) | |
304 | end | |
305 | end | |
306 | end | |
307 | ||
308 | private | |
309 | ||
310 | def signed_request(payload, secret) | |
311 | encoded_payload = base64_encode_url(MultiJson.encode(payload)) | |
312 | encoded_signature = base64_encode_url(signature(encoded_payload, secret)) | |
313 | [encoded_signature, encoded_payload].join('.') | |
314 | end | |
315 | ||
316 | def base64_encode_url(value) | |
317 | Base64.encode64(value).tr('+/', '-_').gsub(/\n/, '') | |
318 | end | |
319 | ||
320 | def signature(payload, secret, algorithm = OpenSSL::Digest::SHA256.new) | |
321 | OpenSSL::HMAC.digest(algorithm, secret, payload) | |
322 | end | |
273 | 323 | end |