├── .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 |
65 | - Home
66 | - View profile<% if !env['warden'].authenticated? %> (implicit sign in)<% end %>
67 | <% if env['warden'].authenticated? %>
68 | - Sign out
69 | <% else %>
70 | - Sign in (explicit sign in)
71 | <% end %>
72 |
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 |
--------------------------------------------------------------------------------