diff --git a/CHANGELOG.md b/CHANGELOG.md index 12eff04..8b0e345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 5.1.0 (2019-02-12) + +Changes: + + - support for an access_token param, validated by facebook's debug token api + ## 5.0.0 (2018-03-29) Changes: diff --git a/README.md b/README.md index 09cf451..a246e84 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,11 @@ When you call `/auth/facebook/callback` in the success callback of `FB.login` th 2. extract the authorization code contained in it 3. and hit Facebook and obtain an access token which will get placed in the `request.env['omniauth.auth']['credentials']` hash. +## Client-side Flow with Facebook Android and ioS SDK + +A long lived access token is returned by the native sdks. This flow is supported by sending an "access_token" query parameter to your callback. This token is then verified with facebook using your client_id and client_secret before being used. +Be sure to leave CSRF protection on for this method of authentication. + ## Token Expiry The expiration time of the access token you obtain will depend on which flow you are using. diff --git a/lib/omniauth/strategies/facebook.rb b/lib/omniauth/strategies/facebook.rb index 12a9003..61a12d5 100644 --- a/lib/omniauth/strategies/facebook.rb +++ b/lib/omniauth/strategies/facebook.rb @@ -8,6 +8,8 @@ module OmniAuth module Strategies class Facebook < OmniAuth::Strategies::OAuth2 class NoAuthorizationCodeError < StandardError; end + class MissingScopesError < StandardError; end + class AppIdMismatchError < StandardError; end DEFAULT_SCOPE = 'email' @@ -63,9 +65,13 @@ def info_options end def callback_phase - with_authorization_code! do + with_authorization_parameter! do super end + rescue AppIdMismatchError => e + fail!(:app_id_mismatch, e) + rescue MissingScopesError => e + fail!(:missing_scopes, e) rescue NoAuthorizationCodeError => e fail!(:no_authorization_code, e) rescue OmniAuth::Facebook::SignedRequest::UnknownSignatureAlgorithmError => e @@ -76,7 +82,7 @@ def callback_phase # phase and it must match during the access_token phase: # https://github.com/facebook/facebook-php-sdk/blob/master/src/base_facebook.php#L477 def callback_url - if @authorization_code_from_signed_request_in_cookie + if defined?(@auth_code_from_cookie) && @auth_code_from_cookie '' else # Fixes regression in omniauth-oauth2 v1.4.0 by https://github.com/intridea/omniauth-oauth2/commit/85fdbe117c2a4400d001a6368cc359d88f40abc7 @@ -107,31 +113,47 @@ def authorize_params protected def build_access_token - super.tap do |token| - token.options.merge!(access_token_options) + if request.params["access_token"] + build_access_token_from_request(request.params["access_token"]) + else + super.tap do |token| + token.options.merge!(access_token_options) + end end end private - def signed_request_from_cookie - @signed_request_from_cookie ||= raw_signed_request_from_cookie && OmniAuth::Facebook::SignedRequest.parse(raw_signed_request_from_cookie, client.secret) + def build_access_token_from_request(access_token_param) + token_hash = { :access_token => access_token_param } + access_token = ::OAuth2::AccessToken.from_hash(client, token_hash.update(access_token_options)) + verify_access_token!(access_token) + return access_token end - def raw_signed_request_from_cookie - request.cookies["fbsr_#{client.id}"] + def verify_access_token!(access_token) + opts = { params: { input_token: access_token.token, access_token: app_access_token }} + token_info = access_token.get('/debug_token', opts) + missing_scopes = authorize_params.scope.split(',').collect(&:strip) - token_info.parsed.fetch("data", {}).fetch("scopes", []) + raise MissingScopesError, "Missing scopes #{missing_scopes.join(', ')}" if missing_scopes.any? + rescue ::OAuth2::Error => e + raise AppIdMismatchError, "Failed to validate token: #{e.message}" + end + + def app_access_token + "%s|%s" % [client.id, client.secret] end # Picks the authorization code in order, from: # # 1. The request 'code' param (manual callback from standard server-side flow) # 2. A signed request from cookie (passed from the client during the client-side flow) - def with_authorization_code! - if request.params.key?('code') + def with_authorization_parameter! + if request.params.key?('code') || request.params.key?('access_token') yield elsif code_from_signed_request = signed_request_from_cookie && signed_request_from_cookie['code'] request.params['code'] = code_from_signed_request - @authorization_code_from_signed_request_in_cookie = true + @auth_code_from_cookie = true # NOTE The code from the signed fbsr_XXX cookie is set by the FB JS SDK will confirm that the identity of the # user contained in the signed request matches the user loading the app. original_provider_ignores_state = options.provider_ignores_state @@ -140,14 +162,22 @@ def with_authorization_code! yield ensure request.params.delete('code') - @authorization_code_from_signed_request_in_cookie = false + @auth_code_from_cookie = false options.provider_ignores_state = original_provider_ignores_state end else - raise NoAuthorizationCodeError, 'must pass either a `code` (via URL or by an `fbsr_XXX` signed request cookie)' + raise NoAuthorizationCodeError, 'must pass either a `access_token` param or a `code` (via URL param or by an `fbsr_XXX` signed request cookie)' end end + def signed_request_from_cookie + @signed_request_from_cookie ||= raw_signed_request_from_cookie && OmniAuth::Facebook::SignedRequest.parse(raw_signed_request_from_cookie, client.secret) + end + + def raw_signed_request_from_cookie + request.cookies["fbsr_#{client.id}"] + end + def prune!(hash) hash.delete_if do |_, value| prune!(value) if value.is_a?(Hash) diff --git a/test/helper.rb b/test/helper.rb index bd1b82b..e7e62ba 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -32,7 +32,7 @@ class TestCase < Minitest::Test class StrategyTestCase < TestCase def setup - @request = stub('Request') + @request = stub('Request', {}) @request.stubs(:params).returns({}) @request.stubs(:cookies).returns({}) @request.stubs(:env).returns({}) @@ -41,6 +41,8 @@ def setup @client_id = '123' @client_secret = '53cr3tz' + + @options = nil end def strategy diff --git a/test/strategy_test.rb b/test/strategy_test.rb index 3cc4a9a..3954260 100644 --- a/test/strategy_test.rb +++ b/test/strategy_test.rb @@ -391,118 +391,152 @@ def setup end end -module SignedRequestHelpers - def signed_request(payload, secret) - encoded_payload = base64_encode_url(MultiJson.encode(payload)) - encoded_signature = base64_encode_url(signature(encoded_payload, secret)) - [encoded_signature, encoded_payload].join('.') - end - - def base64_encode_url(value) - Base64.encode64(value).tr('+/', '-_').gsub(/\n/, '') - end - - def signature(payload, secret, algorithm = OpenSSL::Digest::SHA256.new) - OpenSSL::HMAC.digest(algorithm, secret, payload) - end -end - -module SignedRequestTests - class TestCase < StrategyTestCase - include SignedRequestHelpers - end - - class CookieAndParamNotPresentTest < TestCase +module GettingAccessTokenTests + class CookieAndParamNotPresentTest < StrategyTestCase test 'is nil' do assert_nil strategy.send(:signed_request_from_cookie) end test 'throws an error on calling build_access_token' do - assert_raises(OmniAuth::Strategies::Facebook::NoAuthorizationCodeError) { strategy.send(:with_authorization_code!) {} } - end - end - - class CookiePresentTest < TestCase - def setup(algo = nil) - super() - @payload = { - 'algorithm' => algo || 'HMAC-SHA256', - 'code' => 'm4c0d3z', - 'issued_at' => Time.now.to_i, - 'user_id' => '123456' - } - - @request.stubs(:cookies).returns({"fbsr_#{@client_id}" => signed_request(@payload, @client_secret)}) - end - - test 'parses the access code out from the cookie' do - assert_equal @payload, strategy.send(:signed_request_from_cookie) - end - - test 'throws an error if the algorithm is unknown' do - setup('UNKNOWN-ALGO') - assert_equal "unknown algorithm: UNKNOWN-ALGO", assert_raises(OmniAuth::Facebook::SignedRequest::UnknownSignatureAlgorithmError) { strategy.send(:signed_request_from_cookie) }.message + assert_raises(OmniAuth::Strategies::Facebook::NoAuthorizationCodeError) { strategy.send(:with_authorization_parameter!) {} } end end - class EmptySignedRequestTest < TestCase - def setup - super - @request.stubs(:params).returns({'signed_request' => ''}) - end - - test 'empty param' do - assert_equal nil, strategy.send(:signed_request_from_cookie) + class MissingParamsAndCookieRequestTest < StrategyTestCase + test 'calls fail! when a code or access_token is not included in the params' do + strategy.expects(:fail!).times(1).with(:no_authorization_code, kind_of(OmniAuth::Strategies::Facebook::NoAuthorizationCodeError)) + strategy.callback_phase end end - class MissingCodeInParamsRequestTest < TestCase + class BadTokenTest < StrategyTestCase def setup super - @request.stubs(:params).returns({}) + @access_token = stub('OAuth2::AccessToken') + @access_token.stubs(:token).returns('fake_token') + ::OAuth2::AccessToken.stubs(:from_hash).returns(@access_token) + @request.stubs(:params).returns({'access_token' => 'fake_token'}) + strategy.stubs(:app_access_token).returns('other_token') end - test 'calls fail! when a code is not included in the params' do - strategy.expects(:fail!).times(1).with(:no_authorization_code, kind_of(OmniAuth::Strategies::Facebook::NoAuthorizationCodeError)) - strategy.callback_phase + test 'throws error when access token bad' do + params = { params: { input_token: 'fake_token', access_token: 'other_token' } } + @access_token.expects(:get).with('/debug_token', params).raises(::OAuth2::Error.new(stub_everything('Faraday::Response'))) + strategy.stubs(:access_token).returns(@access_token) + assert_raises(OmniAuth::Strategies::Facebook::AppIdMismatchError) { strategy.send(:build_access_token) {} } end - end - class MissingCodeInCookieRequestTest < TestCase - def setup(algo = nil) - super() - @payload = { - 'algorithm' => algo || 'HMAC-SHA256', - 'code' => nil, - 'issued_at' => Time.now.to_i, - 'user_id' => '123456' - } - - @request.stubs(:cookies).returns({"fbsr_#{@client_id}" => signed_request(@payload, @client_secret)}) + test 'fails when good token with missing scope' do + params = { params: { input_token: 'fake_token', access_token: 'other_token' } } + missing_scopes_response = stub_everything('Faraday::Response') + missing_scopes_response.stubs(:parsed).returns({ 'data' => {'scopes' => [] }}) + @access_token.expects(:get).with('/debug_token', params).returns(missing_scopes_response) + assert_raises(OmniAuth::Strategies::Facebook::MissingScopesError) { strategy.send(:build_access_token) {} } end - test 'calls fail! when a code is not included in the cookie' do - strategy.expects(:fail!).times(1).with(:no_authorization_code, kind_of(OmniAuth::Strategies::Facebook::NoAuthorizationCodeError)) - strategy.callback_phase + test 'succeeds when good token and scope' do + params = { params: { input_token: 'fake_token', access_token: 'other_token' } } + good_response = stub_everything('Faraday::Response') + good_response.stubs(:parsed).returns({ 'data' => {'scopes' => %w(public_profile email)}}) + @access_token.expects(:get).with('/debug_token', params).returns(good_response) + assert_equal 'fake_token', strategy.send(:build_access_token).token end end - - class UnknownAlgorithmInCookieRequestTest < TestCase - def setup - super() - @payload = { - 'algorithm' => 'UNKNOWN-ALGO', - 'code' => nil, - 'issued_at' => Time.now.to_i, - 'user_id' => '123456' - } - - @request.stubs(:cookies).returns({"fbsr_#{@client_id}" => signed_request(@payload, @client_secret)}) + # Fails when good token with missing scope + # Passes when param and good token + module VerifiedAccessTokenTests + module SignedRequestHelpers + def signed_request(payload, secret) + encoded_payload = base64_encode_url(MultiJson.encode(payload)) + encoded_signature = base64_encode_url(signature(encoded_payload, secret)) + [encoded_signature, encoded_payload].join('.') + end + + def base64_encode_url(value) + Base64.encode64(value).tr('+/', '-_').gsub(/\n/, '') + end + + def signature(payload, secret, algorithm = OpenSSL::Digest::SHA256.new) + OpenSSL::HMAC.digest(algorithm, secret, payload) + end end - test 'calls fail! when an algorithm is unknown' do - strategy.expects(:fail!).times(1).with(:unknown_signature_algorithm, kind_of(OmniAuth::Facebook::SignedRequest::UnknownSignatureAlgorithmError)) - strategy.callback_phase + module SignedRequestTests + class TestCase < StrategyTestCase + include SignedRequestHelpers + end + + class CookiePresentTest < TestCase + def setup(algo = nil) + super() + @payload = { + 'algorithm' => algo || 'HMAC-SHA256', + 'code' => 'm4c0d3z', + 'issued_at' => Time.now.to_i, + 'user_id' => '123456' + } + + @request.stubs(:cookies).returns({"fbsr_#{@client_id}" => signed_request(@payload, @client_secret)}) + end + + test 'parses the access code out from the cookie' do + assert_equal @payload, strategy.send(:signed_request_from_cookie) + end + + test 'throws an error if the algorithm is unknown' do + setup('UNKNOWN-ALGO') + assert_equal "unknown algorithm: UNKNOWN-ALGO", assert_raises(OmniAuth::Facebook::SignedRequest::UnknownSignatureAlgorithmError) { strategy.send(:signed_request_from_cookie) }.message + end + end + + class EmptySignedRequestTest < TestCase + def setup + super + @request.stubs(:params).returns({'signed_request' => ''}) + end + + test 'empty param' do + assert_nil strategy.send(:signed_request_from_cookie) + end + end + + class MissingCodeInCookieRequestTest < TestCase + def setup(algo = nil) + super() + @payload = { + 'algorithm' => algo || 'HMAC-SHA256', + 'code' => nil, + 'issued_at' => Time.now.to_i, + 'user_id' => '123456' + } + + @request.stubs(:cookies).returns({"fbsr_#{@client_id}" => signed_request(@payload, @client_secret)}) + end + + test 'calls fail! when a code is not included in the cookie' do + strategy.expects(:fail!).times(1).with(:no_authorization_code, kind_of(OmniAuth::Strategies::Facebook::NoAuthorizationCodeError)) + strategy.callback_phase + end + end + + class UnknownAlgorithmInCookieRequestTest < TestCase + def setup + super() + @payload = { + 'algorithm' => 'UNKNOWN-ALGO', + 'code' => nil, + 'issued_at' => Time.now.to_i, + 'user_id' => '123456' + } + + @request.stubs(:cookies).returns({"fbsr_#{@client_id}" => signed_request(@payload, @client_secret)}) + end + + test 'calls fail! when an algorithm is unknown' do + strategy.expects(:fail!).times(1).with(:unknown_signature_algorithm, kind_of(OmniAuth::Facebook::SignedRequest::UnknownSignatureAlgorithmError)) + strategy.callback_phase + end + end end end end