diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae44b2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.gem +.bundle +.rspec +/Gemfile.lock +pkg/* +.powenv +tmp +bin diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..be375af --- /dev/null +++ b/.travis.yml @@ -0,0 +1,8 @@ +rvm: + - 1.8.7 + - 1.9.2 + - 1.9.3 + - jruby +branches: + only: + - master diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..b4134c7 --- /dev/null +++ b/Gemfile @@ -0,0 +1,5 @@ +source :rubygems + +gemspec + +gem 'jruby-openssl', :platform => :jruby diff --git a/README.md b/README.md new file mode 100644 index 0000000..1fc82e4 --- /dev/null +++ b/README.md @@ -0,0 +1,180 @@ +# OmniAuth Facebook  [![Build Status](http://travis-ci.org/mkdynamic/omniauth-facebook.png?branch=master)](http://travis-ci.org/mkdynamic/omniauth-facebook) + +Facebook OAuth2 Strategy for OmniAuth 1.0. + +Supports the OAuth 2.0 server-side and client-side flows. Read the Facebook docs for more details: http://developers.facebook.com/docs/authentication + +## Installing + +Add to your `Gemfile`: + +```ruby +gem 'omniauth-facebook' +``` + +Then `bundle install`. + +## Usage + +`OmniAuth::Strategies::Facebook` is simply a Rack middleware. Read the OmniAuth 1.0 docs for detailed instructions: https://github.com/intridea/omniauth. + +Here's a quick example, adding the middleware to a Rails app in `config/initializers/omniauth.rb`: + +```ruby +Rails.application.config.middleware.use OmniAuth::Builder do + provider :facebook, ENV['FACEBOOK_KEY'], ENV['FACEBOOK_SECRET'] +end +``` + +[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). + +## Configuring + +You can configure several options, which you pass in to the `provider` method via a `Hash`: + +* `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` +* `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` +* `auth_type`: Optionally specifies the requested authentication features as a comma-separated list, as per https://developers.facebook.com/docs/authentication/reauthentication/. +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`. +* `secure_image_url`: Set to `true` to use https for the avatar image url returned in the auth hash. Default is `false`. +* `image_size`: Set the size for the returned image url in the auth hash. Valid options are `square` (50x50), `small` (50 pixels wide, variable height), `normal` (100 pixels wide, variable height), or `large` (about 200 pixels wide, variable height). Default is `square` (50x50). + +For example, to request `email`, `user_birthday` and `read_stream` permissions and display the authentication page in a popup window: + +```ruby +Rails.application.config.middleware.use OmniAuth::Builder do + provider :facebook, ENV['FACEBOOK_KEY'], ENV['FACEBOOK_SECRET'], + :scope => 'email,user_birthday,read_stream', :display => 'popup' +end +``` + +### Per-Request Options + +If you want to set the `display` format 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`. + +You can also pass through a `state` param which will be passed along to the callback url. + +### Custom Callback URL/Path + +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. + +## Auth Hash + +Here's an example *Auth Hash* available in `request.env['omniauth.auth']`: + +```ruby +{ + :provider => 'facebook', + :uid => '1234567', + :info => { + :nickname => 'jbloggs', + :email => 'joe@bloggs.com', + :name => 'Joe Bloggs', + :first_name => 'Joe', + :last_name => 'Bloggs', + :image => 'http://graph.facebook.com/1234567/picture?type=square', + :urls => { :Facebook => 'http://www.facebook.com/jbloggs' }, + :location => 'Palo Alto, California', + :verified => true + }, + :credentials => { + :token => 'ABCDEF...', # OAuth 2.0 access_token, which you may wish to store + :expires_at => 1321747205, # when the access token expires (it always will) + :expires => true # this will always be true + }, + :extra => { + :raw_info => { + :id => '1234567', + :name => 'Joe Bloggs', + :first_name => 'Joe', + :last_name => 'Bloggs', + :link => 'http://www.facebook.com/jbloggs', + :username => 'jbloggs', + :location => { :id => '123456789', :name => 'Palo Alto, California' }, + :gender => 'male', + :email => 'joe@bloggs.com', + :timezone => -8, + :locale => 'en_US', + :verified => true, + :updated_time => '2011-11-11T06:21:03+0000' + } + } +} +``` + +The precise information available may depend on the permissions which you request. + +## Client-side Flow + +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. + +Note that you must enable cookies in the `FB.init` config for this process to work. + +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. + +### How it Works + +The client-side flow is supported by parsing the authorization code from the signed request which Facebook places in a cookie. + +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: + +1. parse it, +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. + +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/). + +## Canvas Apps + +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. + +There are then 2 scenarios for what happens next: + +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. + +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. + +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). + +Bear in mind you have several options (including [authenticated referrals](https://developers.facebook.com/docs/opengraph/authentication/#referrals)). Read [the Facebook docs on canvas page authentication](https://developers.facebook.com/docs/authentication/canvas/) for more info. + +## Token Expiry + +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. + +### Client-Side Flow + +If you use the client-side flow, Facebook will give you back a short lived access token (~ 2 hours). + +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. + +### Server-Side Flow + +If you use the server-side flow, Facebook will give you back a longer loved access token (~ 60 days). + +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. + +## Supported Rubies + +Actively tested with the following Ruby versions: + +- MRI 1.9.3 +- MRI 1.9.2 +- MRI 1.8.7 +- JRuby 1.6.5 + +*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: + +```ruby +gem 'jruby-openssl', :platform => :jruby +``` + +## License + +Copyright (c) 2012 by Mark Dodwell + +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: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +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. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..9b57540 --- /dev/null +++ b/Rakefile @@ -0,0 +1,8 @@ +require 'bundler/gem_tasks' +require 'rake/testtask' + +Rake::TestTask.new do |task| + task.libs << 'test' +end + +task :default => :test diff --git a/example/Gemfile b/example/Gemfile new file mode 100644 index 0000000..c37f32d --- /dev/null +++ b/example/Gemfile @@ -0,0 +1,4 @@ +source :rubygems + +gem 'sinatra' +gem 'omniauth-facebook', :path => '../' diff --git a/example/Gemfile.lock b/example/Gemfile.lock new file mode 100644 index 0000000..c477ef7 --- /dev/null +++ b/example/Gemfile.lock @@ -0,0 +1,45 @@ +PATH + remote: ../ + specs: + omniauth-facebook (1.4.0) + omniauth-oauth2 (~> 1.1.0) + +GEM + remote: http://rubygems.org/ + specs: + faraday (0.8.1) + multipart-post (~> 1.1) + hashie (1.2.0) + httpauth (0.1) + json (1.7.3) + jwt (0.1.4) + json (>= 1.2.4) + multi_json (1.3.6) + multipart-post (1.1.5) + oauth2 (0.8.0) + faraday (~> 0.8) + httpauth (~> 0.1) + jwt (~> 0.1.4) + multi_json (~> 1.0) + rack (~> 1.2) + omniauth (1.1.0) + hashie (~> 1.2) + rack + omniauth-oauth2 (1.1.0) + oauth2 (~> 0.8.0) + omniauth (~> 1.0) + rack (1.4.1) + rack-protection (1.2.0) + rack + sinatra (1.3.2) + rack (~> 1.3, >= 1.3.6) + rack-protection (~> 1.2) + tilt (~> 1.3, >= 1.3.3) + tilt (1.3.3) + +PLATFORMS + ruby + +DEPENDENCIES + omniauth-facebook! + sinatra diff --git a/example/config.ru b/example/config.ru new file mode 100644 index 0000000..47409ca --- /dev/null +++ b/example/config.ru @@ -0,0 +1,115 @@ +require 'bundler/setup' +require 'sinatra/base' +require 'omniauth-facebook' + +SCOPE = 'email,read_stream' + +class App < Sinatra::Base + # turn off sinatra default X-Frame-Options for FB canvas + set :protection, :except => :frame_options + + # server-side flow + get '/' do + # NOTE: you would just hit this endpoint directly from the browser + # in a real app. the redirect is just here to setup the root + # path in this example sinatra app. + redirect '/auth/facebook' + end + + # client-side flow + get '/client-side' do + content_type 'text/html' + # NOTE: when you enable cookie below in the FB.init call + # the GET request in the FB.login callback will send + # a signed request in a cookie back the OmniAuth callback + # which will parse out the authorization code and obtain + # the access_token. This will be the exact same access_token + # returned to the client in response.authResponse.accessToken. + <<-END + + + Client-side Flow Example + + + +
+ + + +

+ Connect to FB +

+ +

+ + + END + end + + # auth via FB canvas and signed request param + post '/canvas/' do + # we just redirect to /auth/facebook here which will parse the + # signed_request FB sends us, asking for auth if the user has + # not already granted access, or simply moving straight to the + # callback where they have already granted access. + # + # we pass the state parameter which we can detect in our callback + # to do custom rendering/redirection for the canvas app page + redirect "/auth/facebook?signed_request=#{request.params['signed_request']}&state=canvas" + end + + get '/auth/:provider/callback' do + # we can do something special here is +state+ param is canvas + # (see notes above in /canvas/ method for more details) + content_type 'application/json' + MultiJson.encode(request.env) + end + + get '/auth/failure' do + content_type 'application/json' + MultiJson.encode(request.env) + end +end + +use Rack::Session::Cookie + +use OmniAuth::Builder do + provider :facebook, ENV['APP_ID'], ENV['APP_SECRET'], :scope => SCOPE +end + +run App.new diff --git a/lib/omniauth/facebook/version.rb b/lib/omniauth/facebook/version.rb new file mode 100644 index 0000000..1efaaf2 --- /dev/null +++ b/lib/omniauth/facebook/version.rb @@ -0,0 +1,5 @@ +module OmniAuth + module Facebook + VERSION = "1.4.1" + end +end diff --git a/lib/omniauth/facebook.rb b/lib/omniauth/facebook.rb new file mode 100644 index 0000000..6db4ec6 --- /dev/null +++ b/lib/omniauth/facebook.rb @@ -0,0 +1,2 @@ +require 'omniauth/facebook/version' +require 'omniauth/strategies/facebook' diff --git a/lib/omniauth/strategies/facebook.rb b/lib/omniauth/strategies/facebook.rb new file mode 100644 index 0000000..92da028 --- /dev/null +++ b/lib/omniauth/strategies/facebook.rb @@ -0,0 +1,221 @@ +require 'omniauth/strategies/oauth2' +require 'base64' +require 'openssl' +require 'rack/utils' + +module OmniAuth + module Strategies + class Facebook < OmniAuth::Strategies::OAuth2 + class NoAuthorizationCodeError < StandardError; end + + DEFAULT_SCOPE = 'email' + + option :client_options, { + :site => 'https://graph.facebook.com', + :token_url => '/oauth/access_token' + } + + option :token_params, { + :parse => :query + } + + option :access_token_options, { + :header_format => 'OAuth %s', + :param_name => 'access_token' + } + + option :authorize_options, [:scope, :display, :auth_type] + + uid { raw_info['id'] } + + info do + prune!({ + 'nickname' => raw_info['username'], + 'email' => raw_info['email'], + 'name' => raw_info['name'], + 'first_name' => raw_info['first_name'], + 'last_name' => raw_info['last_name'], + 'image' => "#{options[:secure_image_url] ? 'https' : 'http'}://graph.facebook.com/#{uid}/picture?type=#{options[:image_size] || 'square'}", + 'description' => raw_info['bio'], + 'urls' => { + 'Facebook' => raw_info['link'], + 'Website' => raw_info['website'] + }, + 'location' => (raw_info['location'] || {})['name'], + 'verified' => raw_info['verified'] + }) + end + + extra do + hash = {} + hash['raw_info'] = raw_info unless skip_info? + prune! hash + end + + def raw_info + @raw_info ||= access_token.get('/me').parsed || {} + end + + def build_access_token + if access_token = request.params["access_token"] + ::OAuth2::AccessToken.from_hash( + client, + {"access_token" => access_token}.update(access_token_options) + ) + elsif signed_request_contains_access_token? + hash = signed_request.clone + ::OAuth2::AccessToken.new( + client, + hash.delete('oauth_token'), + hash.merge!(access_token_options.merge(:expires_at => hash.delete('expires'))) + ) + else + with_authorization_code! { super }.tap do |token| + token.options.merge!(access_token_options) + end + end + end + + def request_phase + if signed_request_contains_access_token? + # if we already have an access token, we can just hit the + # callback URL directly and pass the signed request along + params = { :signed_request => raw_signed_request } + params[:state] = request.params['state'] if request.params['state'] + query = Rack::Utils.build_query(params) + + url = callback_url + url << "?" unless url.match(/\?/) + url << "&" unless url.match(/[\&\?]$/) + url << query + + redirect url + else + super + end + end + + # NOTE if we're using code from the signed request + # then FB sets the redirect_uri to '' during the authorize + # phase + it must match during the access_token phase: + # https://github.com/facebook/php-sdk/blob/master/src/base_facebook.php#L348 + def callback_url + if @authorization_code_from_signed_request + '' + else + options[:callback_url] || super + end + end + + def access_token_options + options.access_token_options.inject({}) { |h,(k,v)| h[k.to_sym] = v; h } + end + + ## + # You can pass +display+, +state+ or +scope+ params to the auth request, if + # you need to set them dynamically. You can also set these options + # in the OmniAuth config :authorize_params option. + # + # /auth/facebook?display=popup&state=ABC + # + def authorize_params + super.tap do |params| + %w[display state scope].each do |v| + if request.params[v] + params[v.to_sym] = request.params[v] + + # to support omniauth-oauth2's auto csrf protection + session['omniauth.state'] = params[:state] if v == 'state' + end + end + + params[:scope] ||= DEFAULT_SCOPE + end + end + + ## + # Parse signed request in order, from: + # + # 1. the request 'signed_request' param (server-side flow from canvas pages) or + # 2. a cookie (client-side flow via JS SDK) + # + def signed_request + @signed_request ||= raw_signed_request && + parse_signed_request(raw_signed_request) + end + + private + + def raw_signed_request + request.params['signed_request'] || + request.cookies["fbsr_#{client.id}"] + end + + ## + # If the signed_request comes from a FB canvas page and the user + # has already authorized your application, the JSON object will be + # contain the access token. + # + # https://developers.facebook.com/docs/authentication/canvas/ + # + def signed_request_contains_access_token? + signed_request && + signed_request['oauth_token'] + end + + ## + # Picks the authorization code in order, from: + # + # 1. the request 'code' param (manual callback from standard server-side flow) + # 2. a signed request (see #signed_request for more) + # + def with_authorization_code! + if request.params.key?('code') + yield + elsif code_from_signed_request = signed_request && signed_request['code'] + request.params['code'] = code_from_signed_request + @authorization_code_from_signed_request = true + begin + yield + ensure + request.params.delete('code') + @authorization_code_from_signed_request = false + end + else + raise NoAuthorizationCodeError, 'must pass either a `code` parameter or a signed request (via `signed_request` parameter or a `fbsr_XXX` cookie)' + end + end + + def prune!(hash) + hash.delete_if do |_, value| + prune!(value) if value.is_a?(Hash) + value.nil? || (value.respond_to?(:empty?) && value.empty?) + end + end + + def parse_signed_request(value) + signature, encoded_payload = value.split('.') + + decoded_hex_signature = base64_decode_url(signature) + decoded_payload = MultiJson.decode(base64_decode_url(encoded_payload)) + + unless decoded_payload['algorithm'] == 'HMAC-SHA256' + raise NotImplementedError, "unkown algorithm: #{decoded_payload['algorithm']}" + end + + if valid_signature?(client.secret, decoded_hex_signature, encoded_payload) + decoded_payload + end + end + + def valid_signature?(secret, signature, payload, algorithm = OpenSSL::Digest::SHA256.new) + OpenSSL::HMAC.digest(algorithm, secret, payload) == signature + end + + def base64_decode_url(value) + value += '=' * (4 - value.size.modulo(4)) + Base64.decode64(value.tr('-_', '+/')) + end + end + end +end diff --git a/lib/omniauth-facebook.rb b/lib/omniauth-facebook.rb new file mode 100644 index 0000000..2588b1d --- /dev/null +++ b/lib/omniauth-facebook.rb @@ -0,0 +1 @@ +require 'omniauth/facebook' diff --git a/metadata.yml b/metadata.yml new file mode 100644 index 0000000..81ac5a4 --- /dev/null +++ b/metadata.yml @@ -0,0 +1,134 @@ +--- !ruby/object:Gem::Specification +name: omniauth-facebook +version: !ruby/object:Gem::Version + version: 1.4.1 + prerelease: +platform: ruby +authors: +- Mark Dodwell +autorequire: +bindir: bin +cert_chain: [] +date: 2012-07-07 00:00:00.000000000 Z +dependencies: +- !ruby/object:Gem::Dependency + name: omniauth-oauth2 + requirement: !ruby/object:Gem::Requirement + none: false + requirements: + - - ~> + - !ruby/object:Gem::Version + version: 1.1.0 + type: :runtime + prerelease: false + version_requirements: !ruby/object:Gem::Requirement + none: false + requirements: + - - ~> + - !ruby/object:Gem::Version + version: 1.1.0 +- !ruby/object:Gem::Dependency + name: minitest + requirement: !ruby/object:Gem::Requirement + none: false + requirements: + - - ! '>=' + - !ruby/object:Gem::Version + version: '0' + type: :development + prerelease: false + version_requirements: !ruby/object:Gem::Requirement + none: false + requirements: + - - ! '>=' + - !ruby/object:Gem::Version + version: '0' +- !ruby/object:Gem::Dependency + name: mocha + requirement: !ruby/object:Gem::Requirement + none: false + requirements: + - - ! '>=' + - !ruby/object:Gem::Version + version: '0' + type: :development + prerelease: false + version_requirements: !ruby/object:Gem::Requirement + none: false + requirements: + - - ! '>=' + - !ruby/object:Gem::Version + version: '0' +- !ruby/object:Gem::Dependency + name: rake + requirement: !ruby/object:Gem::Requirement + none: false + requirements: + - - ! '>=' + - !ruby/object:Gem::Version + version: '0' + type: :development + prerelease: false + version_requirements: !ruby/object:Gem::Requirement + none: false + requirements: + - - ! '>=' + - !ruby/object:Gem::Version + version: '0' +description: +email: +- mark@mkdynamic.co.uk +executables: [] +extensions: [] +extra_rdoc_files: [] +files: +- .gitignore +- .travis.yml +- Gemfile +- README.md +- Rakefile +- example/Gemfile +- example/Gemfile.lock +- example/config.ru +- lib/omniauth-facebook.rb +- lib/omniauth/facebook.rb +- lib/omniauth/facebook/version.rb +- lib/omniauth/strategies/facebook.rb +- omniauth-facebook.gemspec +- test/helper.rb +- test/support/shared_examples.rb +- test/test.rb +homepage: https://github.com/mkdynamic/omniauth-facebook +licenses: [] +post_install_message: +rdoc_options: [] +require_paths: +- lib +required_ruby_version: !ruby/object:Gem::Requirement + none: false + requirements: + - - ! '>=' + - !ruby/object:Gem::Version + version: '0' + segments: + - 0 + hash: 1875274478054024285 +required_rubygems_version: !ruby/object:Gem::Requirement + none: false + requirements: + - - ! '>=' + - !ruby/object:Gem::Version + version: '0' + segments: + - 0 + hash: 1875274478054024285 +requirements: [] +rubyforge_project: +rubygems_version: 1.8.24 +signing_key: +specification_version: 3 +summary: Facebook strategy for OmniAuth +test_files: +- test/helper.rb +- test/support/shared_examples.rb +- test/test.rb diff --git a/omniauth-facebook.gemspec b/omniauth-facebook.gemspec new file mode 100644 index 0000000..44fd1ae --- /dev/null +++ b/omniauth-facebook.gemspec @@ -0,0 +1,23 @@ +# -*- encoding: utf-8 -*- +$:.push File.expand_path('../lib', __FILE__) +require 'omniauth/facebook/version' + +Gem::Specification.new do |s| + s.name = 'omniauth-facebook' + s.version = OmniAuth::Facebook::VERSION + s.authors = ['Mark Dodwell'] + s.email = ['mark@mkdynamic.co.uk'] + s.summary = 'Facebook strategy for OmniAuth' + s.homepage = 'https://github.com/mkdynamic/omniauth-facebook' + + s.files = `git ls-files`.split("\n") + s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") + s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) } + s.require_paths = ['lib'] + + s.add_runtime_dependency 'omniauth-oauth2', '~> 1.1.0' + + s.add_development_dependency 'minitest' + s.add_development_dependency 'mocha' + s.add_development_dependency 'rake' +end diff --git a/test/helper.rb b/test/helper.rb new file mode 100644 index 0000000..90f6d5c --- /dev/null +++ b/test/helper.rb @@ -0,0 +1,54 @@ +require 'bundler/setup' +require 'minitest/autorun' +require 'mocha' +require 'omniauth/strategies/facebook' + +OmniAuth.config.test_mode = true + +module BlockTestHelper + def test(name, &blk) + method_name = "test_#{name.gsub(/\s+/, '_')}" + raise "Method already defined: #{method_name}" if instance_methods.include?(method_name.to_sym) + define_method method_name, &blk + end +end + +module CustomAssertions + def assert_has_key(key, hash, msg = nil) + msg = message(msg) { "Expected #{hash.inspect} to have key #{key.inspect}" } + assert hash.has_key?(key), msg + end + + def refute_has_key(key, hash, msg = nil) + msg = message(msg) { "Expected #{hash.inspect} not to have key #{key.inspect}" } + refute hash.has_key?(key), msg + end +end + +class TestCase < MiniTest::Unit::TestCase + extend BlockTestHelper + include CustomAssertions +end + +class StrategyTestCase < TestCase + def setup + @request = stub('Request') + @request.stubs(:params).returns({}) + @request.stubs(:cookies).returns({}) + @request.stubs(:env).returns({}) + + @client_id = '123' + @client_secret = '53cr3tz' + end + + def strategy + @strategy ||= begin + args = [@client_id, @client_secret, @options].compact + OmniAuth::Strategies::Facebook.new(nil, *args).tap do |strategy| + strategy.stubs(:request).returns(@request) + end + end + end +end + +Dir[File.expand_path('../support/**/*', __FILE__)].each &method(:require) diff --git a/test/support/shared_examples.rb b/test/support/shared_examples.rb new file mode 100644 index 0000000..053b3b8 --- /dev/null +++ b/test/support/shared_examples.rb @@ -0,0 +1,85 @@ +# NOTE it would be useful if this lived in omniauth-oauth2 eventually +module OAuth2StrategyTests + def self.included(base) + base.class_eval do + include ClientTests + include AuthorizeParamsTests + include CSRFAuthorizeParamsTests + include TokenParamsTests + end + end + + module ClientTests + extend BlockTestHelper + + test 'should be initialized with symbolized client_options' do + @options = { :client_options => { 'authorize_url' => 'https://example.com' } } + assert_equal 'https://example.com', strategy.client.options[:authorize_url] + end + end + + module AuthorizeParamsTests + extend BlockTestHelper + + test 'should include any authorize params passed in the :authorize_params option' do + @options = { :authorize_params => { :foo => 'bar', :baz => 'zip' } } + assert_equal 'bar', strategy.authorize_params['foo'] + assert_equal 'zip', strategy.authorize_params['baz'] + end + + test 'should include top-level options that are marked as :authorize_options' do + @options = { :authorize_options => [:scope, :foo], :scope => 'bar', :foo => 'baz' } + assert_equal 'bar', strategy.authorize_params['scope'] + assert_equal 'baz', strategy.authorize_params['foo'] + end + + test 'should exclude top-level options that are not passed' do + @options = { :authorize_options => [:bar] } + refute_has_key :bar, strategy.authorize_params + refute_has_key 'bar', strategy.authorize_params + end + end + + module CSRFAuthorizeParamsTests + extend BlockTestHelper + + test 'should store random state in the session when none is present in authorize or request params' do + assert_includes strategy.authorize_params.keys, 'state' + refute_empty strategy.authorize_params['state'] + refute_empty strategy.session['omniauth.state'] + assert_equal strategy.authorize_params['state'], strategy.session['omniauth.state'] + end + + test 'should store state in the session when present in authorize params vs. a random one' do + @options = { :authorize_params => { :state => 'bar' } } + refute_empty strategy.authorize_params['state'] + assert_equal 'bar', strategy.authorize_params[:state] + refute_empty strategy.session['omniauth.state'] + assert_equal 'bar', strategy.session['omniauth.state'] + end + + test 'should store state in the session when present in request params vs. a random one' do + @request.stubs(:params).returns({ 'state' => 'foo' }) + refute_empty strategy.authorize_params['state'] + assert_equal 'foo', strategy.authorize_params[:state] + refute_empty strategy.session['omniauth.state'] + assert_equal 'foo', strategy.session['omniauth.state'] + end + end + + module TokenParamsTests + extend BlockTestHelper + + test 'should include any authorize params passed in the :token_params option' do + @options = { :token_params => { :foo => 'bar', :baz => 'zip' } } + assert_equal 'bar', strategy.token_params['foo'] + assert_equal 'zip', strategy.token_params['baz'] + end + + test 'should include top-level options that are marked as :token_options' do + @options = { :token_options => [:scope, :foo], :scope => 'bar', :foo => 'baz' } + assert_equal 'bar', strategy.token_params['scope'] + assert_equal 'baz', strategy.token_params['foo'] + end + end +end diff --git a/test/test.rb b/test/test.rb new file mode 100644 index 0000000..d0456d2 --- /dev/null +++ b/test/test.rb @@ -0,0 +1,494 @@ +require 'helper' +require 'omniauth-facebook' +require 'openssl' +require 'base64' + +class StrategyTest < StrategyTestCase + include OAuth2StrategyTests +end + +class ClientTest < StrategyTestCase + test 'has correct Facebook site' do + assert_equal 'https://graph.facebook.com', strategy.client.site + end + + test 'has correct authorize url' do + assert_equal '/oauth/authorize', strategy.client.options[:authorize_url] + end + + test 'has correct token url' do + assert_equal '/oauth/access_token', strategy.client.options[:token_url] + end +end + +class CallbackUrlTest < StrategyTestCase + test "returns the default callback url" do + url_base = 'http://auth.request.com' + @request.stubs(:url).returns("#{url_base}/some/page") + strategy.stubs(:script_name).returns('') # as not to depend on Rack env + assert_equal "#{url_base}/auth/facebook/callback", strategy.callback_url + end + + test "returns path from callback_path option" do + @options = { :callback_path => "/auth/FB/done"} + url_base = 'http://auth.request.com' + @request.stubs(:url).returns("#{url_base}/page/path") + strategy.stubs(:script_name).returns('') # as not to depend on Rack env + assert_equal "#{url_base}/auth/FB/done", strategy.callback_url + end + + test "returns url from callback_url option" do + url = 'https://auth.myapp.com/auth/fb/callback' + @options = { :callback_url => url } + assert_equal url, strategy.callback_url + end +end + +class AuthorizeParamsTest < StrategyTestCase + test 'includes default scope for email' do + assert strategy.authorize_params.is_a?(Hash) + assert_equal 'email', strategy.authorize_params[:scope] + end + + test 'includes display parameter from request when present' do + @request.stubs(:params).returns({ 'display' => 'touch' }) + assert strategy.authorize_params.is_a?(Hash) + assert_equal 'touch', strategy.authorize_params[:display] + end + + test 'includes state parameter from request when present' do + @request.stubs(:params).returns({ 'state' => 'some_state' }) + assert strategy.authorize_params.is_a?(Hash) + assert_equal 'some_state', strategy.authorize_params[:state] + end + + test 'overrides default scope with parameter passed from request' do + @request.stubs(:params).returns({ 'scope' => 'email' }) + assert strategy.authorize_params.is_a?(Hash) + assert_equal 'email', strategy.authorize_params[:scope] + end +end + +class TokeParamsTest < StrategyTestCase + test 'has correct parse strategy' do + assert_equal :query, strategy.token_params[:parse] + end +end + +class AccessTokenOptionsTest < StrategyTestCase + test 'has correct param name by default' do + assert_equal 'access_token', strategy.access_token_options[:param_name] + end + + test 'has correct header format by default' do + assert_equal 'OAuth %s', strategy.access_token_options[:header_format] + end +end + +class UidTest < StrategyTestCase + def setup + super + strategy.stubs(:raw_info).returns({ 'id' => '123' }) + end + + test 'returns the id from raw_info' do + assert_equal '123', strategy.uid + end +end + +class InfoTest < StrategyTestCase + test 'returns the secure facebook avatar url when `secure_image_url` option is specified' do + @options = { :secure_image_url => true } + raw_info = { 'name' => 'Fred Smith', 'id' => '321' } + strategy.stubs(:raw_info).returns(raw_info) + assert_equal 'https://graph.facebook.com/321/picture?type=square', strategy.info['image'] + end + + test 'returns the image size specified in the `image_size` option' do + @options = { :image_size => 'normal' } + raw_info = { 'name' => 'Fred Smith', 'id' => '321' } + strategy.stubs(:raw_info).returns(raw_info) + assert_equal 'http://graph.facebook.com/321/picture?type=normal', strategy.info['image'] + end +end + +class InfoTestOptionalDataPresent < StrategyTestCase + def setup + super + @raw_info ||= { 'name' => 'Fred Smith' } + strategy.stubs(:raw_info).returns(@raw_info) + end + + test 'returns the name' do + assert_equal 'Fred Smith', strategy.info['name'] + end + + test 'returns the email' do + @raw_info['email'] = 'fred@smith.com' + assert_equal 'fred@smith.com', strategy.info['email'] + end + + test 'returns the username as nickname' do + @raw_info['username'] = 'fredsmith' + assert_equal 'fredsmith', strategy.info['nickname'] + end + + test 'returns the first name' do + @raw_info['first_name'] = 'Fred' + assert_equal 'Fred', strategy.info['first_name'] + end + + test 'returns the last name' do + @raw_info['last_name'] = 'Smith' + assert_equal 'Smith', strategy.info['last_name'] + end + + test 'returns the location name as location' do + @raw_info['location'] = { 'id' => '104022926303756', 'name' => 'Palo Alto, California' } + assert_equal 'Palo Alto, California', strategy.info['location'] + end + + test 'returns bio as description' do + @raw_info['bio'] = 'I am great' + assert_equal 'I am great', strategy.info['description'] + end + + test 'returns the square format facebook avatar url' do + @raw_info['id'] = '321' + assert_equal 'http://graph.facebook.com/321/picture?type=square', strategy.info['image'] + end + + test 'returns the Facebook link as the Facebook url' do + @raw_info['link'] = 'http://www.facebook.com/fredsmith' + assert_kind_of Hash, strategy.info['urls'] + assert_equal 'http://www.facebook.com/fredsmith', strategy.info['urls']['Facebook'] + end + + test 'returns website url' do + @raw_info['website'] = 'https://my-wonderful-site.com' + assert_kind_of Hash, strategy.info['urls'] + assert_equal 'https://my-wonderful-site.com', strategy.info['urls']['Website'] + end + + test 'return both Facebook link and website urls' do + @raw_info['link'] = 'http://www.facebook.com/fredsmith' + @raw_info['website'] = 'https://my-wonderful-site.com' + assert_kind_of Hash, strategy.info['urls'] + assert_equal 'http://www.facebook.com/fredsmith', strategy.info['urls']['Facebook'] + assert_equal 'https://my-wonderful-site.com', strategy.info['urls']['Website'] + end + + test 'returns the positive verified status' do + @raw_info['verified'] = true + assert strategy.info['verified'] + end + + test 'returns the negative verified status' do + @raw_info['verified'] = false + refute strategy.info['verified'] + end +end + +class InfoTestOptionalDataNotPresent < StrategyTestCase + def setup + super + @raw_info ||= { 'name' => 'Fred Smith' } + strategy.stubs(:raw_info).returns(@raw_info) + end + + test 'has no email key' do + refute_has_key 'email', strategy.info + end + + test 'has no nickname key' do + refute_has_key 'nickname', strategy.info + end + + test 'has no first name key' do + refute_has_key 'first_name', strategy.info + end + + test 'has no last name key' do + refute_has_key 'last_name', strategy.info + end + + test 'has no location key' do + refute_has_key 'location', strategy.info + end + + test 'has no description key' do + refute_has_key 'description', strategy.info + end + + test 'has no urls' do + refute_has_key 'urls', strategy.info + end + + test 'has no verified key' do + refute_has_key 'verified', strategy.info + end +end + +class RawInfoTest < StrategyTestCase + def setup + super + @access_token = stub('OAuth2::AccessToken') + end + + test 'performs a GET to https://graph.facebook.com/me' do + strategy.stubs(:access_token).returns(@access_token) + @access_token.expects(:get).with('/me').returns(stub_everything('OAuth2::Response')) + strategy.raw_info + end + + test 'returns a Hash' do + strategy.stubs(:access_token).returns(@access_token) + raw_response = stub('Faraday::Response') + raw_response.stubs(:body).returns('{ "ohai": "thar" }') + raw_response.stubs(:status).returns(200) + raw_response.stubs(:headers).returns({'Content-Type' => 'application/json' }) + oauth2_response = OAuth2::Response.new(raw_response) + @access_token.stubs(:get).with('/me').returns(oauth2_response) + assert_kind_of Hash, strategy.raw_info + assert_equal 'thar', strategy.raw_info['ohai'] + end + + test 'returns an empty hash when the response is false' do + strategy.stubs(:access_token).returns(@access_token) + oauth2_response = stub('OAuth2::Response', :parsed => false) + @access_token.stubs(:get).with('/me').returns(oauth2_response) + assert_kind_of Hash, strategy.raw_info + end + + test 'should not include raw_info in extras hash when skip_info is specified' do + @options = { :skip_info => true } + strategy.stubs(:raw_info).returns({:foo => 'bar' }) + refute_has_key 'raw_info', strategy.extra + end +end + +class CredentialsTest < StrategyTestCase + def setup + super + @access_token = stub('OAuth2::AccessToken') + @access_token.stubs(:token) + @access_token.stubs(:expires?) + @access_token.stubs(:expires_at) + @access_token.stubs(:refresh_token) + strategy.stubs(:access_token).returns(@access_token) + end + + test 'returns a Hash' do + assert_kind_of Hash, strategy.credentials + end + + test 'returns the token' do + @access_token.stubs(:token).returns('123') + assert_equal '123', strategy.credentials['token'] + end + + test 'returns the expiry status' do + @access_token.stubs(:expires?).returns(true) + assert strategy.credentials['expires'] + + @access_token.stubs(:expires?).returns(false) + refute strategy.credentials['expires'] + end + + test 'returns the refresh token and expiry time when expiring' do + ten_mins_from_now = (Time.now + 600).to_i + @access_token.stubs(:expires?).returns(true) + @access_token.stubs(:refresh_token).returns('321') + @access_token.stubs(:expires_at).returns(ten_mins_from_now) + assert_equal '321', strategy.credentials['refresh_token'] + assert_equal ten_mins_from_now, strategy.credentials['expires_at'] + end + + test 'does not return the refresh token when test is nil and expiring' do + @access_token.stubs(:expires?).returns(true) + @access_token.stubs(:refresh_token).returns(nil) + assert_nil strategy.credentials['refresh_token'] + refute_has_key 'refresh_token', strategy.credentials + end + + test 'does not return the refresh token when not expiring' do + @access_token.stubs(:expires?).returns(false) + @access_token.stubs(:refresh_token).returns('XXX') + assert_nil strategy.credentials['refresh_token'] + refute_has_key 'refresh_token', strategy.credentials + end +end + +class ExtraTest < StrategyTestCase + def setup + super + @raw_info = { 'name' => 'Fred Smith' } + strategy.stubs(:raw_info).returns(@raw_info) + end + + test 'returns a Hash' do + assert_kind_of Hash, strategy.extra + end + + test 'contains raw info' do + assert_equal({ 'raw_info' => @raw_info }, strategy.extra) + 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 + test 'is nil' do + assert_nil strategy.send(:signed_request) + end + end + + class CookiePresentTest < TestCase + def setup + super + @payload = { + 'algorithm' => '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) + end + end + + class ParamPresentTest < TestCase + def setup + super + @payload = { + 'algorithm' => 'HMAC-SHA256', + 'oauth_token' => 'XXX', + 'issued_at' => Time.now.to_i, + 'user_id' => '123456' + } + + @request.stubs(:params).returns({'signed_request' => signed_request(@payload, @client_secret)}) + end + + test 'parses the access code out from the param' do + assert_equal @payload, strategy.send(:signed_request) + end + end + + class CookieAndParamPresentTest < TestCase + def setup + super + @payload_from_cookie = { + 'algorithm' => 'HMAC-SHA256', + 'from' => 'cookie' + } + + @request.stubs(:cookies).returns({"fbsr_#{@client_id}" => signed_request(@payload_from_cookie, @client_secret)}) + + @payload_from_param = { + 'algorithm' => 'HMAC-SHA256', + 'from' => 'param' + } + + @request.stubs(:params).returns({'signed_request' => signed_request(@payload_from_param, @client_secret)}) + end + + test 'picks param over cookie' do + assert_equal @payload_from_param, strategy.send(:signed_request) + end + end +end + +class RequestPhaseWithSignedRequestTest < StrategyTestCase + include SignedRequestHelpers + + def setup + super + + payload = { + 'algorithm' => 'HMAC-SHA256', + 'oauth_token' => 'm4c0d3z' + } + @raw_signed_request = signed_request(payload, @client_secret) + @request.stubs(:params).returns("signed_request" => @raw_signed_request) + + strategy.stubs(:callback_url).returns('/') + end + + test 'redirects to callback passing along signed request' do + strategy.expects(:redirect).with("/?signed_request=#{Rack::Utils.escape(@raw_signed_request)}").once + strategy.request_phase + end +end + +module BuildAccessTokenTests + class TestCase < StrategyTestCase + include SignedRequestHelpers + end + + class ParamsContainSignedRequestWithAccessTokenTest < TestCase + def setup + super + + @payload = { + 'algorithm' => 'HMAC-SHA256', + 'oauth_token' => 'm4c0d3z', + 'expires' => Time.now.to_i + } + @raw_signed_request = signed_request(@payload, @client_secret) + @request.stubs(:params).returns({"signed_request" => @raw_signed_request}) + + strategy.stubs(:callback_url).returns('/') + end + + test 'returns a new access token from the signed request' do + result = strategy.build_access_token + assert_kind_of ::OAuth2::AccessToken, result + assert_equal @payload['oauth_token'], result.token + end + + test 'returns an access token with the correct expiry time' do + result = strategy.build_access_token + assert_equal @payload['expires'], result.expires_at + end + end + + class ParamsContainAccessTokenStringTest < TestCase + def setup + super + + @request.stubs(:params).returns({'access_token' => 'm4c0d3z'}) + + strategy.stubs(:callback_url).returns('/') + end + + test 'returns a new access token' do + result = strategy.build_access_token + assert_kind_of ::OAuth2::AccessToken, result + assert_equal 'm4c0d3z', result.token + end + end +end