Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
56 changes: 43 additions & 13 deletions lib/omniauth/strategies/facebook.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion test/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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({})
Expand All @@ -41,6 +41,8 @@ def setup

@client_id = '123'
@client_secret = '53cr3tz'

@options = nil
end

def strategy
Expand Down
212 changes: 123 additions & 89 deletions test/strategy_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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