├── .gitignore ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── config.ru ├── lib ├── sinatra │ └── auth │ │ ├── github.rb │ │ ├── github │ │ ├── test │ │ │ └── test_helper.rb │ │ └── version.rb │ │ └── views │ │ ├── 401.html │ │ └── securocat.png └── sinatra_auth_github.rb ├── sinatra_auth_github.gemspec └── spec ├── app.rb ├── login_spec.rb ├── quality_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | .bundle 3 | pkg 4 | .DS_Store 5 | Gemfile.lock 6 | *.gem 7 | vendor 8 | .ruby-version 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | v1.0.0 2013/09/03 2 | ----------------- 3 | 4 | * Explicitly require Octokit 2.1.1 5 | * Fixups for moving to octokit 2.1.1 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in sinatra_auth_github.gemspec 4 | gemspec 5 | 6 | # vim:ft=ruby 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2012 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | sinatra_auth_github 2 | =================== 3 | 4 | A sinatra extension that provides oauth authentication to github. Find out more about enabling your application at github's [oauth quickstart](http://developer.github.com/v3/oauth/). 5 | 6 | To test it out on localhost set your callback url to 'http://localhost:9393/auth/github/callback' 7 | 8 | The gist of this project is to provide a few things easily: 9 | 10 | * authenticate a user against github's oauth service 11 | * provide an easy way to make API requests for the authenticated user 12 | * optionally restrict users to a specific github organization 13 | * optionally restrict users to a specific github team 14 | 15 | Installation 16 | ============ 17 | 18 | % gem install sinatra_auth_github 19 | 20 | Running the Example 21 | =================== 22 | % gem install bundler 23 | % bundle install 24 | % GITHUB_CLIENT_ID="" GITHUB_CLIENT_SECRET="" bundle exec rackup -p9393 25 | 26 | There's an example app in [spec/app.rb](/spec/app.rb). 27 | 28 | Example App Functionality 29 | ========================= 30 | 31 | You can simply authenticate via GitHub by hitting http://localhost:9393 32 | 33 | You can check organization membership by hitting http://localhost:9393/orgs/github 34 | 35 | You can check team membership by hitting http://localhost:9393/teams/42 36 | 37 | All unsuccessful authentication requests get sent to the securocat denied page. 38 | 39 | API Access 40 | ============ 41 | 42 | The extension also provides a simple way to access the GitHub API, by providing an 43 | authenticated Octokit::Client for the user. 44 | 45 | def repos 46 | github_user.api.repositories 47 | end 48 | 49 | For more information on API access, refer to the [octokit documentation](http://rdoc.info/gems/octokit). 50 | 51 | Extension Options 52 | ================= 53 | 54 | * `:scopes` - The OAuth2 scopes you require, [Learn More](http://gist.github.com/419219) 55 | * `:secret` - The client secret that GitHub provides 56 | * `:client_id` - The client id that GitHub provides 57 | * `:failure_app` - A Sinatra::Base class that has a route for `/unauthenticated`, Useful for overriding the securocat default page. 58 | * `:callback_url` - The path that GitHub posts back to, defaults to `/auth/github/callback`. 59 | 60 | Enterprise Authentication 61 | ========================= 62 | 63 | Under the hood, the `warden-github` portion is powered by octokit. If you find yourself wanting to connect to a GitHub Enterprise installation you'll need to export two environmental variables. 64 | 65 | * OCTOKIT_WEB_ENDPOINT - The web endpoint for OAuth, defaults to https://github.com 66 | * OCTOKIT_API_ENDPOINT - The API endpoint for authenticated requests, defaults to https://api.github.com 67 | -------------------------------------------------------------------------------- /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 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | $LOAD_PATH << File.dirname(__FILE__) + '/lib' 6 | require File.expand_path(File.join(File.dirname(__FILE__), 'lib', 'sinatra_auth_github')) 7 | require File.expand_path(File.join(File.dirname(__FILE__), 'spec', 'app')) 8 | 9 | use Rack::Static, :urls => ["/css", "/img", "/js"], :root => "public" 10 | 11 | run Example::App 12 | 13 | # vim:ft=ruby 14 | -------------------------------------------------------------------------------- /lib/sinatra/auth/github.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | require 'warden/github' 3 | 4 | module Sinatra 5 | module Auth 6 | module Github 7 | # Simple way to serve an image early in the stack and not get blocked by 8 | # application level before filters 9 | class AccessDenied < Sinatra::Base 10 | enable :raise_errors 11 | disable :show_exceptions 12 | 13 | get '/_images/securocat.png' do 14 | send_file(File.join(File.dirname(__FILE__), "views", "securocat.png")) 15 | end 16 | end 17 | 18 | # The default failure application, this is overridable from the extension config 19 | class BadAuthentication < Sinatra::Base 20 | enable :raise_errors 21 | disable :show_exceptions 22 | 23 | helpers do 24 | def unauthorized_template 25 | @unauthenticated_template ||= File.read(File.join(File.dirname(__FILE__), "views", "401.html")) 26 | end 27 | end 28 | 29 | get '/unauthenticated' do 30 | status 403 31 | unauthorized_template 32 | end 33 | end 34 | 35 | module Helpers 36 | def warden 37 | env['warden'] 38 | end 39 | 40 | def authenticate!(*args) 41 | warden.authenticate!(*args) 42 | end 43 | 44 | def authenticated?(*args) 45 | warden.authenticated?(*args) 46 | end 47 | 48 | def logout! 49 | warden.logout 50 | end 51 | 52 | # The authenticated user object 53 | # 54 | # Supports a variety of methods, name, full_name, email, etc 55 | def github_user 56 | warden.user 57 | end 58 | 59 | # Send a V3 API GET request to path 60 | # 61 | # path - the path on api.github.com to hit 62 | # 63 | # Returns a rest client response object 64 | # 65 | # Examples 66 | # github_raw_request("/user") 67 | # # => RestClient::Response 68 | def github_raw_request(path) 69 | github_user.github_raw_request(path) 70 | end 71 | 72 | # Send a V3 API GET request to path and parse the response body 73 | # 74 | # path - the path on api.github.com to hit 75 | # 76 | # Returns a parsed JSON response 77 | # 78 | # Examples 79 | # github_request("/user") 80 | # # => { 'login' => 'atmos', ... } 81 | def github_request(path) 82 | github_user.github_request(path) 83 | end 84 | 85 | # See if the user is a public member of the named organization 86 | # 87 | # name - the organization name 88 | # 89 | # Returns: true if the user is public access, false otherwise 90 | def github_public_organization_access?(name) 91 | github_user.publicized_organization_member?(name) 92 | end 93 | 94 | # See if the user is a member of the named organization 95 | # 96 | # name - the organization name 97 | # 98 | # Returns: true if the user has access, false otherwise 99 | def github_organization_access?(name) 100 | github_user.organization_member?(name) 101 | end 102 | 103 | # See if the user is a member of the team id 104 | # 105 | # team_id - the team's id 106 | # 107 | # Returns: true if the user has access, false otherwise 108 | def github_team_access?(team_id) 109 | github_user.team_member?(team_id) 110 | end 111 | 112 | # Enforce publicized user membership to the named organization 113 | # 114 | # name - the organization to test membership against 115 | # 116 | # Returns an execution halt if the user is not a publicized member of the named org 117 | def github_public_organization_authenticate!(name) 118 | authenticate! 119 | halt([401, "Unauthorized User"]) unless github_public_organization_access?(name) 120 | end 121 | 122 | # Enforce user membership to the named organization 123 | # 124 | # name - the organization to test membership against 125 | # 126 | # Returns an execution halt if the user is not a member of the named org 127 | def github_organization_authenticate!(name) 128 | authenticate! 129 | halt([401, "Unauthorized User"]) unless github_organization_access?(name) 130 | end 131 | 132 | # Enforce user membership to the team id 133 | # 134 | # team_id - the team_id to test membership against 135 | # 136 | # Returns an execution halt if the user is not a member of the team 137 | def github_team_authenticate!(team_id) 138 | authenticate! 139 | halt([401, "Unauthorized User"]) unless github_team_access?(team_id) 140 | end 141 | 142 | def _relative_url_for(path) 143 | request.script_name + path 144 | end 145 | end 146 | 147 | def self.registered(app) 148 | app.use AccessDenied 149 | app.use BadAuthentication 150 | 151 | app.use Warden::Manager do |manager| 152 | manager.default_strategies :github 153 | 154 | manager.failure_app = app.github_options[:failure_app] || BadAuthentication 155 | 156 | manager.scope_defaults :default, :config => { 157 | :client_id => app.github_options[:client_id] || ENV['GITHUB_CLIENT_ID'], 158 | :client_secret => app.github_options[:secret] || ENV['GITHUB_CLIENT_SECRET'], 159 | :scope => app.github_options[:scopes] || '', 160 | :redirect_uri => app.github_options[:callback_url] || '/auth/github/callback' 161 | } 162 | 163 | manager.serialize_from_session { |key| Warden::GitHub::Verifier.load(key) } 164 | manager.serialize_into_session { |user| Warden::GitHub::Verifier.dump(user) } 165 | end 166 | 167 | 168 | # Sign cookie sessions in with AS::Verifier 169 | ENV['WARDEN_GITHUB_VERIFIER_SECRET'] ||= ENV['GITHUB_VERIFIER_SECRET'] 170 | 171 | unless ENV['WARDEN_GITHUB_VERIFIER_SECRET'] 172 | warn "No WARDEN_GITHUB_VERIFIER_SECRET environmental variable found." 173 | warn "Your sessions are likely being stored insecurely." 174 | end 175 | 176 | app.helpers Helpers 177 | 178 | app.get '/auth/github/callback' do 179 | if params["error"] 180 | redirect "/unauthenticated" 181 | else 182 | authenticate! 183 | return_to = session.delete('return_to') || _relative_url_for('/') 184 | redirect return_to 185 | end 186 | end 187 | end 188 | end 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /lib/sinatra/auth/github/test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'warden/test/helpers' 2 | require 'warden/github/user' 3 | 4 | module Sinatra 5 | module Auth 6 | module Github 7 | module Test 8 | module Helper 9 | include(Warden::Test::Helpers) 10 | def make_user(attrs = {}) 11 | User.make(attrs) 12 | end 13 | 14 | class User < Warden::GitHub::User 15 | def self.make(attrs = {}) 16 | default_attrs = { 17 | 'login' => "test_user", 18 | 'name' => "Test User", 19 | 'email' => "test@example.com", 20 | 'company' => "GitHub", 21 | 'gravatar_id' => 'a'*32, 22 | 'avatar_url' => 'https://a249.e.akamai.net/assets.github.com/images/gravatars/gravatar-140.png?d=https://a248.e.akamai.net/assets.github.com%2Fimages%2Fgravatars%2Fgravatar-user-420.png' 23 | } 24 | default_attrs.merge! attrs 25 | User.new(default_attrs) 26 | end 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/sinatra/auth/github/version.rb: -------------------------------------------------------------------------------- 1 | module Sinatra 2 | module Auth 3 | module Github 4 | VERSION = '2.0.0' 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/sinatra/auth/views/401.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Denied 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /lib/sinatra/auth/views/securocat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atmos/sinatra_auth_github/6eb5969c35dc5933354c93663f153a6dfbd8cda8/lib/sinatra/auth/views/securocat.png -------------------------------------------------------------------------------- /lib/sinatra_auth_github.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra/auth/github' 2 | -------------------------------------------------------------------------------- /sinatra_auth_github.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "sinatra/auth/github/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "sinatra_auth_github" 7 | s.version = Sinatra::Auth::Github::VERSION 8 | s.platform = Gem::Platform::RUBY 9 | s.authors = ["Corey Donohoe"] 10 | s.email = ["atmos@atmos.org"] 11 | s.homepage = "http://github.com/atmos/sinatra_auth_github" 12 | s.summary = "A sinatra extension for easy oauth integration with github" 13 | s.license = "MIT" 14 | s.description = s.summary 15 | 16 | s.rubyforge_project = "sinatra_auth_github" 17 | 18 | s.add_dependency "sinatra", "~>2.0" 19 | s.add_dependency "warden-github", "~>1.3" 20 | 21 | s.add_development_dependency "rake" 22 | s.add_development_dependency "rspec" 23 | s.add_development_dependency "shotgun" 24 | s.add_development_dependency "randexp" 25 | s.add_development_dependency "rack-test" 26 | 27 | s.files = `git ls-files`.split("\n") 28 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 29 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 30 | s.require_paths = ["lib"] 31 | end 32 | -------------------------------------------------------------------------------- /spec/app.rb: -------------------------------------------------------------------------------- 1 | require 'pp' 2 | require 'json' 3 | 4 | module Example 5 | class App < Sinatra::Base 6 | enable :sessions 7 | 8 | set :github_options, { 9 | :scopes => "user", 10 | :secret => ENV['GITHUB_CLIENT_SECRET'], 11 | :client_id => ENV['GITHUB_CLIENT_ID'], 12 | } 13 | 14 | register Sinatra::Auth::Github 15 | 16 | helpers do 17 | def repos 18 | github_request("user/repos") 19 | end 20 | end 21 | 22 | get '/' do 23 | authenticate! 24 | "Hello there, #{github_user.login}!" 25 | end 26 | 27 | get '/orgs/:id' do 28 | github_organization_authenticate!(params['id']) 29 | "Hello There, #{github_user.name}! You have access to the #{params['id']} organization." 30 | end 31 | 32 | get '/publicized_orgs/:id' do 33 | github_publicized_organization_authenticate!(params['id']) 34 | "Hello There, #{github_user.name}! You are publicly a member of the #{params['id']} organization." 35 | end 36 | 37 | get '/teams/:id' do 38 | github_team_authenticate!(params['id']) 39 | "Hello There, #{github_user.name}! You have access to the #{params['id']} team." 40 | end 41 | 42 | get '/logout' do 43 | logout! 44 | redirect 'https://github.com' 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/login_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "Logged in users" do 4 | before do 5 | @user = make_user('login' => 'defunkt') 6 | login_as @user 7 | end 8 | 9 | it "greets the user" do 10 | get "/" 11 | last_response.body.should eql("Hello there, defunkt!") 12 | end 13 | 14 | it "logs the user out" do 15 | get "/" 16 | 17 | get "/logout" 18 | last_response.status.should eql(302) 19 | last_response.headers['Location'].should eql("https://github.com") 20 | 21 | get "/" 22 | last_response.status.should eql(302) 23 | last_response.headers['Location'].should =~ %r{^https://github\.com/login/oauth/authorize} 24 | end 25 | 26 | it "shows the securocat when github returns an oauth error" do 27 | get "/auth/github/callback?error=redirect_uri_mismatch" 28 | follow_redirect! 29 | last_response.body.should =~ %r{securocat\.png} 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/quality_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/spec_helper' 2 | 3 | describe "The library itself" do 4 | RSpec::Matchers.define :have_no_tab_characters do 5 | match do |filename| 6 | @failing_lines = [] 7 | File.readlines(filename).each_with_index do |line,number| 8 | @failing_lines << number + 1 if line =~ /\t/ 9 | end 10 | @failing_lines.empty? 11 | end 12 | 13 | failure_message_for_should do |filename| 14 | "The file #{filename} has tab characters on lines #{@failing_lines.join(', ')}" 15 | end 16 | end 17 | 18 | RSpec::Matchers.define :have_no_extraneous_spaces do 19 | match do |filename| 20 | @failing_lines = [] 21 | File.readlines(filename).each_with_index do |line,number| 22 | next if line =~ /^\s+#.*\s+\n$/ 23 | @failing_lines << number + 1 if line =~ /\s+\n$/ 24 | end 25 | @failing_lines.empty? 26 | end 27 | 28 | failure_message_for_should do |filename| 29 | "The file #{filename} has spaces on the EOL on lines #{@failing_lines.join(', ')}" 30 | end 31 | end 32 | 33 | it "has no tab characters" do 34 | Dir.chdir(File.dirname(__FILE__) + '/..') do 35 | Dir.glob("./lib/**/*.rb").each do |filename| 36 | filename.should have_no_tab_characters 37 | filename.should have_no_extraneous_spaces 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | $:.push File.join(File.dirname(__FILE__), '..', 'lib') 4 | 5 | require 'pp' 6 | require 'rack/test' 7 | 8 | require 'sinatra/auth/github' 9 | require 'sinatra/auth/github/test/test_helper' 10 | 11 | require 'app' 12 | 13 | RSpec.configure do |config| 14 | config.include(Rack::Test::Methods) 15 | config.include(Sinatra::Auth::Github::Test::Helper) 16 | 17 | def app 18 | Example::App 19 | end 20 | end 21 | --------------------------------------------------------------------------------