├── lib ├── omniauth-facebook.rb └── omniauth │ ├── facebook.rb │ ├── facebook │ └── version.rb │ └── strategies │ └── facebook.rb ├── .gitignore ├── example ├── Gemfile ├── Gemfile.lock └── config.ru ├── Gemfile ├── Rakefile ├── .travis.yml ├── omniauth-facebook.gemspec ├── test ├── helper.rb ├── support │ └── shared_examples.rb └── test.rb ├── CHANGELOG.md └── README.md /lib/omniauth-facebook.rb: -------------------------------------------------------------------------------- 1 | require 'omniauth/facebook' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | .rspec 4 | /Gemfile.lock 5 | pkg/* 6 | .powenv 7 | tmp 8 | bin 9 | -------------------------------------------------------------------------------- /lib/omniauth/facebook.rb: -------------------------------------------------------------------------------- 1 | require 'omniauth/facebook/version' 2 | require 'omniauth/strategies/facebook' 3 | -------------------------------------------------------------------------------- /example/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'sinatra' 4 | gem 'omniauth-facebook', :path => '../' 5 | -------------------------------------------------------------------------------- /lib/omniauth/facebook/version.rb: -------------------------------------------------------------------------------- 1 | module OmniAuth 2 | module Facebook 3 | VERSION = "1.6.0" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | platforms :rbx do 6 | gem 'rubysl', '~> 2.0' 7 | end 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rake/testtask' 3 | 4 | Rake::TestTask.new do |task| 5 | task.libs << 'test' 6 | end 7 | 8 | task :default => :test 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | before_install: 2 | - gem update bundler 3 | - bundle --version 4 | - gem update --system 2.1.11 5 | - gem --version 6 | rvm: 7 | - 1.8.7 8 | - 1.9.2 9 | - 1.9.3 10 | - 2.0.0 11 | - 2.1.0 12 | - jruby 13 | - rbx 14 | matrix: 15 | allow_failures: 16 | - rvm: rbx 17 | -------------------------------------------------------------------------------- /omniauth-facebook.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path('../lib', __FILE__) 3 | require 'omniauth/facebook/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'omniauth-facebook' 7 | s.version = OmniAuth::Facebook::VERSION 8 | s.authors = ['Mark Dodwell', 'Josef Šimánek'] 9 | s.email = ['mark@madeofcode.com', 'retro@ballgag.cz'] 10 | s.summary = 'Facebook OAuth2 Strategy for OmniAuth' 11 | s.homepage = 'https://github.com/mkdynamic/omniauth-facebook' 12 | s.license = 'MIT' 13 | 14 | s.files = `git ls-files`.split("\n") 15 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 16 | s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } 17 | s.require_paths = ['lib'] 18 | 19 | s.add_runtime_dependency 'omniauth-oauth2', '~> 1.1' 20 | 21 | s.add_development_dependency 'minitest' 22 | s.add_development_dependency 'mocha' 23 | s.add_development_dependency 'rake' 24 | end 25 | -------------------------------------------------------------------------------- /example/Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../ 3 | specs: 4 | omniauth-facebook (1.6.0.rc1) 5 | omniauth-oauth2 (~> 1.1) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | faraday (0.8.8) 11 | multipart-post (~> 1.2.0) 12 | hashie (2.0.5) 13 | httpauth (0.2.0) 14 | jwt (0.1.8) 15 | multi_json (>= 1.5) 16 | multi_json (1.8.2) 17 | multipart-post (1.2.0) 18 | oauth2 (0.8.1) 19 | faraday (~> 0.8) 20 | httpauth (~> 0.1) 21 | jwt (~> 0.1.4) 22 | multi_json (~> 1.0) 23 | rack (~> 1.2) 24 | omniauth (1.1.4) 25 | hashie (>= 1.2, < 3) 26 | rack 27 | omniauth-oauth2 (1.1.1) 28 | oauth2 (~> 0.8.0) 29 | omniauth (~> 1.0) 30 | rack (1.5.2) 31 | rack-protection (1.5.1) 32 | rack 33 | sinatra (1.4.4) 34 | rack (~> 1.4) 35 | rack-protection (~> 1.4) 36 | tilt (~> 1.3, >= 1.3.4) 37 | tilt (1.4.1) 38 | 39 | PLATFORMS 40 | ruby 41 | 42 | DEPENDENCIES 43 | omniauth-facebook! 44 | sinatra 45 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'minitest/autorun' 3 | require 'mocha/setup' 4 | require 'omniauth/strategies/facebook' 5 | 6 | OmniAuth.config.test_mode = true 7 | 8 | module BlockTestHelper 9 | def test(name, &blk) 10 | method_name = "test_#{name.gsub(/\s+/, '_')}" 11 | raise "Method already defined: #{method_name}" if instance_methods.include?(method_name.to_sym) 12 | define_method method_name, &blk 13 | end 14 | end 15 | 16 | module CustomAssertions 17 | def assert_has_key(key, hash, msg = nil) 18 | msg = message(msg) { "Expected #{hash.inspect} to have key #{key.inspect}" } 19 | assert hash.has_key?(key), msg 20 | end 21 | 22 | def refute_has_key(key, hash, msg = nil) 23 | msg = message(msg) { "Expected #{hash.inspect} not to have key #{key.inspect}" } 24 | refute hash.has_key?(key), msg 25 | end 26 | end 27 | 28 | class TestCase < Minitest::Test 29 | extend BlockTestHelper 30 | include CustomAssertions 31 | end 32 | 33 | class StrategyTestCase < TestCase 34 | def setup 35 | @request = stub('Request') 36 | @request.stubs(:params).returns({}) 37 | @request.stubs(:cookies).returns({}) 38 | @request.stubs(:env).returns({}) 39 | @request.stubs(:scheme).returns({}) 40 | @request.stubs(:ssl?).returns(false) 41 | 42 | @client_id = '123' 43 | @client_secret = '53cr3tz' 44 | end 45 | 46 | def strategy 47 | @strategy ||= begin 48 | args = [@client_id, @client_secret, @options].compact 49 | OmniAuth::Strategies::Facebook.new(nil, *args).tap do |strategy| 50 | strategy.stubs(:request).returns(@request) 51 | end 52 | end 53 | end 54 | end 55 | 56 | Dir[File.expand_path('../support/**/*', __FILE__)].each &method(:require) 57 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.6.0 (2014-01-13) 2 | 3 | Features: 4 | 5 | - ability to specify `auth_type` per-request (#78, @sebastian-stylesaint) 6 | - image dimension can be set using `image_size` option (#91, @weilu) 7 | - update Facebook authorize URL to fix broken authorization (#103, @dlackty) 8 | - adds `info_fields` option (#109, @bloudermilk) 9 | - adds `locale` parameter (#133, @donbobka, @simi) 10 | - add automatically `appsecret_proof` (#140, @nlsrchtr, @simi) 11 | 12 | Changes: 13 | 14 | - `NoAuthorizationCodeError` and `UnknownSignatureAlgorithmError` will now `fail!` (#117, @nchelluri) 15 | - don't try to parse the signature if it's nil (#127, @oriolgual) 16 | 17 | ## 1.5.1 (2013-11-18) 18 | 19 | Changes: 20 | 21 | - don't use `access_token` in URL [CVE-2013-4593](https://github.com/mkdynamic/omniauth-facebook/wiki/Access-token-vulnerability:-CVE-2013-4593) (@homakov, @mkdynamic, @simi) 22 | 23 | ## 1.5.0 (2013-11-13) 24 | 25 | Changes: 26 | 27 | - remove `state` param to fix CSRF vulnerabilty [CVE-2013-4562](https://github.com/mkdynamic/omniauth-facebook/wiki/CSRF-vulnerability:-CVE-2013-4562) (@homakov, @mkdynamic, @simi) 28 | 29 | ## 1.4.1 (2012-07-07) 30 | 31 | Changes: 32 | 33 | - update to omniauth-oauth2 1.1.0 for csrf protection (@mkdynamic) 34 | 35 | ## 1.4.0 (2012-06-24) 36 | 37 | Features: 38 | 39 | - obey `skip_info?` config (@mkdynamic) 40 | - add support of the `:auth_type` option to `:authorize_options` (#58, @JHeidinga, @mkdynamic) 41 | - support `access_token` parameter as part of the callback request (#62, @steverandy) 42 | 43 | ## 1.3.0 (2012-05-05) 44 | 45 | Features: 46 | 47 | - dynamic permissions in the auth params (#30, @famoseagle) 48 | - add support for facebook canvas (@mkdynamic) 49 | - add verified key to the info hash (#34, @ryansobol) 50 | - add option to use secure url for image in auth hash (@mkdynamic) 51 | - add option to specify image size (@mkdynamic) 52 | 53 | Changes: 54 | 55 | - have `raw_info` return an empty hash if the Facebook response returns false (#44, @brianjlandau) 56 | - prevent oauth2 from interpreting Facebook's expires field as `expires_in`, when it's really `expires_at` (#39, @watsonbox) 57 | - remove deprecated `offline_access` permission (@mkdynamic) 58 | - tidy up the `callback_url` option (@mkdynamic) 59 | 60 | ## 1.2.0 (2012-01-06) 61 | 62 | Features: 63 | 64 | - add `state` to authorization params (#19, @GermanDZ) 65 | 66 | Changes: 67 | 68 | - lock to `rack ~> 1.3.6` (@mkdynamic) 69 | 70 | ## 1.1.0 (2011-12-10) 71 | 72 | Features: 73 | 74 | - add `callback_url` option (#13, @gumayunov) 75 | - support for parsing code from signed request cookie (client-side flow) (@mkdynamic) 76 | 77 | ## 1.0.0 (2011-11-19) 78 | 79 | Features: 80 | 81 | - allow passing of display via option (@mkdynamic) 82 | 83 | Bugfixes: 84 | 85 | - fix `ten_mins_from_now` calculation (#7, @olegkovalenko) 86 | 87 | ## 1.0.0.rc2 (2011-11-11) 88 | 89 | Features: 90 | 91 | - allow passing `display` parameter (@mkdynamic) 92 | - included default scope (@mkdynamic) 93 | 94 | ## 1.0.0.rc1 (2011-10-29) 95 | 96 | - first public gem release (@mkdynamic) 97 | -------------------------------------------------------------------------------- /test/support/shared_examples.rb: -------------------------------------------------------------------------------- 1 | # NOTE it would be useful if this lived in omniauth-oauth2 eventually 2 | module OAuth2StrategyTests 3 | def self.included(base) 4 | base.class_eval do 5 | include ClientTests 6 | include AuthorizeParamsTests 7 | include CSRFAuthorizeParamsTests 8 | include TokenParamsTests 9 | end 10 | end 11 | 12 | module ClientTests 13 | extend BlockTestHelper 14 | 15 | test 'should be initialized with symbolized client_options' do 16 | @options = { :client_options => { 'authorize_url' => 'https://example.com' } } 17 | assert_equal 'https://example.com', strategy.client.options[:authorize_url] 18 | end 19 | end 20 | 21 | module AuthorizeParamsTests 22 | extend BlockTestHelper 23 | 24 | test 'should include any authorize params passed in the :authorize_params option' do 25 | @options = { :authorize_params => { :foo => 'bar', :baz => 'zip' } } 26 | assert_equal 'bar', strategy.authorize_params['foo'] 27 | assert_equal 'zip', strategy.authorize_params['baz'] 28 | end 29 | 30 | test 'should include top-level options that are marked as :authorize_options' do 31 | @options = { :authorize_options => [:scope, :foo], :scope => 'bar', :foo => 'baz' } 32 | assert_equal 'bar', strategy.authorize_params['scope'] 33 | assert_equal 'baz', strategy.authorize_params['foo'] 34 | end 35 | 36 | test 'should exclude top-level options that are not passed' do 37 | @options = { :authorize_options => [:bar] } 38 | refute_has_key :bar, strategy.authorize_params 39 | refute_has_key 'bar', strategy.authorize_params 40 | end 41 | end 42 | 43 | module CSRFAuthorizeParamsTests 44 | extend BlockTestHelper 45 | 46 | test 'should store random state in the session when none is present in authorize or request params' do 47 | assert_includes strategy.authorize_params.keys, 'state' 48 | refute_empty strategy.authorize_params['state'] 49 | refute_empty strategy.session['omniauth.state'] 50 | assert_equal strategy.authorize_params['state'], strategy.session['omniauth.state'] 51 | end 52 | 53 | test 'should not store state in the session when present in authorize params vs. a random one' do 54 | @options = { :authorize_params => { :state => 'bar' } } 55 | refute_empty strategy.authorize_params['state'] 56 | refute_equal 'bar', strategy.authorize_params[:state] 57 | refute_empty strategy.session['omniauth.state'] 58 | refute_equal 'bar', strategy.session['omniauth.state'] 59 | end 60 | 61 | test 'should not store state in the session when present in request params vs. a random one' do 62 | @request.stubs(:params).returns({ 'state' => 'foo' }) 63 | refute_empty strategy.authorize_params['state'] 64 | refute_equal 'foo', strategy.authorize_params[:state] 65 | refute_empty strategy.session['omniauth.state'] 66 | refute_equal 'foo', strategy.session['omniauth.state'] 67 | end 68 | end 69 | 70 | module TokenParamsTests 71 | extend BlockTestHelper 72 | 73 | test 'should include any authorize params passed in the :token_params option' do 74 | @options = { :token_params => { :foo => 'bar', :baz => 'zip' } } 75 | assert_equal 'bar', strategy.token_params['foo'] 76 | assert_equal 'zip', strategy.token_params['baz'] 77 | end 78 | 79 | test 'should include top-level options that are marked as :token_options' do 80 | @options = { :token_options => [:scope, :foo], :scope => 'bar', :foo => 'baz' } 81 | assert_equal 'bar', strategy.token_params['scope'] 82 | assert_equal 'baz', strategy.token_params['foo'] 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /example/config.ru: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'sinatra/base' 3 | require 'omniauth-facebook' 4 | 5 | SCOPE = 'email,read_stream' 6 | 7 | class App < Sinatra::Base 8 | # turn off sinatra default X-Frame-Options for FB canvas 9 | set :protection, :except => :frame_options 10 | 11 | # server-side flow 12 | get '/' do 13 | # NOTE: you would just hit this endpoint directly from the browser 14 | # in a real app. the redirect is just here to setup the root 15 | # path in this example sinatra app. 16 | redirect '/auth/facebook' 17 | end 18 | 19 | # client-side flow 20 | get '/client-side' do 21 | content_type 'text/html' 22 | # NOTE: when you enable cookie below in the FB.init call 23 | # the GET request in the FB.login callback will send 24 | # a signed request in a cookie back the OmniAuth callback 25 | # which will parse out the authorization code and obtain 26 | # the access_token. This will be the exact same access_token 27 | # returned to the client in response.authResponse.accessToken. 28 | <<-END 29 | 30 | 31 | Client-side Flow Example 32 | 33 | 34 | 35 |
36 | 37 | 73 | 74 |

75 | Connect to FB 76 |

77 | 78 |

79 | 80 | 81 | END 82 | end 83 | 84 | # auth via FB canvas and signed request param 85 | post '/canvas/' do 86 | # we just redirect to /auth/facebook here which will parse the 87 | # signed_request FB sends us, asking for auth if the user has 88 | # not already granted access, or simply moving straight to the 89 | # callback where they have already granted access. 90 | redirect "/auth/facebook?signed_request=#{request.params['signed_request']}" 91 | end 92 | 93 | get '/auth/:provider/callback' do 94 | content_type 'application/json' 95 | MultiJson.encode(request.env) 96 | end 97 | 98 | get '/auth/failure' do 99 | content_type 'application/json' 100 | MultiJson.encode(request.env) 101 | end 102 | end 103 | 104 | use Rack::Session::Cookie 105 | 106 | use OmniAuth::Builder do 107 | provider :facebook, ENV['APP_ID'], ENV['APP_SECRET'], :scope => SCOPE 108 | end 109 | 110 | run App.new 111 | -------------------------------------------------------------------------------- /lib/omniauth/strategies/facebook.rb: -------------------------------------------------------------------------------- 1 | require 'omniauth/strategies/oauth2' 2 | require 'base64' 3 | require 'openssl' 4 | require 'rack/utils' 5 | require 'uri' 6 | 7 | module OmniAuth 8 | module Strategies 9 | class Facebook < OmniAuth::Strategies::OAuth2 10 | class NoAuthorizationCodeError < StandardError; end 11 | class UnknownSignatureAlgorithmError < NotImplementedError; end 12 | 13 | DEFAULT_SCOPE = 'email' 14 | 15 | option :client_options, { 16 | :site => 'https://graph.facebook.com', 17 | :authorize_url => "https://www.facebook.com/dialog/oauth", 18 | :token_url => '/oauth/access_token' 19 | } 20 | 21 | option :token_params, { 22 | :parse => :query 23 | } 24 | 25 | option :access_token_options, { 26 | :header_format => 'OAuth %s', 27 | :param_name => 'access_token' 28 | } 29 | 30 | option :authorize_options, [:scope, :display, :auth_type] 31 | 32 | uid { raw_info['id'] } 33 | 34 | info do 35 | prune!({ 36 | 'nickname' => raw_info['username'], 37 | 'email' => raw_info['email'], 38 | 'name' => raw_info['name'], 39 | 'first_name' => raw_info['first_name'], 40 | 'last_name' => raw_info['last_name'], 41 | 'image' => image_url(uid, options), 42 | 'description' => raw_info['bio'], 43 | 'urls' => { 44 | 'Facebook' => raw_info['link'], 45 | 'Website' => raw_info['website'] 46 | }, 47 | 'location' => (raw_info['location'] || {})['name'], 48 | 'verified' => raw_info['verified'] 49 | }) 50 | end 51 | 52 | extra do 53 | hash = {} 54 | hash['raw_info'] = raw_info unless skip_info? 55 | prune! hash 56 | end 57 | 58 | def raw_info 59 | @raw_info ||= access_token.get('/me', info_options).parsed || {} 60 | end 61 | 62 | def info_options 63 | params = {:appsecret_proof => appsecret_proof} 64 | params.merge!({:fields => options[:info_fields]}) if options[:info_fields] 65 | params.merge!({:locale => options[:locale]}) if options[:locale] 66 | 67 | { :params => params } 68 | end 69 | 70 | def callback_phase 71 | super 72 | rescue NoAuthorizationCodeError => e 73 | fail!(:no_authorization_code, e) 74 | rescue UnknownSignatureAlgorithmError => e 75 | fail!(:unknown_signature_algoruthm, e) 76 | end 77 | 78 | def request_phase 79 | if signed_request_contains_access_token? 80 | # If we already have an access token, we can just hit the callback URL directly and pass the signed request. 81 | params = { :signed_request => raw_signed_request } 82 | query = Rack::Utils.build_query(params) 83 | 84 | url = callback_url 85 | url << "?" unless url.match(/\?/) 86 | url << "&" unless url.match(/[\&\?]$/) 87 | url << query 88 | 89 | redirect url 90 | else 91 | super 92 | end 93 | end 94 | 95 | # NOTE If we're using code from the signed request then FB sets the redirect_uri to '' during the authorize 96 | # phase and it must match during the access_token phase: 97 | # https://github.com/facebook/php-sdk/blob/master/src/base_facebook.php#L348 98 | def callback_url 99 | if @authorization_code_from_signed_request 100 | '' 101 | else 102 | options[:callback_url] || super 103 | end 104 | end 105 | 106 | def access_token_options 107 | options.access_token_options.inject({}) { |h,(k,v)| h[k.to_sym] = v; h } 108 | end 109 | 110 | # You can pass +display+, +scope+, or +auth_type+ params to the auth request, if you need to set them dynamically. 111 | # You can also set these options in the OmniAuth config :authorize_params option. 112 | # 113 | # /auth/facebook?display=popup 114 | def authorize_params 115 | super.tap do |params| 116 | %w[display scope auth_type].each do |v| 117 | if request.params[v] 118 | params[v.to_sym] = request.params[v] 119 | end 120 | end 121 | 122 | params[:scope] ||= DEFAULT_SCOPE 123 | end 124 | end 125 | 126 | # Parse signed request in order, from: 127 | # 128 | # 1. The request 'signed_request' param (server-side flow from canvas pages) or 129 | # 2. A cookie (client-side flow via JS SDK) 130 | def signed_request 131 | @signed_request ||= raw_signed_request && parse_signed_request(raw_signed_request) 132 | end 133 | 134 | protected 135 | 136 | def build_access_token 137 | if signed_request_contains_access_token? 138 | hash = signed_request.clone 139 | ::OAuth2::AccessToken.new( 140 | client, 141 | hash.delete('oauth_token'), 142 | hash.merge!(access_token_options.merge(:expires_at => hash.delete('expires'))) 143 | ) 144 | else 145 | with_authorization_code! { super }.tap do |token| 146 | token.options.merge!(access_token_options) 147 | end 148 | end 149 | end 150 | 151 | private 152 | 153 | def raw_signed_request 154 | request.params['signed_request'] || request.cookies["fbsr_#{client.id}"] 155 | end 156 | 157 | # If the signed_request comes from a FB canvas page and the user has already authorized your application, the JSON 158 | # object will be contain the access token. 159 | # 160 | # https://developers.facebook.com/docs/authentication/canvas/ 161 | def signed_request_contains_access_token? 162 | signed_request && signed_request['oauth_token'] 163 | end 164 | 165 | # Picks the authorization code in order, from: 166 | # 167 | # 1. The request 'code' param (manual callback from standard server-side flow) 168 | # 2. A signed request (see #signed_request for more) 169 | def with_authorization_code! 170 | if request.params.key?('code') 171 | yield 172 | elsif code_from_signed_request = signed_request && signed_request['code'] 173 | request.params['code'] = code_from_signed_request 174 | @authorization_code_from_signed_request = true 175 | begin 176 | yield 177 | ensure 178 | request.params.delete('code') 179 | @authorization_code_from_signed_request = false 180 | end 181 | else 182 | raise NoAuthorizationCodeError, 'must pass either a `code` parameter or a signed request (via `signed_request` parameter or a `fbsr_XXX` cookie)' 183 | end 184 | end 185 | 186 | def prune!(hash) 187 | hash.delete_if do |_, value| 188 | prune!(value) if value.is_a?(Hash) 189 | value.nil? || (value.respond_to?(:empty?) && value.empty?) 190 | end 191 | end 192 | 193 | def parse_signed_request(value) 194 | signature, encoded_payload = value.split('.') 195 | return if signature.nil? 196 | 197 | decoded_hex_signature = base64_decode_url(signature) 198 | decoded_payload = MultiJson.decode(base64_decode_url(encoded_payload)) 199 | 200 | unless decoded_payload['algorithm'] == 'HMAC-SHA256' 201 | raise UnknownSignatureAlgorithmError, "unknown algorithm: #{decoded_payload['algorithm']}" 202 | end 203 | 204 | if valid_signature?(client.secret, decoded_hex_signature, encoded_payload) 205 | decoded_payload 206 | end 207 | end 208 | 209 | def valid_signature?(secret, signature, payload, algorithm = OpenSSL::Digest::SHA256.new) 210 | OpenSSL::HMAC.digest(algorithm, secret, payload) == signature 211 | end 212 | 213 | def base64_decode_url(value) 214 | value += '=' * (4 - value.size.modulo(4)) 215 | Base64.decode64(value.tr('-_', '+/')) 216 | end 217 | 218 | def image_url(uid, options) 219 | uri_class = options[:secure_image_url] ? URI::HTTPS : URI::HTTP 220 | url = uri_class.build({:host => 'graph.facebook.com', :path => "/#{uid}/picture"}) 221 | 222 | query = if options[:image_size].is_a?(String) 223 | { :type => options[:image_size] } 224 | elsif options[:image_size].is_a?(Hash) 225 | options[:image_size] 226 | end 227 | url.query = Rack::Utils.build_query(query) if query 228 | 229 | url.to_s 230 | end 231 | 232 | def appsecret_proof 233 | @appsecret_proof ||= OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, client.secret, access_token.token) 234 | end 235 | end 236 | end 237 | end 238 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **IMPORTANT: If you're running < 1.5.1, please upgrade to address 2 security vulnerabilities. 2 | More details [here](https://github.com/mkdynamic/omniauth-facebook/wiki/CSRF-vulnerability:-CVE-2013-4562) and [here](https://github.com/mkdynamic/omniauth-facebook/wiki/Access-token-vulnerability:-CVE-2013-4593).** 3 | 4 | --- 5 | 6 | # OmniAuth Facebook  [![Build Status](https://secure.travis-ci.org/mkdynamic/omniauth-facebook.png?branch=master)](https://travis-ci.org/mkdynamic/omniauth-facebook) 7 | 8 | **These notes are based on master, please see tags for README pertaining to specific releases.** 9 | 10 | Facebook OAuth2 Strategy for OmniAuth. 11 | 12 | Supports the OAuth 2.0 server-side and client-side flows. Read the Facebook docs for more details: http://developers.facebook.com/docs/authentication 13 | 14 | ## Installing 15 | 16 | Add to your `Gemfile`: 17 | 18 | ```ruby 19 | gem 'omniauth-facebook' 20 | ``` 21 | 22 | Then `bundle install`. 23 | 24 | ## Usage 25 | 26 | `OmniAuth::Strategies::Facebook` is simply a Rack middleware. Read the OmniAuth docs for detailed instructions: https://github.com/intridea/omniauth. 27 | 28 | Here's a quick example, adding the middleware to a Rails app in `config/initializers/omniauth.rb`: 29 | 30 | ```ruby 31 | Rails.application.config.middleware.use OmniAuth::Builder do 32 | provider :facebook, ENV['FACEBOOK_KEY'], ENV['FACEBOOK_SECRET'] 33 | end 34 | ``` 35 | 36 | [See the example Sinatra app for full examples](https://github.com/mkdynamic/omniauth-facebook/blob/master/example/config.ru) of both the server and client-side flows (including using the Facebook Javascript SDK). 37 | 38 | ## Configuring 39 | 40 | You can configure several options, which you pass in to the `provider` method via a `Hash`: 41 | 42 | * `scope`: A comma-separated list of permissions you want to request from the user. See the Facebook docs for a full list of available permissions: http://developers.facebook.com/docs/reference/api/permissions. Default: `email` 43 | * `display`: The display context to show the authentication page. Options are: `page`, `popup` and `touch`. Read the Facebook docs for more details: https://developers.facebook.com/docs/reference/dialogs/oauth/. Default: `page` 44 | * `auth_type`: Optionally specifies the requested authentication features as a comma-separated list, as per https://developers.facebook.com/docs/authentication/reauthentication/. 45 | Valid values are `https` (checks for the presence of the secure cookie and asks for re-authentication if it is not present), and `reauthenticate` (asks the user to re-authenticate unconditionally). Default is `nil`. 46 | * `secure_image_url`: Set to `true` to use https for the avatar image url returned in the auth hash. Default is `false`. 47 | * `image_size`: Set the size for the returned image url in the auth hash. Valid options include `square` (50x50), `small` (50 pixels wide, variable height), `normal` (100 pixels wide, variable height), or `large` (about 200 pixels wide, variable height). Additionally, you can request a picture of a specific size by setting this option to a hash with `:width` and `:height` as keys. This will return an available profile picture closest to the requested size and requested aspect ratio. If only `:width` or `:height` is specified, we will return a picture whose width or height is closest to the requested size, respectively. 48 | * `info_fields`: Specify exactly which fields should be returned when getting the user's info. Value should be a comma-separated string as per https://developers.facebook.com/docs/reference/api/user/ (only /me endpoint). 49 | * `locale`: Specify locale which should be used when getting the user's info. Value should be locale string as per https://developers.facebook.com/docs/reference/api/locale/. 50 | 51 | For example, to request `email`, `user_birthday` and `read_stream` permissions and display the authentication page in a popup window: 52 | 53 | ```ruby 54 | Rails.application.config.middleware.use OmniAuth::Builder do 55 | provider :facebook, ENV['FACEBOOK_KEY'], ENV['FACEBOOK_SECRET'], 56 | :scope => 'email,user_birthday,read_stream', :display => 'popup' 57 | end 58 | ``` 59 | 60 | ### Per-Request Options 61 | 62 | If you want to set the `display` format, `auth_type`, or `scope` on a per-request basis, you can just pass it to the OmniAuth request phase URL, for example: `/auth/facebook?display=popup` or `/auth/facebook?scope=email`. 63 | 64 | ### Custom Callback URL/Path 65 | 66 | You can set a custom `callback_url` or `callback_path` option to override the default value. See [OmniAuth::Strategy#callback_url](https://github.com/intridea/omniauth/blob/master/lib/omniauth/strategy.rb#L411) for more details on the default. 67 | 68 | ## Auth Hash 69 | 70 | Here's an example *Auth Hash* available in `request.env['omniauth.auth']`: 71 | 72 | ```ruby 73 | { 74 | :provider => 'facebook', 75 | :uid => '1234567', 76 | :info => { 77 | :nickname => 'jbloggs', 78 | :email => 'joe@bloggs.com', 79 | :name => 'Joe Bloggs', 80 | :first_name => 'Joe', 81 | :last_name => 'Bloggs', 82 | :image => 'http://graph.facebook.com/1234567/picture?type=square', 83 | :urls => { :Facebook => 'http://www.facebook.com/jbloggs' }, 84 | :location => 'Palo Alto, California', 85 | :verified => true 86 | }, 87 | :credentials => { 88 | :token => 'ABCDEF...', # OAuth 2.0 access_token, which you may wish to store 89 | :expires_at => 1321747205, # when the access token expires (it always will) 90 | :expires => true # this will always be true 91 | }, 92 | :extra => { 93 | :raw_info => { 94 | :id => '1234567', 95 | :name => 'Joe Bloggs', 96 | :first_name => 'Joe', 97 | :last_name => 'Bloggs', 98 | :link => 'http://www.facebook.com/jbloggs', 99 | :username => 'jbloggs', 100 | :location => { :id => '123456789', :name => 'Palo Alto, California' }, 101 | :gender => 'male', 102 | :email => 'joe@bloggs.com', 103 | :timezone => -8, 104 | :locale => 'en_US', 105 | :verified => true, 106 | :updated_time => '2011-11-11T06:21:03+0000' 107 | } 108 | } 109 | } 110 | ``` 111 | 112 | The precise information available may depend on the permissions which you request. 113 | 114 | ## Client-side Flow 115 | 116 | You can use the Facebook Javascript SDK with `FB.login`, and just hit the callback endpoint (`/auth/facebook/callback` by default) once the user has authenticated in the success callback. 117 | 118 | Note that you must enable cookies in the `FB.init` config for this process to work. 119 | 120 | See the example Sinatra app under `example/` and read the [Facebook docs on Client-Side Authentication](https://developers.facebook.com/docs/authentication/client-side/) for more details. 121 | 122 | ### How it Works 123 | 124 | The client-side flow is supported by parsing the authorization code from the signed request which Facebook places in a cookie. 125 | 126 | When you call `/auth/facebook/callback` in the success callback of `FB.login` that will pass the cookie back to the server. omniauth-facebook will see this cookie and: 127 | 128 | 1. parse it, 129 | 2. extract the authorization code contained in it 130 | 3. and hit Facebook and obtain an access token which will get placed in the `request.env['omniauth.auth']['credentials']` hash. 131 | 132 | Note that this access token will be the same token obtained and available in the client through the hash [as detailed in the Facebook docs](https://developers.facebook.com/docs/authentication/client-side/). 133 | 134 | ## Canvas Apps 135 | 136 | Canvas apps will send a signed request with the initial POST, therefore you *can* (if it makes sense for your app) pass this to the authorize endpoint (`/auth/facebook` by default) in the querystring. 137 | 138 | There are then 2 scenarios for what happens next: 139 | 140 | 1. A user has already granted access to your app, this will contain an access token. In this case, omniauth-facebook will skip asking the user for authentication and immediately redirect to the callback endpoint (`/auth/facebook/callback` by default) with the access token present in the `request.env['omniauth.auth']['credentials']` hash. 141 | 142 | 2. A user has not granted access to your app, and the signed request *will not* contain an access token. In this case omniauth-facebook will simply follow the standard auth flow. 143 | 144 | Take a look at [the example Sinatra app for one option of how you can integrate with a canvas page](https://github.com/mkdynamic/omniauth-facebook/blob/master/example/config.ru). 145 | 146 | Bear in mind you have several [options](https://developers.facebook.com/docs/opengraph/authentication). Read [the Facebook docs on canvas page authentication](https://developers.facebook.com/docs/authentication/canvas/) for more info. 147 | 148 | ## Token Expiry 149 | 150 | Since Facebook deprecated the `offline_access` permission, this has become more complex. The expiration time of the access token you obtain will depend on which flow you are using. See below for more details. 151 | 152 | ### Client-Side Flow 153 | 154 | If you use the client-side flow, Facebook will give you back a short lived access token (~ 2 hours). 155 | 156 | You can exchange this short lived access token for a longer lived version. Read the [Facebook docs about the offline_access deprecation](https://developers.facebook.com/roadmap/offline-access-removal/) for more information. 157 | 158 | ### Server-Side Flow 159 | 160 | If you use the server-side flow, Facebook will give you back a longer lived access token (~ 60 days). 161 | 162 | If you're having issue getting a long lived token with the server-side flow, make sure to enable the 'deprecate offline_access setting' in you Facebook app config. Read the [Facebook docs about the offline_access deprecation](https://developers.facebook.com/roadmap/offline-access-removal/) for more information. 163 | 164 | ## Supported Rubies 165 | 166 | Actively tested with the following Ruby versions: 167 | 168 | - MRI 2.1.0 169 | - MRI 2.0.0 170 | - MRI 1.9.3 171 | - MRI 1.9.2 172 | - MRI 1.8.7 173 | - JRuby 1.7.9 174 | - Rubinius (latest stable) 175 | 176 | ## License 177 | 178 | Copyright (c) 2012 by Mark Dodwell 179 | 180 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 181 | 182 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 183 | 184 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 185 | 186 | 187 | [![Bitdeli Badge](https://d2weczhvl823v0.cloudfront.net/mkdynamic/omniauth-facebook/trend.png)](https://bitdeli.com/free "Bitdeli Badge") 188 | 189 | -------------------------------------------------------------------------------- /test/test.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | require 'omniauth-facebook' 3 | require 'openssl' 4 | require 'base64' 5 | 6 | class StrategyTest < StrategyTestCase 7 | include OAuth2StrategyTests 8 | end 9 | 10 | class ClientTest < StrategyTestCase 11 | test 'has correct Facebook site' do 12 | assert_equal 'https://graph.facebook.com', strategy.client.site 13 | end 14 | 15 | test 'has correct authorize url' do 16 | assert_equal 'https://www.facebook.com/dialog/oauth', strategy.client.options[:authorize_url] 17 | end 18 | 19 | test 'has correct token url' do 20 | assert_equal '/oauth/access_token', strategy.client.options[:token_url] 21 | end 22 | end 23 | 24 | class CallbackUrlTest < StrategyTestCase 25 | test "returns the default callback url" do 26 | url_base = 'http://auth.request.com' 27 | @request.stubs(:url).returns("#{url_base}/some/page") 28 | strategy.stubs(:script_name).returns('') # as not to depend on Rack env 29 | assert_equal "#{url_base}/auth/facebook/callback", strategy.callback_url 30 | end 31 | 32 | test "returns path from callback_path option" do 33 | @options = { :callback_path => "/auth/FB/done"} 34 | url_base = 'http://auth.request.com' 35 | @request.stubs(:url).returns("#{url_base}/page/path") 36 | strategy.stubs(:script_name).returns('') # as not to depend on Rack env 37 | assert_equal "#{url_base}/auth/FB/done", strategy.callback_url 38 | end 39 | 40 | test "returns url from callback_url option" do 41 | url = 'https://auth.myapp.com/auth/fb/callback' 42 | @options = { :callback_url => url } 43 | assert_equal url, strategy.callback_url 44 | end 45 | end 46 | 47 | class AuthorizeParamsTest < StrategyTestCase 48 | test 'includes default scope for email' do 49 | assert strategy.authorize_params.is_a?(Hash) 50 | assert_equal 'email', strategy.authorize_params[:scope] 51 | end 52 | 53 | test 'includes display parameter from request when present' do 54 | @request.stubs(:params).returns({ 'display' => 'touch' }) 55 | assert strategy.authorize_params.is_a?(Hash) 56 | assert_equal 'touch', strategy.authorize_params[:display] 57 | end 58 | 59 | test 'includes auth_type parameter from request when present' do 60 | @request.stubs(:params).returns({ 'auth_type' => 'reauthenticate' }) 61 | assert strategy.authorize_params.is_a?(Hash) 62 | assert_equal 'reauthenticate', strategy.authorize_params[:auth_type] 63 | end 64 | 65 | test 'overrides default scope with parameter passed from request' do 66 | @request.stubs(:params).returns({ 'scope' => 'email' }) 67 | assert strategy.authorize_params.is_a?(Hash) 68 | assert_equal 'email', strategy.authorize_params[:scope] 69 | end 70 | end 71 | 72 | class TokeParamsTest < StrategyTestCase 73 | test 'has correct parse strategy' do 74 | assert_equal :query, strategy.token_params[:parse] 75 | end 76 | end 77 | 78 | class AccessTokenOptionsTest < StrategyTestCase 79 | test 'has correct param name by default' do 80 | assert_equal 'access_token', strategy.access_token_options[:param_name] 81 | end 82 | 83 | test 'has correct header format by default' do 84 | assert_equal 'OAuth %s', strategy.access_token_options[:header_format] 85 | end 86 | end 87 | 88 | class UidTest < StrategyTestCase 89 | def setup 90 | super 91 | strategy.stubs(:raw_info).returns({ 'id' => '123' }) 92 | end 93 | 94 | test 'returns the id from raw_info' do 95 | assert_equal '123', strategy.uid 96 | end 97 | end 98 | 99 | class InfoTest < StrategyTestCase 100 | test 'returns the secure facebook avatar url when `secure_image_url` option is specified' do 101 | @options = { :secure_image_url => true } 102 | raw_info = { 'name' => 'Fred Smith', 'id' => '321' } 103 | strategy.stubs(:raw_info).returns(raw_info) 104 | assert_equal 'https://graph.facebook.com/321/picture', strategy.info['image'] 105 | end 106 | 107 | test 'returns the image with size specified in the `image_size` option' do 108 | @options = { :image_size => 'normal' } 109 | raw_info = { 'name' => 'Fred Smith', 'id' => '321' } 110 | strategy.stubs(:raw_info).returns(raw_info) 111 | assert_equal 'http://graph.facebook.com/321/picture?type=normal', strategy.info['image'] 112 | end 113 | 114 | test 'returns the image with width and height specified in the `image_size` option' do 115 | @options = { :image_size => { :width => 123, :height => 987 } } 116 | raw_info = { 'name' => 'Fred Smith', 'id' => '321' } 117 | strategy.stubs(:raw_info).returns(raw_info) 118 | assert_match 'width=123', strategy.info['image'] 119 | assert_match 'height=987', strategy.info['image'] 120 | assert_match 'http://graph.facebook.com/321/picture?', strategy.info['image'] 121 | end 122 | end 123 | 124 | class InfoTestOptionalDataPresent < StrategyTestCase 125 | def setup 126 | super 127 | @raw_info ||= { 'name' => 'Fred Smith' } 128 | strategy.stubs(:raw_info).returns(@raw_info) 129 | end 130 | 131 | test 'returns the name' do 132 | assert_equal 'Fred Smith', strategy.info['name'] 133 | end 134 | 135 | test 'returns the email' do 136 | @raw_info['email'] = 'fred@smith.com' 137 | assert_equal 'fred@smith.com', strategy.info['email'] 138 | end 139 | 140 | test 'returns the username as nickname' do 141 | @raw_info['username'] = 'fredsmith' 142 | assert_equal 'fredsmith', strategy.info['nickname'] 143 | end 144 | 145 | test 'returns the first name' do 146 | @raw_info['first_name'] = 'Fred' 147 | assert_equal 'Fred', strategy.info['first_name'] 148 | end 149 | 150 | test 'returns the last name' do 151 | @raw_info['last_name'] = 'Smith' 152 | assert_equal 'Smith', strategy.info['last_name'] 153 | end 154 | 155 | test 'returns the location name as location' do 156 | @raw_info['location'] = { 'id' => '104022926303756', 'name' => 'Palo Alto, California' } 157 | assert_equal 'Palo Alto, California', strategy.info['location'] 158 | end 159 | 160 | test 'returns bio as description' do 161 | @raw_info['bio'] = 'I am great' 162 | assert_equal 'I am great', strategy.info['description'] 163 | end 164 | 165 | test 'returns the facebook avatar url' do 166 | @raw_info['id'] = '321' 167 | assert_equal 'http://graph.facebook.com/321/picture', strategy.info['image'] 168 | end 169 | 170 | test 'returns the Facebook link as the Facebook url' do 171 | @raw_info['link'] = 'http://www.facebook.com/fredsmith' 172 | assert_kind_of Hash, strategy.info['urls'] 173 | assert_equal 'http://www.facebook.com/fredsmith', strategy.info['urls']['Facebook'] 174 | end 175 | 176 | test 'returns website url' do 177 | @raw_info['website'] = 'https://my-wonderful-site.com' 178 | assert_kind_of Hash, strategy.info['urls'] 179 | assert_equal 'https://my-wonderful-site.com', strategy.info['urls']['Website'] 180 | end 181 | 182 | test 'return both Facebook link and website urls' do 183 | @raw_info['link'] = 'http://www.facebook.com/fredsmith' 184 | @raw_info['website'] = 'https://my-wonderful-site.com' 185 | assert_kind_of Hash, strategy.info['urls'] 186 | assert_equal 'http://www.facebook.com/fredsmith', strategy.info['urls']['Facebook'] 187 | assert_equal 'https://my-wonderful-site.com', strategy.info['urls']['Website'] 188 | end 189 | 190 | test 'returns the positive verified status' do 191 | @raw_info['verified'] = true 192 | assert strategy.info['verified'] 193 | end 194 | 195 | test 'returns the negative verified status' do 196 | @raw_info['verified'] = false 197 | refute strategy.info['verified'] 198 | end 199 | end 200 | 201 | class InfoTestOptionalDataNotPresent < StrategyTestCase 202 | def setup 203 | super 204 | @raw_info ||= { 'name' => 'Fred Smith' } 205 | strategy.stubs(:raw_info).returns(@raw_info) 206 | end 207 | 208 | test 'has no email key' do 209 | refute_has_key 'email', strategy.info 210 | end 211 | 212 | test 'has no nickname key' do 213 | refute_has_key 'nickname', strategy.info 214 | end 215 | 216 | test 'has no first name key' do 217 | refute_has_key 'first_name', strategy.info 218 | end 219 | 220 | test 'has no last name key' do 221 | refute_has_key 'last_name', strategy.info 222 | end 223 | 224 | test 'has no location key' do 225 | refute_has_key 'location', strategy.info 226 | end 227 | 228 | test 'has no description key' do 229 | refute_has_key 'description', strategy.info 230 | end 231 | 232 | test 'has no urls' do 233 | refute_has_key 'urls', strategy.info 234 | end 235 | 236 | test 'has no verified key' do 237 | refute_has_key 'verified', strategy.info 238 | end 239 | end 240 | 241 | class RawInfoTest < StrategyTestCase 242 | def setup 243 | super 244 | @access_token = stub('OAuth2::AccessToken') 245 | @appsecret_proof = 'appsecret_proof' 246 | @options = {:appsecret_proof => @appsecret_proof} 247 | end 248 | 249 | test 'performs a GET to https://graph.facebook.com/me' do 250 | strategy.stubs(:appsecret_proof).returns(@appsecret_proof) 251 | strategy.stubs(:access_token).returns(@access_token) 252 | params = {:params => @options} 253 | @access_token.expects(:get).with('/me', params).returns(stub_everything('OAuth2::Response')) 254 | strategy.raw_info 255 | end 256 | 257 | test 'performs a GET to https://graph.facebook.com/me with locale' do 258 | @options.merge!({ :locale => 'cs_CZ' }) 259 | strategy.stubs(:access_token).returns(@access_token) 260 | strategy.stubs(:appsecret_proof).returns(@appsecret_proof) 261 | params = {:params => @options} 262 | @access_token.expects(:get).with('/me', params).returns(stub_everything('OAuth2::Response')) 263 | strategy.raw_info 264 | end 265 | 266 | test 'performs a GET to https://graph.facebook.com/me with info_fields' do 267 | @options.merge!({:info_fields => 'about'}) 268 | strategy.stubs(:access_token).returns(@access_token) 269 | strategy.stubs(:appsecret_proof).returns(@appsecret_proof) 270 | params = {:params => {:appsecret_proof => @appsecret_proof, :fields => 'about'}} 271 | @access_token.expects(:get).with('/me', params).returns(stub_everything('OAuth2::Response')) 272 | strategy.raw_info 273 | end 274 | 275 | test 'returns a Hash' do 276 | strategy.stubs(:access_token).returns(@access_token) 277 | strategy.stubs(:appsecret_proof).returns(@appsecret_proof) 278 | raw_response = stub('Faraday::Response') 279 | raw_response.stubs(:body).returns('{ "ohai": "thar" }') 280 | raw_response.stubs(:status).returns(200) 281 | raw_response.stubs(:headers).returns({'Content-Type' => 'application/json' }) 282 | oauth2_response = OAuth2::Response.new(raw_response) 283 | params = {:params => @options} 284 | @access_token.stubs(:get).with('/me', params).returns(oauth2_response) 285 | assert_kind_of Hash, strategy.raw_info 286 | assert_equal 'thar', strategy.raw_info['ohai'] 287 | end 288 | 289 | test 'returns an empty hash when the response is false' do 290 | strategy.stubs(:access_token).returns(@access_token) 291 | strategy.stubs(:appsecret_proof).returns(@appsecret_proof) 292 | oauth2_response = stub('OAuth2::Response', :parsed => false) 293 | params = {:params => @options} 294 | @access_token.stubs(:get).with('/me', params).returns(oauth2_response) 295 | assert_kind_of Hash, strategy.raw_info 296 | assert_equal({}, strategy.raw_info) 297 | end 298 | 299 | test 'should not include raw_info in extras hash when skip_info is specified' do 300 | @options = { :skip_info => true } 301 | strategy.stubs(:raw_info).returns({:foo => 'bar' }) 302 | refute_has_key 'raw_info', strategy.extra 303 | end 304 | end 305 | 306 | class CredentialsTest < StrategyTestCase 307 | def setup 308 | super 309 | @access_token = stub('OAuth2::AccessToken') 310 | @access_token.stubs(:token) 311 | @access_token.stubs(:expires?) 312 | @access_token.stubs(:expires_at) 313 | @access_token.stubs(:refresh_token) 314 | strategy.stubs(:access_token).returns(@access_token) 315 | end 316 | 317 | test 'returns a Hash' do 318 | assert_kind_of Hash, strategy.credentials 319 | end 320 | 321 | test 'returns the token' do 322 | @access_token.stubs(:token).returns('123') 323 | assert_equal '123', strategy.credentials['token'] 324 | end 325 | 326 | test 'returns the expiry status' do 327 | @access_token.stubs(:expires?).returns(true) 328 | assert strategy.credentials['expires'] 329 | 330 | @access_token.stubs(:expires?).returns(false) 331 | refute strategy.credentials['expires'] 332 | end 333 | 334 | test 'returns the refresh token and expiry time when expiring' do 335 | ten_mins_from_now = (Time.now + 600).to_i 336 | @access_token.stubs(:expires?).returns(true) 337 | @access_token.stubs(:refresh_token).returns('321') 338 | @access_token.stubs(:expires_at).returns(ten_mins_from_now) 339 | assert_equal '321', strategy.credentials['refresh_token'] 340 | assert_equal ten_mins_from_now, strategy.credentials['expires_at'] 341 | end 342 | 343 | test 'does not return the refresh token when test is nil and expiring' do 344 | @access_token.stubs(:expires?).returns(true) 345 | @access_token.stubs(:refresh_token).returns(nil) 346 | assert_nil strategy.credentials['refresh_token'] 347 | refute_has_key 'refresh_token', strategy.credentials 348 | end 349 | 350 | test 'does not return the refresh token when not expiring' do 351 | @access_token.stubs(:expires?).returns(false) 352 | @access_token.stubs(:refresh_token).returns('XXX') 353 | assert_nil strategy.credentials['refresh_token'] 354 | refute_has_key 'refresh_token', strategy.credentials 355 | end 356 | end 357 | 358 | class ExtraTest < StrategyTestCase 359 | def setup 360 | super 361 | @raw_info = { 'name' => 'Fred Smith' } 362 | strategy.stubs(:raw_info).returns(@raw_info) 363 | end 364 | 365 | test 'returns a Hash' do 366 | assert_kind_of Hash, strategy.extra 367 | end 368 | 369 | test 'contains raw info' do 370 | assert_equal({ 'raw_info' => @raw_info }, strategy.extra) 371 | end 372 | end 373 | 374 | module SignedRequestHelpers 375 | def signed_request(payload, secret) 376 | encoded_payload = base64_encode_url(MultiJson.encode(payload)) 377 | encoded_signature = base64_encode_url(signature(encoded_payload, secret)) 378 | [encoded_signature, encoded_payload].join('.') 379 | end 380 | 381 | def base64_encode_url(value) 382 | Base64.encode64(value).tr('+/', '-_').gsub(/\n/, '') 383 | end 384 | 385 | def signature(payload, secret, algorithm = OpenSSL::Digest::SHA256.new) 386 | OpenSSL::HMAC.digest(algorithm, secret, payload) 387 | end 388 | end 389 | 390 | module SignedRequestTests 391 | class TestCase < StrategyTestCase 392 | include SignedRequestHelpers 393 | end 394 | 395 | class CookieAndParamNotPresentTest < TestCase 396 | test 'is nil' do 397 | assert_nil strategy.send(:signed_request) 398 | end 399 | 400 | test 'throws an error on calling build_access_token' do 401 | assert_equal 'must pass either a `code` parameter or a signed request (via `signed_request` parameter or a `fbsr_XXX` cookie)', 402 | assert_raises(OmniAuth::Strategies::Facebook::NoAuthorizationCodeError) { strategy.send(:build_access_token) }.message 403 | end 404 | end 405 | 406 | class CookiePresentTest < TestCase 407 | def setup(algo = nil) 408 | super() 409 | @payload = { 410 | 'algorithm' => algo || 'HMAC-SHA256', 411 | 'code' => 'm4c0d3z', 412 | 'issued_at' => Time.now.to_i, 413 | 'user_id' => '123456' 414 | } 415 | 416 | @request.stubs(:cookies).returns({"fbsr_#{@client_id}" => signed_request(@payload, @client_secret)}) 417 | end 418 | 419 | test 'parses the access code out from the cookie' do 420 | assert_equal @payload, strategy.send(:signed_request) 421 | end 422 | 423 | test 'throws an error if the algorithm is unknown' do 424 | setup('UNKNOWN-ALGO') 425 | assert_equal "unknown algorithm: UNKNOWN-ALGO", assert_raises(OmniAuth::Strategies::Facebook::UnknownSignatureAlgorithmError) { strategy.send(:signed_request) }.message 426 | end 427 | end 428 | 429 | class ParamPresentTest < TestCase 430 | def setup(algo = nil) 431 | super() 432 | @payload = { 433 | 'algorithm' => algo || 'HMAC-SHA256', 434 | 'oauth_token' => 'XXX', 435 | 'issued_at' => Time.now.to_i, 436 | 'user_id' => '123456' 437 | } 438 | 439 | @request.stubs(:params).returns({'signed_request' => signed_request(@payload, @client_secret)}) 440 | end 441 | 442 | test 'parses the access code out from the param' do 443 | assert_equal @payload, strategy.send(:signed_request) 444 | end 445 | 446 | test 'throws an error if the algorithm is unknown' do 447 | setup('UNKNOWN-ALGO') 448 | assert_equal "unknown algorithm: UNKNOWN-ALGO", assert_raises(OmniAuth::Strategies::Facebook::UnknownSignatureAlgorithmError) { strategy.send(:signed_request) }.message 449 | end 450 | end 451 | 452 | class CookieAndParamPresentTest < TestCase 453 | def setup 454 | super 455 | @payload_from_cookie = { 456 | 'algorithm' => 'HMAC-SHA256', 457 | 'from' => 'cookie' 458 | } 459 | 460 | @request.stubs(:cookies).returns({"fbsr_#{@client_id}" => signed_request(@payload_from_cookie, @client_secret)}) 461 | 462 | @payload_from_param = { 463 | 'algorithm' => 'HMAC-SHA256', 464 | 'from' => 'param' 465 | } 466 | 467 | @request.stubs(:params).returns({'signed_request' => signed_request(@payload_from_param, @client_secret)}) 468 | end 469 | 470 | test 'picks param over cookie' do 471 | assert_equal @payload_from_param, strategy.send(:signed_request) 472 | end 473 | end 474 | 475 | class EmptySignedRequestTest < TestCase 476 | def setup 477 | super 478 | @request.stubs(:params).returns({'signed_request' => ''}) 479 | end 480 | 481 | test 'empty param' do 482 | assert_equal nil, strategy.send(:signed_request) 483 | end 484 | end 485 | 486 | end 487 | 488 | class RequestPhaseWithSignedRequestTest < StrategyTestCase 489 | include SignedRequestHelpers 490 | 491 | def setup 492 | super 493 | 494 | payload = { 495 | 'algorithm' => 'HMAC-SHA256', 496 | 'oauth_token' => 'm4c0d3z' 497 | } 498 | @raw_signed_request = signed_request(payload, @client_secret) 499 | @request.stubs(:params).returns("signed_request" => @raw_signed_request) 500 | 501 | strategy.stubs(:callback_url).returns('/') 502 | end 503 | 504 | test 'redirects to callback passing along signed request' do 505 | strategy.expects(:redirect).with("/?signed_request=#{Rack::Utils.escape(@raw_signed_request)}").once 506 | strategy.request_phase 507 | end 508 | end 509 | 510 | module BuildAccessTokenTests 511 | class TestCase < StrategyTestCase 512 | include SignedRequestHelpers 513 | end 514 | 515 | class ParamsContainSignedRequestWithAccessTokenTest < TestCase 516 | def setup 517 | super 518 | 519 | @payload = { 520 | 'algorithm' => 'HMAC-SHA256', 521 | 'oauth_token' => 'm4c0d3z', 522 | 'expires' => Time.now.to_i 523 | } 524 | @raw_signed_request = signed_request(@payload, @client_secret) 525 | @request.stubs(:params).returns({"signed_request" => @raw_signed_request}) 526 | 527 | strategy.stubs(:callback_url).returns('/') 528 | end 529 | 530 | test 'returns a new access token from the signed request' do 531 | result = strategy.send(:build_access_token) 532 | assert_kind_of ::OAuth2::AccessToken, result 533 | assert_equal @payload['oauth_token'], result.token 534 | end 535 | 536 | test 'returns an access token with the correct expiry time' do 537 | result = strategy.send(:build_access_token) 538 | assert_equal @payload['expires'], result.expires_at 539 | end 540 | end 541 | end 542 | --------------------------------------------------------------------------------