├── .travis.yml ├── .rspec ├── lib └── warden │ ├── github │ ├── version.rb │ ├── hook.rb │ ├── verifier.rb │ ├── sso.rb │ ├── membership_cache.rb │ ├── oauth.rb │ ├── strategy.rb │ ├── user.rb │ └── config.rb │ └── github.rb ├── Gemfile ├── .gitignore ├── Rakefile ├── config.ru ├── CHANGELOG.md ├── example ├── setup.rb ├── multi_scope_app.rb └── simple_app.rb ├── spec ├── spec_helper.rb ├── unit │ ├── sso_spec.rb │ ├── oauth_spec.rb │ ├── membership_cache_spec.rb │ ├── user_spec.rb │ └── config_spec.rb ├── fixtures │ └── user.json └── integration │ └── oauth_spec.rb ├── LICENSE.md ├── warden-github.gemspec └── README.md /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.4.1 4 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --order rand 4 | -------------------------------------------------------------------------------- /lib/warden/github/version.rb: -------------------------------------------------------------------------------- 1 | module Warden 2 | module GitHub 3 | VERSION = "1.3.2" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'byebug', require: false 4 | 5 | # Specify your gem's dependencies in warden-github.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | .bundle 3 | pkg 4 | .DS_Store 5 | Gemfile.lock 6 | vendor/gems 7 | *.gem 8 | .rbenv-version 9 | bin/ 10 | tags 11 | .ruby-version 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems/package_task' 2 | require 'rubygems/specification' 3 | require 'date' 4 | require 'bundler' 5 | 6 | task default: [:spec] 7 | 8 | require 'rspec/core/rake_task' 9 | desc "Run specs" 10 | RSpec::Core::RakeTask.new do |t| 11 | t.pattern = 'spec/**/*_spec.rb' 12 | end 13 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | ENV['RACK_ENV'] ||= 'development' 2 | 3 | require "bundler/setup" 4 | require 'warden/github' 5 | 6 | if ENV['MULTI_SCOPE_APP'] 7 | require File.expand_path('../example/multi_scope_app', __FILE__) 8 | else 9 | require File.expand_path('../example/simple_app', __FILE__) 10 | end 11 | 12 | run Example.app 13 | -------------------------------------------------------------------------------- /lib/warden/github/hook.rb: -------------------------------------------------------------------------------- 1 | Warden::Manager.after_authentication do |user, auth, opts| 2 | scope = opts.fetch(:scope) 3 | strategy = auth.winning_strategies[scope] 4 | 5 | strategy.finalize_flow! if strategy.class == Warden::GitHub::Strategy 6 | end 7 | 8 | Warden::Manager.after_set_user do |user, auth, opts| 9 | if user.is_a?(Warden::GitHub::User) 10 | session = auth.session(opts.fetch(:scope)) 11 | user.memberships = session[:_memberships] ||= {} 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | v1.2.0 2015/02/18 2 | 3 | * Implement single sign out for cookie sessions of GitHub properties 4 | 5 | v1.0.3 2014/12/13 6 | 7 | * Reintroduce membership caching to reduce API hits for validating team membership. 8 | 9 | v1.0.1 2014/03/24 10 | ----------------- 11 | 12 | * Handle multiple X-Forwarded-Proto headers when comma delimited 13 | 14 | v1.0.0 2013/09/03 15 | ----------------- 16 | 17 | * Explicitly require Octokit 2.1.1 18 | * Fixups for moving to octokit 2.1.1 19 | -------------------------------------------------------------------------------- /lib/warden/github.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'warden' 3 | require 'octokit' 4 | 5 | require 'warden/github/sso' 6 | require 'warden/github/user' 7 | require 'warden/github/oauth' 8 | require 'warden/github/version' 9 | require 'warden/github/strategy' 10 | require 'warden/github/hook' 11 | require 'warden/github/config' 12 | require 'warden/github/membership_cache' 13 | require 'warden/github/verifier' 14 | 15 | require 'active_support/message_verifier' 16 | require 'securerandom' 17 | -------------------------------------------------------------------------------- /example/setup.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra' 2 | require 'yajl/json_gem' 3 | 4 | module Example 5 | class BaseApp < Sinatra::Base 6 | enable :sessions 7 | enable :raise_errors 8 | disable :show_exceptions 9 | 10 | get '/debug' do 11 | content_type :text 12 | env['rack.session'].to_yaml 13 | end 14 | end 15 | 16 | class BadAuthentication < Sinatra::Base 17 | get '/unauthenticated' do 18 | status 403 19 | <<-EOS 20 |

Unable to authenticate, sorry bud.

21 |

#{env['warden'].message}

22 | EOS 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start do 3 | add_filter '/spec' 4 | add_filter '/example' 5 | end 6 | 7 | require 'warden/github' 8 | require File.expand_path('../../example/simple_app', __FILE__) 9 | require 'rack/test' 10 | require 'addressable/uri' 11 | require 'webmock/rspec' 12 | 13 | RSpec.configure do |config| 14 | config.include(Rack::Test::Methods) 15 | 16 | def app 17 | Example.app 18 | end 19 | 20 | def stub_user_session_request 21 | stub_request(:get, "https://api.github.com/user/sessions/active?browser_session_id=abcdefghijklmnop"). 22 | with(headers: {'Accept'=>'application/vnd.github.v3+json', 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Authorization'=>'token the_token', 'Content-Type'=>'application/json', 'User-Agent'=>"Octokit Ruby Gem #{Octokit::VERSION}"}) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/warden/github/verifier.rb: -------------------------------------------------------------------------------- 1 | module Warden 2 | module GitHub 3 | class Verifier 4 | def self.dump(user) 5 | new.serialize(user) 6 | end 7 | 8 | def self.load(key) 9 | new.deserialize(key) 10 | end 11 | 12 | def serialize(user) 13 | cookie_verifier.generate(user.marshal_dump) 14 | end 15 | 16 | def deserialize(key) 17 | User.new.tap do |u| 18 | u.marshal_load(cookie_verifier.verify(key)) 19 | end 20 | rescue ::ActiveSupport::MessageVerifier::InvalidSignature 21 | nil 22 | end 23 | 24 | def verifier_key 25 | self.class.verifier_key 26 | end 27 | 28 | private 29 | def self.verifier_key 30 | @verifier_key ||= ENV['WARDEN_GITHUB_VERIFIER_SECRET'] || SecureRandom.hex 31 | end 32 | 33 | def cookie_verifier 34 | @cookie_verifier ||= ::ActiveSupport::MessageVerifier.new(verifier_key, serializer: JSON) 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/warden/github/sso.rb: -------------------------------------------------------------------------------- 1 | module Warden 2 | module GitHub 3 | module SSO 4 | def warden_github_sso_session_valid?(user, expiry_in_seconds = 30) 5 | return true if defined?(::Rails) && ::Rails.env.test? 6 | if warden_github_sso_session_needs_reverification?(user, expiry_in_seconds) 7 | if user.browser_session_valid?(expiry_in_seconds) 8 | warden_github_sso_session_reverify! 9 | return true 10 | end 11 | return false 12 | end 13 | true 14 | end 15 | 16 | def warden_github_sso_session_verified_at 17 | session[:warden_github_sso_session_verified_at] || Time.now.utc.to_i - 86400 18 | end 19 | 20 | def warden_github_sso_session_reverify! 21 | session[:warden_github_sso_session_verified_at] = Time.now.utc.to_i 22 | end 23 | 24 | def warden_github_sso_session_needs_reverification?(user, expiry_in_seconds) 25 | user.using_single_sign_out? && 26 | (warden_github_sso_session_verified_at <= (Time.now.utc.to_i - expiry_in_seconds)) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2013 Corey Donohoe 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /spec/unit/sso_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | class FakeController 4 | def session 5 | @session ||= {} 6 | end 7 | include Warden::GitHub::SSO 8 | end 9 | 10 | describe Warden::GitHub::SSO do 11 | let(:default_attrs) do 12 | { 'login' => 'john', 13 | 'name' => 'John Doe', 14 | 'gravatar_id' => '38581cb351a52002548f40f8066cfecg', 15 | 'avatar_url' => 'http://example.com/avatar.jpg', 16 | 'email' => 'john@doe.com', 17 | 'company' => 'Doe, Inc.' } 18 | end 19 | 20 | let(:user) do 21 | Warden::GitHub::User.new(default_attrs, "the_token", "abcdefghijklmnop") 22 | end 23 | 24 | subject do 25 | FakeController.new 26 | end 27 | 28 | describe "warden_github_sso_session_valid?" do 29 | it "identifies when browsers need to be reverified" do 30 | subject.session[:warden_github_sso_session_verified_at] = Time.now.utc.to_i - 10 31 | expect(subject).to be_warden_github_sso_session_valid(user) 32 | 33 | subject.session[:warden_github_sso_session_verified_at] = Time.now.utc.to_i - 300 34 | stub_user_session_request.to_return(status: 204, body: "", headers: {}) 35 | expect(subject).to be_warden_github_sso_session_valid(user) 36 | 37 | subject.session[:warden_github_sso_session_verified_at] = Time.now.utc.to_i - 300 38 | stub_user_session_request.to_return(status: 404, body: "", headers: {}) 39 | expect(subject).not_to be_warden_github_sso_session_valid(user) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /warden-github.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.expand_path('../lib/warden/github/version', __FILE__) 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "warden-github" 6 | s.version = Warden::GitHub::VERSION 7 | s.platform = Gem::Platform::RUBY 8 | s.authors = ["Corey Donohoe"] 9 | s.email = ["atmos@atmos.org"] 10 | s.homepage = "http://github.com/atmos/warden-github" 11 | s.summary = "A warden strategy for easy oauth integration with github" 12 | s.license = 'MIT' 13 | s.description = s.summary 14 | 15 | s.rubyforge_project = "warden-github" 16 | 17 | s.add_dependency "warden", ">1.0" 18 | s.add_dependency "octokit", ">2.1.0" 19 | s.add_dependency "activesupport", ">3.0" 20 | 21 | s.add_development_dependency "rack", "~>1.4.1" 22 | s.add_development_dependency "rake" 23 | s.add_development_dependency "rspec", "~>3.6" 24 | s.add_development_dependency "simplecov" 25 | s.add_development_dependency "webmock", "~>1.9" 26 | s.add_development_dependency "sinatra" 27 | s.add_development_dependency "shotgun" 28 | s.add_development_dependency "addressable", ">2.2.0" 29 | s.add_development_dependency "rack-test", "~>0.5.3" 30 | s.add_development_dependency "yajl-ruby" 31 | 32 | s.files = `git ls-files`.split("\n") 33 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 34 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 35 | s.require_paths = ["lib"] 36 | end 37 | -------------------------------------------------------------------------------- /lib/warden/github/membership_cache.rb: -------------------------------------------------------------------------------- 1 | module Warden 2 | module GitHub 3 | # A hash subclass that acts as a cache for organization and team 4 | # membership states. Only membership states that are true are cached. These 5 | # are invalidated after a certain time. 6 | class MembershipCache 7 | CACHE_TIMEOUT = 60 * 5 8 | 9 | def initialize(data) 10 | @data = data 11 | end 12 | 13 | # Fetches a membership status by type and id (e.g. 'org', 'my_company') 14 | # from cache. If no cached value is present or if the cached value 15 | # expired, the block will be invoked and the return value, if true, 16 | # cached for e certain time. 17 | def fetch_membership(type, id) 18 | type = type.to_s 19 | id = id.to_s 20 | 21 | if cached_membership_valid?(type, id) 22 | true 23 | elsif block_given? && yield 24 | cache_membership(type, id) 25 | true 26 | else 27 | false 28 | end 29 | end 30 | 31 | private 32 | 33 | def cached_membership_valid?(type, id) 34 | timestamp = @data.fetch(type).fetch(id) 35 | 36 | if Time.now.to_i > timestamp + CACHE_TIMEOUT 37 | @data.fetch(type).delete(id) 38 | false 39 | else 40 | true 41 | end 42 | rescue IndexError 43 | false 44 | end 45 | 46 | def cache_membership(type, id) 47 | hash = @data[type] ||= {} 48 | hash[id] = Time.now.to_i 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/fixtures/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "public_repos": 14, 3 | "company": "Foobar, Inc.", 4 | "type": "User", 5 | "url": "https:\/\/api.github.com\/users\/john", 6 | "received_events_url": "https:\/\/api.github.com\/users\/john\/received_events", 7 | "login": "john", 8 | "updated_at": "2013-02-03T18:50:08Z", 9 | "avatar_url": "https:\/\/secure.gravatar.com\/avatar\/38581cb351a52002548f40f8066cfecf?d=https:\/\/a248.e.akamai.net\/assets.github.com%2Fimages%2Fgravatars%2Fgravatar-user-420.png", 10 | "collaborators": 0, 11 | "public_gists": 8, 12 | "hireable": false, 13 | "events_url": "https:\/\/api.github.com\/users\/john\/events{\/privacy}", 14 | "organizations_url": "https:\/\/api.github.com\/users\/john\/orgs", 15 | "total_private_repos": 0, 16 | "gists_url": "https:\/\/api.github.com\/users\/john\/gists{\/gist_id}", 17 | "private_gists": 9, 18 | "followers": 15, 19 | "following": 35, 20 | "created_at": "2009-09-17T14:12:20Z", 21 | "bio": "Just a simple test user", 22 | "owned_private_repos": 0, 23 | "location": "Bay Area", 24 | "starred_url": "https:\/\/api.github.com\/users\/john\/starred{\/owner}{\/repo}", 25 | "gravatar_id": "38581cb351a52002548f40f8066cfecf", 26 | "name": "John Doe", 27 | "blog": "http:\/\/johndoe.com/", 28 | "disk_usage": 10361, 29 | "html_url": "https:\/\/github.com\/john", 30 | "followers_url": "https:\/\/api.github.com\/users\/john\/followers", 31 | "id": 1234, 32 | "plan": { 33 | "collaborators": 1, 34 | "space": 614400, 35 | "name": "micro", 36 | "private_repos": 5 37 | }, 38 | "email": "me@johndoe.com", 39 | "repos_url": "https:\/\/api.github.com\/users\/john\/repos", 40 | "subscriptions_url": "https:\/\/api.github.com\/users\/john\/subscriptions", 41 | "following_url": "https:\/\/api.github.com\/users\/john\/following" 42 | } 43 | -------------------------------------------------------------------------------- /spec/unit/oauth_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Warden::GitHub::OAuth do 4 | let(:default_attrs) do 5 | { state: 'abc', 6 | client_id: 'foo', 7 | client_secret: 'bar', 8 | redirect_uri: 'http://example.com/callback' } 9 | end 10 | 11 | def oauth(attrs=default_attrs) 12 | described_class.new(attrs) 13 | end 14 | 15 | describe '#authorize_uri' do 16 | it 'contains the base uri' do 17 | expect(oauth.authorize_uri.to_s).to \ 18 | include Octokit.web_endpoint 19 | end 20 | 21 | %w[ client_id state redirect_uri ].each do |name| 22 | it "contains the correct #{name} param" do 23 | uri = Addressable::URI.parse(oauth.authorize_uri) 24 | 25 | expect(uri.query_values[name]).to eq default_attrs[name.to_sym] 26 | end 27 | end 28 | 29 | { nil: nil, empty: '' }.each do |desc, value| 30 | it "does not contain the scope param if #{desc}" do 31 | uri = oauth(default_attrs.merge(scope: value)).authorize_uri 32 | 33 | expect(uri.to_s).not_to include 'scope' 34 | end 35 | end 36 | end 37 | 38 | describe '#access_token' do 39 | def expect_request(attrs={}) 40 | stub_request(:post, %r{\/login\/oauth\/access_token$}). 41 | with(body: hash_including(attrs.fetch(:params, {}))). 42 | to_return(status: 200, 43 | body: attrs.fetch(:answer, 'access_token=foobar')) 44 | end 45 | 46 | it 'exchanges the code for an access token' do 47 | expect_request(answer: 'access_token=the_token&token_type=bearer') 48 | 49 | expect(oauth.access_token).to eq 'the_token' 50 | end 51 | 52 | it 'raises BadVerificationCode if no access token is returned' do 53 | expect_request(answer: 'error=bad_verification_code') 54 | 55 | expect { oauth.access_token }. 56 | to raise_error(described_class::BadVerificationCode) 57 | end 58 | 59 | %w[ client_id client_secret code ].each do |name| 60 | it "performs a request containing the correct #{name} param" do 61 | oauth(default_attrs.merge(code: 'the_code')).tap do |o| 62 | expect_request(params: { name => o.send(name) }) 63 | o.access_token 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /example/multi_scope_app.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../setup', __FILE__) 2 | 3 | module Example 4 | class MultiScopeApp < BaseApp 5 | enable :inline_templates 6 | 7 | GITHUB_CONFIG = { 8 | client_id: ENV['GITHUB_CLIENT_ID'] || 'test_client_id', 9 | client_secret: ENV['GITHUB_CLIENT_SECRET'] || 'test_client_secret' 10 | } 11 | 12 | use Warden::Manager do |config| 13 | config.failure_app = BadAuthentication 14 | config.default_strategies :github 15 | config.scope_defaults :default, config: GITHUB_CONFIG 16 | config.scope_defaults :admin, config: GITHUB_CONFIG.merge(scope: 'user,notifications') 17 | end 18 | 19 | get '/' do 20 | erb :index 21 | end 22 | 23 | get '/login' do 24 | env['warden'].authenticate! 25 | redirect '/' 26 | end 27 | 28 | get '/admin/login' do 29 | env['warden'].authenticate!(scope: :admin) 30 | redirect '/' 31 | end 32 | 33 | get '/logout' do 34 | if params.include?('all') 35 | env['warden'].logout 36 | else 37 | env['warden'].logout(:default) 38 | end 39 | redirect '/' 40 | end 41 | 42 | get '/admin/logout' do 43 | env['warden'].logout(:admin) 44 | redirect '/' 45 | end 46 | end 47 | 48 | def self.app 49 | @app ||= Rack::Builder.new do 50 | run MultiScopeApp 51 | end 52 | end 53 | end 54 | 55 | __END__ 56 | 57 | @@ index 58 | 59 | 60 |

Multi Scope App Example

61 | 76 |
77 |
78 |
User:
79 |
<%= env['warden'].authenticated? ? env['warden'].user.name : 'Not signed in' %>
80 |
Admin:
81 |
<%= env['warden'].authenticated?(:admin) ? env['warden'].user(:admin).name : 'Not signed in' %>
82 |
83 | 84 | 85 | -------------------------------------------------------------------------------- /spec/unit/membership_cache_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Warden::GitHub::MembershipCache do 4 | describe '#fetch_membership' do 5 | it 'returns false by default' do 6 | cache = described_class.new({}) 7 | expect(cache.fetch_membership('foo', 'bar')).to be_falsey 8 | end 9 | 10 | context 'when cache valid' do 11 | let(:cache) do 12 | described_class.new('foo' => { 13 | 'bar' => Time.now.to_i - described_class::CACHE_TIMEOUT + 5 14 | }) 15 | end 16 | 17 | it 'returns true' do 18 | expect(cache.fetch_membership('foo', 'bar')).to be_truthy 19 | end 20 | 21 | it 'does not invoke the block' do 22 | expect { |b| cache.fetch_membership('foo', 'bar', &b) }. 23 | to_not yield_control 24 | end 25 | 26 | it 'converts type and id to strings' do 27 | expect(cache.fetch_membership(:foo, :bar)).to be_truthy 28 | end 29 | end 30 | 31 | context 'when cache expired' do 32 | let(:cache) do 33 | described_class.new('foo' => { 34 | 'bar' => Time.now.to_i - described_class::CACHE_TIMEOUT - 5 35 | }) 36 | end 37 | 38 | context 'when no block given' do 39 | it 'returns false' do 40 | expect(cache.fetch_membership('foo', 'bar')).to be_falsey 41 | end 42 | end 43 | 44 | it 'invokes the block' do 45 | expect { |b| cache.fetch_membership('foo', 'bar', &b) }. 46 | to yield_control 47 | end 48 | end 49 | 50 | it 'caches the value when block returns true' do 51 | cache = described_class.new({}) 52 | cache.fetch_membership('foo', 'bar') { true } 53 | expect(cache.fetch_membership('foo', 'bar')).to be_truthy 54 | end 55 | 56 | it 'does not cache the value when block returns false' do 57 | cache = described_class.new({}) 58 | cache.fetch_membership('foo', 'bar') { false } 59 | expect(cache.fetch_membership('foo', 'bar')).to be_falsey 60 | end 61 | end 62 | 63 | it 'uses the hash that is passed to the initializer as storage' do 64 | time = Time.now.to_i 65 | hash = { 66 | 'foo' => { 67 | 'valid' => time, 68 | 'timedout' => time - described_class::CACHE_TIMEOUT - 5 69 | } 70 | } 71 | cache = described_class.new(hash) 72 | 73 | # Verify that existing data in the hash is used: 74 | expect(cache.fetch_membership('foo', 'valid')).to be(true) 75 | expect(cache.fetch_membership('foo', 'timedout')).to be(false) 76 | 77 | # Add new data to the hash: 78 | cache.fetch_membership('foo', 'new') { true } 79 | cache.fetch_membership('foo', 'false') { false } 80 | 81 | # Verify the final hash state: 82 | expect(hash).to eq('foo' => { 83 | 'valid' => time, 84 | 'new' => time 85 | }) 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/warden/github/oauth.rb: -------------------------------------------------------------------------------- 1 | require 'uri' 2 | require 'net/https' 3 | 4 | module Warden 5 | module GitHub 6 | class OAuth 7 | BadVerificationCode = Class.new(StandardError) 8 | 9 | attr_reader :code, 10 | :state, 11 | :scope, 12 | :client_secret, 13 | :client_id, 14 | :redirect_uri 15 | 16 | def initialize(attrs={}) 17 | @code = attrs[:code] 18 | @state = attrs[:state] 19 | @scope = attrs[:scope] 20 | @client_id = attrs.fetch(:client_id) 21 | @client_secret = attrs.fetch(:client_secret) 22 | @redirect_uri = attrs.fetch(:redirect_uri) 23 | end 24 | 25 | def authorize_uri 26 | @authorize_uri ||= build_uri( 27 | '/login/oauth/authorize', 28 | client_id: client_id, 29 | redirect_uri: redirect_uri, 30 | scope: scope, 31 | state: state) 32 | end 33 | 34 | def access_token 35 | @access_token ||= load_access_token 36 | end 37 | 38 | private 39 | 40 | def load_access_token 41 | http = Net::HTTP.new(access_token_uri.host, access_token_uri.port) 42 | http.use_ssl = access_token_uri.scheme == 'https' 43 | 44 | request = Net::HTTP::Post.new(access_token_uri.path) 45 | request.body = access_token_uri.query 46 | 47 | response = http.request(request) 48 | decode_params(response.body).fetch('access_token') 49 | rescue IndexError 50 | fail BadVerificationCode, 'Bad verification code' 51 | end 52 | 53 | def access_token_uri 54 | @access_token_uri ||= build_uri( 55 | '/login/oauth/access_token', 56 | client_id: client_id, 57 | client_secret: client_secret, 58 | code: code) 59 | end 60 | 61 | def build_uri(path, params) 62 | URI(Octokit.web_endpoint).tap do |uri| 63 | uri.path = path 64 | uri.query = encode_params(normalize_params(params)) 65 | end 66 | end 67 | 68 | def normalize_params(params) 69 | params.reject { |_,v| v.nil? || v == '' } 70 | end 71 | 72 | def encode_params(params) 73 | if URI.respond_to? :encode_www_form 74 | return URI.encode_www_form(params) 75 | end 76 | 77 | params.map { |*kv| 78 | kv.flatten.map { |i| 79 | URI.encode(i.to_s, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]")) 80 | }.join('=') 81 | }.join('&') 82 | end 83 | 84 | def decode_params(params) 85 | if URI.respond_to? :decode_www_form 86 | return Hash[URI.decode_www_form(params)] 87 | end 88 | 89 | Hash[ 90 | params.split('&').map { |i| 91 | i.split('=').map { |i| URI.decode(i) } 92 | } 93 | ] 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /example/simple_app.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../setup', __FILE__) 2 | 3 | module Example 4 | class SimpleApp < BaseApp 5 | include Warden::GitHub::SSO 6 | 7 | enable :inline_templates 8 | 9 | GITHUB_CONFIG = { 10 | client_id: ENV['GITHUB_CLIENT_ID'] || 'test_client_id', 11 | client_secret: ENV['GITHUB_CLIENT_SECRET'] || 'test_client_secret', 12 | scope: 'user' 13 | } 14 | 15 | use Warden::Manager do |config| 16 | config.failure_app = BadAuthentication 17 | config.default_strategies :github 18 | config.scope_defaults :default, config: GITHUB_CONFIG 19 | config.serialize_from_session { |key| Warden::GitHub::Verifier.load(key) } 20 | config.serialize_into_session { |user| Warden::GitHub::Verifier.dump(user) } 21 | end 22 | 23 | def verify_browser_session 24 | if env['warden'].user && !warden_github_sso_session_valid?(env['warden'].user, 10) 25 | env['warden'].logout 26 | end 27 | end 28 | 29 | get '/' do 30 | erb :index 31 | end 32 | 33 | get '/profile' do 34 | verify_browser_session 35 | env['warden'].authenticate! 36 | erb :profile 37 | end 38 | 39 | get '/login' do 40 | verify_browser_session 41 | env['warden'].authenticate! 42 | redirect '/' 43 | end 44 | 45 | get '/logout' do 46 | env['warden'].logout 47 | redirect '/' 48 | end 49 | end 50 | 51 | def self.app 52 | @app ||= Rack::Builder.new do 53 | run SimpleApp 54 | end 55 | end 56 | end 57 | 58 | __END__ 59 | 60 | @@ layout 61 | 62 | 63 |

Simple App Example

64 | 73 |
74 | <%= yield %> 75 | 76 | 77 | 78 | @@ index 79 | <% if env['warden'].authenticated? %> 80 |

81 | ' width='50' height='50' /> 82 | Welcome <%= env['warden'].user.name %> 83 |

84 | <% else %> 85 |

Welcome stranger

86 | <% end %> 87 | 88 | @@ profile 89 |

Profile

90 |
91 |
Rails Org Member:
92 |
<%= env['warden'].user.organization_member?('rails') %>
93 |
Publicized Rails Org Member:
94 |
<%= env['warden'].user.organization_public_member?('rails') %>
95 |
Rails Committer Team Member:
96 |
<%= env['warden'].user.team_member?(632) %>
97 |
GitHub Site Admin:
98 |
<%= env['warden'].user.site_admin? %>
99 | <% if env['warden'].user.using_single_sign_out? %> 100 |
GitHub Browser Session ID
101 |
<%= env['warden'].user.browser_session_id %>
102 |
GitHub Browser Session Valid
103 |
<%= warden_github_sso_session_valid?(env['warden'].user, 10) %>
104 |
GitHub Browser Session Verified At
105 |
<%= Time.at(warden_github_sso_session_verified_at) %>
106 | <% end %> 107 |
108 | -------------------------------------------------------------------------------- /lib/warden/github/strategy.rb: -------------------------------------------------------------------------------- 1 | module Warden 2 | module GitHub 3 | class Strategy < ::Warden::Strategies::Base 4 | SESSION_KEY = 'warden.github.oauth' 5 | 6 | # The first time this is called, the flow gets set up, stored in the 7 | # session and the user gets redirected to GitHub to perform the login. 8 | # 9 | # When this is called a second time, the flow gets evaluated, the code 10 | # gets exchanged for a token, and the user gets loaded and passed to 11 | # warden. 12 | # 13 | # If anything goes wrong, the flow is aborted and reset, and warden gets 14 | # notified about the failure. 15 | # 16 | # Once the user gets set, warden invokes the after_authentication callback 17 | # that handles the redirect to the originally requested url and cleans up 18 | # the flow. Note that this is done in a hook because setting a user 19 | # (through #success!) and redirecting (through #redirect!) inside the 20 | # #authenticate! method are mutual exclusive. 21 | def authenticate! 22 | if in_flow? 23 | continue_flow! 24 | else 25 | begin_flow! 26 | end 27 | end 28 | 29 | def in_flow? 30 | !custom_session.empty? && 31 | params['state'] && 32 | (params['code'] || params['error']) 33 | end 34 | 35 | # This is called by the after_authentication hook which is invoked after 36 | # invoking #success!. 37 | def finalize_flow! 38 | redirect!(custom_session['return_to']) 39 | teardown_flow 40 | throw(:warden) 41 | end 42 | 43 | private 44 | 45 | def begin_flow! 46 | custom_session['state'] = state 47 | custom_session['return_to'] = request.url 48 | redirect!(oauth.authorize_uri.to_s) 49 | throw(:warden) 50 | end 51 | 52 | def continue_flow! 53 | validate_flow! 54 | success!(load_user) 55 | end 56 | 57 | def abort_flow!(message) 58 | teardown_flow 59 | fail!(message) 60 | throw(:warden) 61 | end 62 | 63 | def teardown_flow 64 | session.delete(SESSION_KEY) 65 | end 66 | 67 | def validate_flow! 68 | if params['state'] != state 69 | abort_flow!('State mismatch') 70 | elsif (error = params['error']) && !error.empty? 71 | abort_flow!(error.gsub(/_/, ' ')) 72 | end 73 | 74 | if params['browser_session_id'] 75 | custom_session['browser_session_id'] = params['browser_session_id'] 76 | end 77 | end 78 | 79 | def custom_session 80 | session[SESSION_KEY] ||= {} 81 | end 82 | 83 | def load_user 84 | User.load(oauth.access_token, custom_session['browser_session_id']) 85 | rescue OAuth::BadVerificationCode => e 86 | abort_flow!(e.message) 87 | end 88 | 89 | def state 90 | @state ||= custom_session['state'] || SecureRandom.hex(20) 91 | end 92 | 93 | def oauth 94 | @oauth ||= OAuth.new( 95 | config.to_hash.merge(code: params['code'], state: state)) 96 | end 97 | 98 | def config 99 | @config ||= ::Warden::GitHub::Config.new(env, scope) 100 | end 101 | end 102 | end 103 | end 104 | 105 | Warden::Strategies.add(:github, Warden::GitHub::Strategy) 106 | -------------------------------------------------------------------------------- /lib/warden/github/user.rb: -------------------------------------------------------------------------------- 1 | module Warden 2 | module GitHub 3 | class User < Struct.new(:attribs, :token, :browser_session_id) 4 | ATTRIBUTES = %w[id login name gravatar_id avatar_url email company site_admin].freeze 5 | 6 | attr_accessor :memberships 7 | 8 | def self.load(access_token, browser_session_id = nil) 9 | api = Octokit::Client.new(access_token: access_token) 10 | data = { } 11 | 12 | api.user.to_hash.each do |k,v| 13 | data[k.to_s] = v if ATTRIBUTES.include?(k.to_s) 14 | end 15 | 16 | new(data, access_token, browser_session_id) 17 | end 18 | 19 | def marshal_dump 20 | Hash[members.zip(values)] 21 | end 22 | 23 | def marshal_load(hash) 24 | hash.each { |k,v| send("#{k}=", v) } 25 | end 26 | 27 | ATTRIBUTES.each do |name| 28 | define_method(name) { attribs[name] } 29 | end 30 | 31 | # See if the user is a public member of the named organization 32 | # 33 | # name - the organization name 34 | # 35 | # Returns: true if the user is publicized as an org member 36 | def organization_public_member?(org_name) 37 | membership_cache.fetch_membership(:org_pub, org_name) do 38 | api.organization_public_member?(org_name, login) 39 | end 40 | end 41 | 42 | # Backwards compatibility: 43 | alias_method :publicized_organization_member?, :organization_public_member? 44 | 45 | # See if the user is a member of the named organization 46 | # 47 | # name - the organization name 48 | # 49 | # Returns: true if the user has access, false otherwise 50 | def organization_member?(org_name) 51 | membership_cache.fetch_membership(:org, org_name) do 52 | api.organization_member?(org_name, login) 53 | end 54 | end 55 | 56 | # See if the user is a member of the team id 57 | # 58 | # team_id - the team's id 59 | # 60 | # Returns: true if the user has access, false otherwise 61 | def team_member?(team_id) 62 | membership_cache.fetch_membership(:team, team_id) do 63 | api.team_member?(team_id, login) 64 | end 65 | end 66 | 67 | # Identify GitHub employees/staff members. 68 | # 69 | # Returns: true if the authenticated user is a GitHub employee, false otherwise 70 | def site_admin? 71 | !!site_admin 72 | end 73 | 74 | # Identify if the browser session is still valid 75 | # 76 | # Returns: true if the browser session is still active or the GitHub API is unavailable 77 | def browser_session_valid?(since = 120) 78 | return true unless using_single_sign_out? 79 | client = api 80 | client.get("/user/sessions/active", browser_session_id: browser_session_id) 81 | client.last_response.status == 204 82 | rescue Octokit::ServerError # GitHub API unavailable 83 | true 84 | rescue Octokit::ClientError => e # GitHub API failed 85 | false 86 | end 87 | 88 | # Identify if the user is on a GitHub SSO property 89 | # 90 | # Returns: true if a browser_session_id is present, false otherwise. 91 | def using_single_sign_out? 92 | !(browser_session_id.nil? || browser_session_id == "") 93 | end 94 | 95 | # Access the GitHub API from Octokit 96 | # 97 | # Octokit is a robust client library for the GitHub API 98 | # https://github.com/octokit/octokit.rb 99 | # 100 | # Returns a cached client object for easy use 101 | def api 102 | # Don't cache instance for now because of a ruby marshaling bug present 103 | # in MRI 1.9.3 (Bug #7627) that causes instance variables to be 104 | # marshaled even when explicitly specifying #marshal_dump. 105 | Octokit::Client.new(login: login, access_token: token) 106 | end 107 | 108 | private 109 | 110 | def membership_cache 111 | self.memberships ||= {} 112 | @membership_cache ||= MembershipCache.new(memberships) 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /spec/unit/user_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Warden::GitHub::User do 4 | let(:default_attrs) do 5 | { 'login' => 'john', 6 | 'name' => 'John Doe', 7 | 'gravatar_id' => '38581cb351a52002548f40f8066cfecg', 8 | 'avatar_url' => 'http://example.com/avatar.jpg', 9 | 'email' => 'john@doe.com', 10 | 'company' => 'Doe, Inc.' } 11 | end 12 | let(:token) { 'the_token' } 13 | 14 | let(:user) do 15 | described_class.new(default_attrs, token) 16 | end 17 | 18 | let(:sso_user) do 19 | described_class.new(default_attrs, token, "abcdefghijklmnop") 20 | end 21 | 22 | describe '#token' do 23 | it 'returns the token' do 24 | expect(user.token).to eq token 25 | end 26 | end 27 | 28 | %w[login name gravatar_id avatar_url email company].each do |name| 29 | describe "##{name}" do 30 | it "returns the #{name}" do 31 | expect(user.send(name)).to eq default_attrs[name] 32 | end 33 | end 34 | end 35 | 36 | describe '#api' do 37 | it 'returns a preconfigured Octokit client for the user' do 38 | api = user.api 39 | 40 | expect(api).to be_an Octokit::Client 41 | expect(api.login).to eq user.login 42 | expect(api.access_token).to eq user.token 43 | end 44 | end 45 | 46 | def stub_api(user, method, args, ret) 47 | api = double 48 | allow(user).to receive_messages(api: api) 49 | expect(api).to receive(method).with(*args).and_return(ret) 50 | end 51 | 52 | [:organization_public_member?, :organization_member?].each do |method| 53 | describe "##{method}" do 54 | context 'when user is not member' do 55 | it 'returns false' do 56 | stub_api(user, method, ['rails', user.login], false) 57 | expect(user.send(method, 'rails')).to be_falsey 58 | end 59 | end 60 | 61 | context 'when user is member' do 62 | it 'returns true' do 63 | stub_api(user, method, ['rails', user.login], true) 64 | expect(user.send(method, 'rails')).to be_truthy 65 | end 66 | end 67 | end 68 | end 69 | 70 | describe '#team_member?' do 71 | context 'when user is not member' do 72 | it 'returns false' do 73 | api = double() 74 | allow(user).to receive_messages(api: api) 75 | 76 | allow(api).to receive(:team_member?).with(123, user.login).and_return(false) 77 | 78 | expect(user).not_to be_team_member(123) 79 | end 80 | end 81 | 82 | context 'when user is member' do 83 | it 'returns true' do 84 | api = double() 85 | allow(user).to receive_messages(api: api) 86 | allow(api).to receive(:team_member?).with(123, user.login).and_return(true) 87 | 88 | expect(user).to be_team_member(123) 89 | end 90 | end 91 | end 92 | 93 | describe '.load' do 94 | it 'loads the user data from GitHub and creates an instance' do 95 | client = double 96 | attrs = {} 97 | 98 | expect(Octokit::Client). 99 | to receive(:new). 100 | with(access_token: token). 101 | and_return(client) 102 | expect(client).to receive(:user).and_return(attrs) 103 | 104 | user = described_class.load(token) 105 | 106 | expect(user.attribs).to eq attrs 107 | expect(user.token).to eq token 108 | end 109 | end 110 | 111 | # NOTE: This always passes on MRI 1.9.3 because of ruby bug #7627. 112 | it 'marshals correctly' do 113 | expect(Marshal.load(Marshal.dump(user))).to eq user 114 | end 115 | 116 | describe 'single sign out' do 117 | it "knows if the user is using single sign out" do 118 | expect(user).not_to be_using_single_sign_out 119 | expect(sso_user).to be_using_single_sign_out 120 | end 121 | 122 | context "browser reverification" do 123 | it "handles success" do 124 | stub_user_session_request.to_return(status: 204, body: "", headers: {}) 125 | expect(sso_user).to be_browser_session_valid 126 | end 127 | 128 | it "handles failure" do 129 | stub_user_session_request.to_return(status: 404, body: "", headers: {}) 130 | expect(sso_user).not_to be_browser_session_valid 131 | end 132 | 133 | it "handles GitHub being unavailable" do 134 | stub_user_session_request.to_raise(Octokit::ServerError.new) 135 | expect(sso_user).to be_browser_session_valid 136 | end 137 | 138 | it "handles authentication failures" do 139 | stub_user_session_request.to_return(status: 403, body: "", headers: {}) 140 | expect(sso_user).not_to be_browser_session_valid 141 | end 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /lib/warden/github/config.rb: -------------------------------------------------------------------------------- 1 | require 'uri' 2 | 3 | module Warden 4 | module GitHub 5 | # This class encapsulates the configuration of the strategy. A strategy can 6 | # be configured through Warden::Manager by defining a scope's default. Thus, 7 | # it is possible to use the same strategy with different configurations by 8 | # using multiple scopes. 9 | # 10 | # To configure a scope, use #scope_defaults inside the Warden::Manager 11 | # config block. The first arg is the name of the scope (the default is 12 | # :default, so use that to configure the default scope), the second arg is 13 | # an options hash which should contain: 14 | # 15 | # - :strategies : An array of strategies to use for this scope. Since this 16 | # strategy is called :github, include it in the array. 17 | # 18 | # - :config : A hash containing the configs that are used for OAuth. 19 | # Valid parameters include :client_id, :client_secret, 20 | # :scope, :redirect_uri. Please refer to the OAuth 21 | # documentation of the GitHub API for the meaning of these 22 | # parameters. 23 | # 24 | # If :client_id or :client_secret are not specified, they 25 | # will be fetched from ENV['GITHUB_CLIENT_ID'] and 26 | # ENV['GITHUB_CLIENT_SECRET'], respectively. 27 | # 28 | # :scope defaults to nil. 29 | # 30 | # If no :redirect_uri is specified, the current path will 31 | # be used. If a path is specified it will be appended to 32 | # the request host, forming a valid URL. 33 | # 34 | # Examples 35 | # 36 | # use Warden::Manager do |config| 37 | # config.failure_app = BadAuthentication 38 | # 39 | # # The following line doesn't specify any custom configurations, thus 40 | # # the default scope will be using the implict client_id, 41 | # # client_secret, and redirect_uri. 42 | # config.default_strategies :github 43 | # 44 | # # This configures an additional scope that uses the github strategy 45 | # # with custom configuration. 46 | # config.scope_defaults :admin, config: { client_id: 'foobar', 47 | # client_secret: 'barfoo', 48 | # scope: 'user,repo', 49 | # redirect_uri: '/admin/oauth/callback' } 50 | # end 51 | class Config 52 | BadConfig = Class.new(StandardError) 53 | 54 | include ::Warden::Mixins::Common 55 | 56 | attr_reader :env, :warden_scope 57 | 58 | def initialize(env, warden_scope) 59 | @env = env 60 | @warden_scope = warden_scope 61 | end 62 | 63 | def client_id 64 | custom_config[:client_id] || 65 | deprecated_config(:github_client_id) || 66 | ENV['GITHUB_CLIENT_ID'] || 67 | fail(BadConfig, 'Missing client_id configuration.') 68 | end 69 | 70 | def client_secret 71 | custom_config[:client_secret] || 72 | deprecated_config(:github_secret) || 73 | ENV['GITHUB_CLIENT_SECRET'] || 74 | fail(BadConfig, 'Missing client_secret configuration.') 75 | end 76 | 77 | def redirect_uri 78 | uri_or_path = 79 | custom_config[:redirect_uri] || 80 | deprecated_config(:github_callback_url) || 81 | request.path 82 | 83 | normalized_uri(uri_or_path).to_s 84 | end 85 | 86 | def scope 87 | custom_config[:scope] || deprecated_config(:github_scopes) 88 | end 89 | 90 | def to_hash 91 | { client_id: client_id, 92 | client_secret: client_secret, 93 | redirect_uri: redirect_uri, 94 | scope: scope } 95 | end 96 | 97 | private 98 | 99 | def custom_config 100 | @custom_config ||= 101 | env['warden']. 102 | config[:scope_defaults]. 103 | fetch(warden_scope, {}). 104 | fetch(:config, {}) 105 | end 106 | 107 | def deprecated_config(name) 108 | env['warden'].config[name].tap do |config| 109 | unless config.nil? 110 | warn "[warden-github] Deprecated configuration #{name} used. Please refer to the README for updated configuration instructions." 111 | end 112 | end 113 | end 114 | 115 | def normalized_uri(uri_or_path) 116 | uri = URI(request.url) 117 | uri.path = extract_path(URI(uri_or_path)) 118 | uri.query = nil 119 | uri.fragment = nil 120 | 121 | correct_scheme(uri) 122 | end 123 | 124 | def extract_path(uri) 125 | path = uri.path 126 | 127 | if path.start_with?('/') 128 | path 129 | else 130 | "/#{path}" 131 | end 132 | end 133 | 134 | def https_forwarded_proto? 135 | env['HTTP_X_FORWARDED_PROTO'] && 136 | env['HTTP_X_FORWARDED_PROTO'].split(',')[0] == "https" 137 | end 138 | 139 | def correct_scheme(uri) 140 | if uri.scheme != 'https' && https_forwarded_proto? 141 | uri.scheme = 'https' 142 | # Reparsing will use a different URI subclass, namely URI::HTTPS which 143 | # knows the default port for https and strips it if present. 144 | uri = URI(uri.to_s) 145 | end 146 | uri.port = nil if uri.port == 80 147 | 148 | URI(uri.to_s) 149 | end 150 | end 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /spec/integration/oauth_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'OAuth' do 4 | let(:code) { '1234' } 5 | 6 | def stub_code_for_token_exchange(answer='access_token=the_token') 7 | stub_request(:post, 'https://github.com/login/oauth/access_token'). 8 | with(body: hash_including(code: code)). 9 | to_return(status: 200, body: answer) 10 | end 11 | 12 | def stub_user_retrieval 13 | stub_request(:get, 'https://api.github.com/user'). 14 | with(headers: { 'Authorization' => 'token the_token' }). 15 | to_return( 16 | status: 200, 17 | body: File.read('spec/fixtures/user.json'), 18 | headers: { 'Content-Type' => 'application/json; charset=utf-8' }) 19 | end 20 | 21 | def redirect_uri(response) 22 | Addressable::URI.parse(response.headers['Location']) 23 | end 24 | 25 | context 'when accessing a protected url' do 26 | it 'redirects to GitHub for authentication' do 27 | unauthenticated_response = get '/profile' 28 | github_uri = redirect_uri(unauthenticated_response) 29 | 30 | expect(github_uri.scheme).to eq 'https' 31 | expect(github_uri.host).to eq 'github.com' 32 | expect(github_uri.path).to eq '/login/oauth/authorize' 33 | expect(github_uri.query_values['client_id']).to match(/\w+/) 34 | expect(github_uri.query_values['state']).to match(/\w+/) 35 | expect(github_uri.query_values['redirect_uri']).to match(/^http.*\/profile$/) 36 | end 37 | end 38 | 39 | context 'when redirected back from GitHub' do 40 | it 'exchanges the code for an access token' do 41 | stub_code_for_token_exchange 42 | stub_user_retrieval 43 | 44 | unauthenticated_response = get '/login' 45 | github_uri = redirect_uri(unauthenticated_response) 46 | state = github_uri.query_values['state'] 47 | 48 | get "/login?code=#{code}&state=#{state}" 49 | end 50 | 51 | context 'and the returned state does not match the initial state' do 52 | it 'fails authentication' do 53 | get '/login' 54 | response = get "/login?code=#{code}&state=foobar" 55 | 56 | expect(response).not_to be_successful 57 | expect(response.body).to include 'State mismatch' 58 | end 59 | end 60 | 61 | context 'and GitHub rejects the code while exchanging it for an access token' do 62 | it 'fails authentication' do 63 | stub_code_for_token_exchange('error=bad_verification_code') 64 | 65 | unauthenticated_response = get '/login' 66 | github_uri = redirect_uri(unauthenticated_response) 67 | state = github_uri.query_values['state'] 68 | response = get "/login?code=#{code}&state=#{state}" 69 | 70 | expect(response).not_to be_successful 71 | expect(response.body).to include 'Bad verification code' 72 | end 73 | end 74 | 75 | context 'and the user denied access' do 76 | it 'fails authentication' do 77 | unauthenticated_response = get '/login' 78 | github_uri = redirect_uri(unauthenticated_response) 79 | state = github_uri.query_values['state'] 80 | response = get "/login?error=access_denied&state=#{state}" 81 | 82 | expect(response).not_to be_successful 83 | expect(response.body).to include 'access denied' 84 | end 85 | end 86 | 87 | context 'and code was exchanged for an access token' do 88 | it 'redirects back to the original path' do 89 | stub_code_for_token_exchange 90 | stub_user_retrieval 91 | 92 | unauthenticated_response = get '/profile?foo=bar' 93 | github_uri = redirect_uri(unauthenticated_response) 94 | state = github_uri.query_values['state'] 95 | 96 | callback_response = get "/profile?code=#{code}&state=#{state}" 97 | authenticated_uri = redirect_uri(callback_response) 98 | 99 | expect(authenticated_uri.path).to eq '/profile' 100 | expect(authenticated_uri.query).to eq 'foo=bar' 101 | end 102 | end 103 | 104 | context 'with GitHub SSO and code was exchanged for an access token' do 105 | it 'redirects back to the original path' do 106 | stub_code_for_token_exchange 107 | stub_user_retrieval 108 | 109 | unauthenticated_response = get '/profile?foo=bar' 110 | github_uri = redirect_uri(unauthenticated_response) 111 | state = github_uri.query_values['state'] 112 | 113 | callback_response = get "/profile?code=#{code}&state=#{state}&browser_session_id=abcdefghijklmnop" 114 | authenticated_uri = redirect_uri(callback_response) 115 | 116 | expect(authenticated_uri.path).to eq '/profile' 117 | expect(authenticated_uri.query).to eq 'foo=bar' 118 | end 119 | end 120 | end 121 | 122 | context 'when not inside OAuth flow' do 123 | it 'does not recognize a seeming callback url as an actual callback' do 124 | response = get '/profile?state=foo&code=bar' 125 | 126 | expect(a_request(:post, 'https://github.com/login/oauth/access_token')). 127 | to have_not_been_made 128 | end 129 | end 130 | 131 | context 'when already authenticated' do 132 | it 'does not perform the OAuth flow again' do 133 | stub_code_for_token_exchange 134 | stub_user_retrieval 135 | 136 | unauthenticated_response = get '/login' 137 | github_uri = redirect_uri(unauthenticated_response) 138 | state = github_uri.query_values['state'] 139 | 140 | callback_response = get "/login?code=#{code}&state=#{state}" 141 | authenticated_uri = redirect_uri(callback_response) 142 | get authenticated_uri.path 143 | logged_in_response = get '/login' 144 | 145 | expect(redirect_uri(logged_in_response).path).to eq '/' 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /spec/unit/config_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Warden::GitHub::Config do 4 | let(:warden_scope) { :test_scope } 5 | 6 | let(:env) do 7 | { 'warden' => double(config: warden_config) } 8 | end 9 | 10 | let(:warden_config) do 11 | { scope_defaults: { warden_scope => { config: scope_config } } } 12 | end 13 | 14 | let(:scope_config) do 15 | {} 16 | end 17 | 18 | let(:request) do 19 | double(url: 'http://example.com/the/path', path: '/the/path') 20 | end 21 | 22 | subject(:config) do 23 | described_class.new(env, warden_scope) 24 | end 25 | 26 | before do 27 | allow(config).to receive_messages(request: request) 28 | end 29 | 30 | def silence_warnings 31 | old_verbose, $VERBOSE = $VERBOSE, nil 32 | yield 33 | ensure 34 | $VERBOSE = old_verbose 35 | end 36 | 37 | describe '#client_id' do 38 | context 'when specified in scope config' do 39 | it 'returns the client id' do 40 | scope_config[:client_id] = 'foobar' 41 | expect(config.client_id).to eq 'foobar' 42 | end 43 | end 44 | 45 | context 'when specified in deprecated config' do 46 | it 'returns the client id' do 47 | warden_config[:github_client_id] = 'foobar' 48 | silence_warnings do 49 | expect(config.client_id).to eq 'foobar' 50 | end 51 | end 52 | end 53 | 54 | context 'when specified in ENV' do 55 | it 'returns the client id' do 56 | allow(ENV).to receive(:[]).with('GITHUB_CLIENT_ID').and_return('foobar') 57 | expect(config.client_id).to eq 'foobar' 58 | end 59 | end 60 | 61 | context 'when not specified' do 62 | it 'raises BadConfig' do 63 | expect { config.client_id }.to raise_error(described_class::BadConfig) 64 | end 65 | end 66 | end 67 | 68 | describe '#client_secret' do 69 | context 'when specified in scope config' do 70 | it 'returns the client secret' do 71 | scope_config[:client_secret] = 'foobar' 72 | expect(config.client_secret).to eq 'foobar' 73 | end 74 | end 75 | 76 | context 'when specified in deprecated config' do 77 | it 'returns the client secret' do 78 | warden_config[:github_secret] = 'foobar' 79 | silence_warnings do 80 | expect(config.client_secret).to eq 'foobar' 81 | end 82 | end 83 | end 84 | 85 | context 'when specified in ENV' do 86 | it 'returns the client secret' do 87 | allow(ENV).to receive(:[]).with('GITHUB_CLIENT_SECRET').and_return('foobar') 88 | silence_warnings do 89 | expect(config.client_secret).to eq 'foobar' 90 | end 91 | end 92 | end 93 | 94 | context 'when not specified' do 95 | it 'raises BadConfig' do 96 | expect { config.client_secret }.to raise_error(described_class::BadConfig) 97 | end 98 | end 99 | end 100 | 101 | describe '#redirect_uri' do 102 | context 'when specified in scope config' do 103 | it 'returns the expanded redirect uri' do 104 | scope_config[:redirect_uri] = '/callback' 105 | expect(config.redirect_uri).to eq 'http://example.com/callback' 106 | end 107 | end 108 | 109 | context 'when specified path lacks leading slash' do 110 | it 'corrects the path and returns the expanded uri' do 111 | scope_config[:redirect_uri] = 'callback' 112 | expect(config.redirect_uri).to eq 'http://example.com/callback' 113 | end 114 | end 115 | 116 | context 'when specified in deprecated config' do 117 | it 'returns the expanded redirect uri' do 118 | warden_config[:github_callback_url] = '/callback' 119 | silence_warnings do 120 | expect(config.redirect_uri).to eq 'http://example.com/callback' 121 | end 122 | end 123 | end 124 | 125 | context 'when not specified' do 126 | it 'returns the expanded redirect uri with the current path' do 127 | expect(config.redirect_uri).to eq 'http://example.com/the/path' 128 | end 129 | end 130 | 131 | context 'when HTTP_X_FORWARDED_PROTO is set to https' do 132 | it 'returns the expanded redirect uri(with port) with adjusted scheme' do 133 | env['HTTP_X_FORWARDED_PROTO'] = 'https' 134 | allow(request).to receive_messages(url: 'http://example.com:443/the/path') 135 | expect(config.redirect_uri).to eq 'https://example.com/the/path' 136 | end 137 | 138 | it 'returns the expanded redirect uri with adjusted scheme including port 80' do 139 | env['HTTP_X_FORWARDED_PROTO'] = 'https' 140 | allow(request).to receive_messages(url: 'http://example.com:80/the/path') 141 | expect(config.redirect_uri).to eq 'https://example.com/the/path' 142 | end 143 | 144 | it 'returns the expanded redirect uri with adjusted scheme including port 80 with multiple forwarded protocols' do 145 | env['HTTP_X_FORWARDED_PROTO'] = 'https,https' 146 | allow(request).to receive_messages(url: 'https://example.com:80/the/path') 147 | expect(config.redirect_uri).to eq 'https://example.com/the/path' 148 | end 149 | 150 | it 'returns the expanded redirect uri(without port) with adjusted scheme' do 151 | env['HTTP_X_FORWARDED_PROTO'] = 'https' 152 | allow(request).to receive_messages(url: 'http://example.com/the/path') 153 | expect(config.redirect_uri).to eq 'https://example.com/the/path' 154 | end 155 | end 156 | end 157 | 158 | describe '#scope' do 159 | context 'when specified in scope config' do 160 | it 'returns the client secret' do 161 | scope_config[:scope] = 'user' 162 | expect(config.scope).to eq 'user' 163 | end 164 | end 165 | 166 | context 'when specified in deprecated config' do 167 | it 'returns the client secret' do 168 | warden_config[:github_scopes] = 'user' 169 | silence_warnings do 170 | expect(config.scope).to eq 'user' 171 | end 172 | end 173 | end 174 | 175 | context 'when not specified' do 176 | it 'returns nil' do 177 | expect(config.scope).to be_nil 178 | end 179 | end 180 | end 181 | 182 | describe '#to_hash' do 183 | it 'includes all configs' do 184 | scope_config.merge!( 185 | scope: 'user', 186 | client_id: 'abc', 187 | client_secret: '123', 188 | redirect_uri: '/foo') 189 | 190 | expect(config.to_hash.keys). 191 | to match_array([:scope, :client_id, :client_secret, :redirect_uri]) 192 | end 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # warden-github 2 | 3 | A [warden](https://github.com/hassox/warden) strategy that provides OAuth authentication to GitHub. 4 | 5 | ## The Extension in Action 6 | 7 | To play with the extension, follow these steps: 8 | 9 | 1. Check out a copy of the source. 10 | 2. [Create an application on GitHub](https://github.com/settings/applications/new) and set the callback URL to `http://localhost:9292` 11 | 3. Run the following command with the client id and client secret obtained from the previous step: 12 | 13 | GITHUB_CLIENT_ID="" GITHUB_CLIENT_SECRET="" bundle exec rackup 14 | 15 | This will run the example app [example/simple_app.rb](example/simple_app.rb). 16 | 17 | If you wish to see multiple user scopes in action, run the above command with an additional variable: 18 | 19 | MULTI_SCOPE_APP=1 GITHUB_CLIENT_ID="" GITHUB_CLIENT_SECRET="" bundle exec rackup 20 | 21 | This will run the example app [example/multi_scope_app.rb](example/multi_scope_app.rb). 22 | 23 | 4. Point your browser at [http://localhost:9292/](http://localhost:9292) and enjoy! 24 | 25 | ## Configuration 26 | 27 | In order to use this strategy, simply tell warden about it. 28 | This is done by using `Warden::Manager` as a rack middleware and passing a config block to it. 29 | Read more about warden setup at the [warden wiki](https://github.com/hassox/warden/wiki/Setup). 30 | 31 | For simple usage without customization, simply specify it as the default strategy. 32 | 33 | ```ruby 34 | use Warden::Manager do |config| 35 | config.failure_app = BadAuthentication 36 | config.default_strategies :github 37 | end 38 | ``` 39 | 40 | In order to pass custom configurations, you need to configure a warden scope. 41 | Note that the default warden scope (i.e. when not specifying any explicit scope) is `:default`. 42 | 43 | Here's an example that specifies configs for the default scope and a custom admin scope. 44 | Using multiple scopes allows you to have different user types. 45 | 46 | ```ruby 47 | use Warden::Manager do |config| 48 | config.failure_app = BadAuthentication 49 | config.default_strategies :github 50 | 51 | config.scope_defaults :default, config: { scope: 'user:email' } 52 | config.scope_defaults :admin, config: { client_id: 'foobar', 53 | client_secret: 'barfoo', 54 | scope: 'user,repo', 55 | redirect_uri: '/admin/oauth/callback' } 56 | 57 | config.serialize_from_session { |key| Warden::GitHub::Verifier.load(key) } 58 | config.serialize_into_session { |user| Warden::GitHub::Verifier.dump(user) } 59 | end 60 | ``` 61 | 62 | The two serialization methods store the API token in the session securely via the `WARDEN_GITHUB_VERIFIER_SECRET` environmental variable. 63 | 64 | ### Parameters 65 | 66 | The config parameters and their defaults are listed below. 67 | Please refer to the [GitHub OAuth documentation](http://developer.github.com/v3/oauth/) for an explanation of their meaning. 68 | 69 | - **client_id:** Defaults to `ENV['GITHUB_CLIENT_ID']` and raises if not present. 70 | - **client_secret:** Defaults to `ENV['GITHUB_CLIENT_SECRET']` and raises if not present. 71 | - **scope:** Defaults to `nil`. 72 | - **redirect_uri:** Defaults to the current path. 73 | Note that paths will be expanded to a valid URL using the request url's host. 74 | 75 | ### Using with GitHub Enterprise 76 | 77 | GitHub API communication is done entirely through the [octokit gem](https://github.com/pengwynn/octokit). 78 | For the OAuth process (which uses another endpoint than the API), the web endpoint is read from octokit. 79 | In order to configure octokit for GitHub Enterprise you can either define the two environment variables `OCTOKIT_API_ENDPOINT` and `OCTOKIT_WEB_ENDPOINT`, or configure the `Octokit` module as specified in their [README](https://github.com/pengwynn/octokit#using-with-github-enterprise). 80 | 81 | ### JSON Dependency 82 | 83 | This gem and its dependencies do not explicitly depend on any JSON library. 84 | If you're on ruby 1.8.7 you'll have to include one explicitly. 85 | ruby 1.9 comes with a json library that will be used if no other is specified. 86 | 87 | ## Usage 88 | 89 | Some warden methods that you will need: 90 | 91 | ```ruby 92 | env['warden'].authenticate! # => Uses the configs from the default scope. 93 | env['warden'].authenticate!(:scope => :admin) # => Uses the configs from the admin scope. 94 | 95 | # Analogous to previous lines, but does not halt if authentication does not succeed. 96 | env['warden'].authenticate 97 | env['warden'].authenticate(:scope => :admin) 98 | 99 | env['warden'].authenticated? # => Checks whether the default scope is logged in. 100 | env['warden'].authenticated?(:admin) # => Checks whether the admin scope is logged in. 101 | 102 | env['warden'].user # => The user for the default scope. 103 | env['warden'].user(:admin) # => The user for the admin scope. 104 | 105 | env['warden'].session # => Namespaced session accessor for the default scope. 106 | env['warden'].session(:admin) # => Namespaced session accessor for the admin scope. 107 | 108 | env['warden'].logout # => Logs out all scopes. 109 | env['warden'].logout(:default) # => Logs out the default scope. 110 | env['warden'].logout(:admin) # => Logs out the admin scope. 111 | ``` 112 | 113 | For further documentation, refer to the [warden wiki](https://github.com/hassox/warden/wiki). 114 | 115 | The user object (`Warden::GitHub::User`) responds to the following methods: 116 | 117 | ```ruby 118 | user = env['warden'].user 119 | 120 | user.id # => The GitHub user id. 121 | user.login # => The GitHub username. 122 | user.name 123 | user.gravatar_id # => The md5 email hash to construct a gravatar image. 124 | user.avatar_url 125 | user.email # => Requires user:email or user scope. 126 | user.company 127 | 128 | # These require user scope. 129 | user.organization_member?('rails') # => Checks 'rails' organization membership. 130 | user.organization_public_member?('github') # => Checks publicly disclosed 'github' organization membership. 131 | user.team_member?(1234) # => Checks membership in team with id 1234. 132 | 133 | # API access 134 | user.api # => Authenticated Octokit::Client for the user. 135 | ``` 136 | 137 | For more information on API access, refer to the [octokit documentation](http://rdoc.info/gems/octokit). 138 | 139 | ## Framework Adapters 140 | 141 | If you're looking for an easy way to integrate this into a Sinatra or Rails application, take a look at the following gems: 142 | 143 | - [sinatra_auth_github](https://github.com/atmos/sinatra_auth_github) 144 | - [warden-github-rails](https://github.com/fphilipe/warden-github-rails) 145 | 146 | ## Single Sign Out 147 | 148 | OAuth applications owned by the GitHub organization are sent an extra browser parameter to ensure that the user remains logged in to github.com. Taking advantage of this is provided by a small module you include into your controller and a before filter. Your `ApplicationController` should resemble something like this. 149 | 150 | 151 | ```ruby 152 | class ApplicationController < ActionController::Base 153 | include Warden::GitHub::SSO 154 | 155 | protect_from_forgery with: :exception 156 | 157 | before_filter :verify_logged_in_user 158 | 159 | private 160 | 161 | def verify_logged_in_user 162 | unless github_user && warden_github_sso_session_valid?(github_user, 120) 163 | request.env['warden'].logout 164 | request.env['warden'].authenticate! 165 | end 166 | end 167 | end 168 | ``` 169 | 170 | You can also see single sign out in action in the example app. 171 | 172 | ## Additional Information 173 | 174 | - [warden](https://github.com/hassox/warden) 175 | - [octokit](https://github.com/pengwynn/octokit) 176 | - [GitHub OAuth Busy Developer's Guide](https://gist.github.com/technoweenie/419219) 177 | - [GitHub API documentation](http://developer.github.com) 178 | - [List of GitHub OAuth scopes](http://developer.github.com/v3/oauth/#scopes) 179 | - [Register a new OAuth application on GitHub](https://github.com/settings/applications/new) 180 | 181 | --------------------------------------------------------------------------------