diff --git a/Gemfile b/Gemfile index e876375c..fab74ec3 100644 --- a/Gemfile +++ b/Gemfile @@ -11,6 +11,7 @@ gem 'yard-sinatra', github: 'rkh/yard-sinatra' group :test do gem 'rspec', '~> 2.11' gem 'factory_girl', '~> 2.4.0' + gem 'mocha', '~> 0.12' end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index b1fee1a4..9dafe817 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -20,7 +20,7 @@ GIT GIT remote: git://github.com/travis-ci/travis-core.git - revision: 0382e80787319cc496400a2e7cb85feb63e278ec + revision: b2f6905573a3b947bc6d3df6802bb03f5dc13bae specs: travis-core (0.0.1) actionmailer (~> 3.2.3) @@ -129,11 +129,14 @@ GEM i18n (>= 0.4.0) mime-types (~> 1.16) treetop (~> 1.4.8) + metaclass (0.0.1) metriks (0.9.9.1) atomic (~> 1.0) avl_tree (~> 1.1.2) hitimes (~> 1.1) mime-types (1.19) + mocha (0.12.3) + metaclass (~> 0.0.1) multi_json (1.3.6) multipart-post (1.1.5) net-http-persistent (2.7) @@ -227,6 +230,7 @@ DEPENDENCIES foreman hubble! micro_migrations! + mocha (~> 0.12) rake (~> 0.9.2) rerun rspec (~> 2.11) diff --git a/lib/travis/api/app.rb b/lib/travis/api/app.rb index f6820062..b89a1686 100644 --- a/lib/travis/api/app.rb +++ b/lib/travis/api/app.rb @@ -7,6 +7,7 @@ require 'backports' require 'rack' require 'rack/protection' require 'active_record' +require 'redis' # Rack class implementing the HTTP API. # Instances respond to #call. @@ -15,7 +16,7 @@ require 'active_record' # # Requires TLS in production. class Travis::Api::App - autoload :AccessToken, 'travis/api/api/access_token' + autoload :AccessToken, 'travis/api/app/access_token' autoload :Responder, 'travis/api/app/responder' autoload :Endpoint, 'travis/api/app/endpoint' autoload :Extensions, 'travis/api/app/extensions' @@ -57,7 +58,7 @@ class Travis::Api::App Middleware.subclasses.each { |m| use(m) } endpoints = Endpoint.subclasses endpoints -= [Endpoint::Home] if options[:disable_root_endpoint] - endpoints.each { |e| map(e.prefix) { run(e) } } + endpoints.each { |e| map(e.prefix) { run(e.new) } } end end diff --git a/lib/travis/api/app/access_token.rb b/lib/travis/api/app/access_token.rb index c69caaeb..f4944cf9 100644 --- a/lib/travis/api/app/access_token.rb +++ b/lib/travis/api/app/access_token.rb @@ -1,9 +1,9 @@ require 'travis/api/app' require 'securerandom' -require 'redis' class Travis::Api::App class AccessToken + DEFAULT_SCOPES = [:public, :private] attr_reader :token, :scopes, :user_id def self.create(options = {}) @@ -16,21 +16,30 @@ class Travis::Api::App end def initialize(options = {}) - raise ArgumentError, 'must supply either user_id or user' unless options[:user] ^ options[:user_id] + raise ArgumentError, 'must supply either user_id or user' unless options.key?(:user) ^ options.key?(:user_id) + @token = options[:token] || SecureRandom.urlsafe_base64(64) - @scopes = Array(options[:scopes] || options[:scope]) + @scopes = Array(options[:scopes] || options[:scope] || DEFAULT_SCOPES).map(&:to_sym) @user = options[:user] - @user_id = options[:user_id] || @user.id + @user_id = Integer(options[:user_id] || @user.id) end def save key = key(token) redis.del(key) - redis.rpush(key, [user_id, nil, *scopes].map(&)) + redis.rpush(key, [user_id, '', *scopes].map(&:to_s)) end def user - @user ||= User.find(user_id) + @user ||= User.find(user_id) if user_id + end + + def user? + !!user + end + + def to_s + token end module Helpers diff --git a/lib/travis/api/app/endpoint.rb b/lib/travis/api/app/endpoint.rb index 7ab1f090..44011042 100644 --- a/lib/travis/api/app/endpoint.rb +++ b/lib/travis/api/app/endpoint.rb @@ -4,13 +4,10 @@ class Travis::Api::App # Superclass for HTTP endpoints. Takes care of prefixing. class Endpoint < Responder set(:prefix) { "/" << name[/[^:]+$/].underscore } - before { content_type :json } + register :scoping + before { content_type :json } error(ActiveRecord::RecordNotFound, Sinatra::NotFound) { not_found } not_found { content_type =~ /json/ ? { 'file' => 'not found' } : 'file not found' } - - # TODO: Dummy method. - def self.scope(name) - end end end diff --git a/lib/travis/api/app/endpoint/authorization.rb b/lib/travis/api/app/endpoint/authorization.rb index a6b9cdd9..6d90e9be 100644 --- a/lib/travis/api/app/endpoint/authorization.rb +++ b/lib/travis/api/app/endpoint/authorization.rb @@ -31,7 +31,7 @@ class Travis::Api::App # This is the recommended way for the official client. We might improve the # authorization flow to support third-party clients in the future, too. class Authorization < Endpoint - set prefix: '/auth', default_scope: :private + set prefix: '/auth' # Parameters: # diff --git a/lib/travis/api/app/endpoint/endpoints.rb b/lib/travis/api/app/endpoint/endpoints.rb index 588c31fc..5416df99 100644 --- a/lib/travis/api/app/endpoint/endpoints.rb +++ b/lib/travis/api/app/endpoint/endpoints.rb @@ -19,7 +19,7 @@ class Travis::Api::App 'uri' => (controller.prefix + route.http_path[1..-2]).gsub('//', '/'), 'verb' => route.http_verb, 'doc' => route.docstring, - 'scope' => /scope\W+(\w+)/.match(route.source).try(:[], 1) + 'scope' => /scope\W+(\w+)/.match(route.source).try(:[], 1) || controller.default_scope.to_s } endpoint = endpoints[controller.prefix] ||= { 'name' => namespace.name, diff --git a/lib/travis/api/app/extensions/scoping.rb b/lib/travis/api/app/extensions/scoping.rb new file mode 100644 index 00000000..255ab079 --- /dev/null +++ b/lib/travis/api/app/extensions/scoping.rb @@ -0,0 +1,44 @@ +require 'travis/api/app' + +class Travis::Api::App + module Extensions + module Scoping + module Helpers + def access_token + env['travis.access_token'] + end + + def user + access_token.user if logged_in? + end + + def logged_in? + !!access_token + end + + def scopes + logged_in? ? access_token.scopes : settings.anonymous_scopes + end + end + + def self.registered(app) + app.set default_scope: :public, anonymous_scopes: [:public] + app.helpers(Helpers) + end + + def scope(name) + condition do + name = settings.default_scope if name == :default + headers['X-OAuth-Scopes'] = scopes.map(&:to_s).join(',') + headers['X-Accepted-OAuth-Scopes'] = name.to_s + scopes.include? name + end + end + + def route(verb, path, options = {}, &block) + options[:scope] ||= :default + super(verb, path, options, &block) + end + end + end +end diff --git a/lib/travis/api/app/middleware/access_token.rb b/lib/travis/api/app/middleware/access_token.rb deleted file mode 100644 index 30db73bd..00000000 --- a/lib/travis/api/app/middleware/access_token.rb +++ /dev/null @@ -1,9 +0,0 @@ -require 'travis/api/app' - -class Travis::Api::App - class Middleware - # Checks access tokens and sets appropriate scopes. - class AccessToken < Middleware - end - end -end diff --git a/lib/travis/api/app/middleware/scope_check.rb b/lib/travis/api/app/middleware/scope_check.rb new file mode 100644 index 00000000..94b2f13a --- /dev/null +++ b/lib/travis/api/app/middleware/scope_check.rb @@ -0,0 +1,42 @@ +require 'travis/api/app' + +class Travis::Api::App + class Middleware + # Checks access tokens and sets appropriate scopes. + class ScopeCheck < Middleware + before do + next unless token + access_token = AccessToken.find_by_token(token) + halt 403, 'access denied' unless access_token + env['travis.access_token'] = access_token + end + + after do + headers['X-OAuth-Scopes'] ||= begin + scopes = Array(env['travis.access_token'].try(:scopes)) + scopes.map(&:to_s).join(',') + end + end + + def token + @token ||= header_token || query_token + end + + private + + def query_token + params[:access_token] if params[:access_token] and not params[:access_token].empty? + end + + def header_token + type, payload = env['HTTP_AUTHORIZATION'].to_s.split(" ", 2) + return if payload.nil? or payload.empty? + + case type.downcase + when 'basic' then payload.unpack("m").first.split(':', 2).first + when 'token' then payload.gsub(/^"(.+)"$/, '\1') + end + end + end + end +end diff --git a/spec/endpoint/authorization_spec.rb b/spec/endpoint/authorization_spec.rb new file mode 100644 index 00000000..79bdc1b7 --- /dev/null +++ b/spec/endpoint/authorization_spec.rb @@ -0,0 +1,62 @@ +require 'spec_helper' + +describe Travis::Api::App::Endpoint::Authorization do + include Travis::Testing::Stubs + + before do + add_endpoint '/info' do + get '/login', scope: :private do + env['travis.access_token'].user.login + end + end + + User.stubs(:find_by_login).with(user.login).returns(user) + User.stubs(:find).with(user.id).returns(user) + end + + describe 'GET /auth/authorize' do + pending "not yet implemented" + end + + describe 'POST /auth/access_token' do + pending "not yet implemented" + end + + describe 'POST /auth/github' do + before do + GH.stubs(:with).with(token: 'private repos').returns stub(:[] => user.login, :headers => {'x-oauth-scopes' => 'repo'}) + GH.stubs(:with).with(token: 'public repos').returns stub(:[] => user.login, :headers => {'x-oauth-scopes' => 'public_repo'}) + GH.stubs(:with).with(token: 'no repos').returns stub(:[] => user.login, :headers => {'x-oauth-scopes' => 'user'}) + GH.stubs(:with).with(token: 'invalid token').raises(Faraday::Error::ClientError, 'CLIENT ERROR!') + end + + def get_token(github_token) + post('/auth/github', token: github_token).should be_ok + parsed_body['access_token'] + end + + def user_for(github_token) + get '/info/login', access_token: get_token(github_token) + last_response.status.should == 200 + User.find_by_login(body) + end + + it 'accepts tokens with repo scope' do + user_for('private repos').should == user + end + + it 'accepts tokens with public_repo scope' do + user_for('public repos').should == user + end + + it 'rejects tokens with user scope' do + post('/auth/github', token: 'no repos').should_not be_ok + body.should_not include('access_token') + end + + it 'rejects tokens with user scope' do + post('/auth/github', token: 'invalid token').should_not be_ok + body.should_not include('access_token') + end + end +end diff --git a/spec/extensions/scoping_spec.rb b/spec/extensions/scoping_spec.rb new file mode 100644 index 00000000..5b037f58 --- /dev/null +++ b/spec/extensions/scoping_spec.rb @@ -0,0 +1,65 @@ +require 'spec_helper' + +describe Travis::Api::App::Extensions::Scoping do + include Travis::Testing::Stubs + + before do + mock_app do + register Travis::Api::App::Extensions::Scoping + get('/') { 'ok' } + get('/private', scope: :private) { 'ok' } + end + + User.stubs(:find).with(user.id).returns(user) + end + + def with_scopes(url, *scopes) + token = Travis::Api::App::AccessToken.create(user: user, scopes: scopes) + get(url, {}, 'travis.access_token' => token) + end + + it 'uses the default scope if no token is given' do + get('/').should be_ok + headers['X-Accepted-OAuth-Scopes'].should == 'public' + headers['X-OAuth-Scopes'].should == 'public' + end + + it 'allows overriding scopes for anonymous users' do + settings.set anonymous_scopes: [:foo] + get('/').should_not be_ok + headers['X-Accepted-OAuth-Scopes'].should == 'public' + headers['X-OAuth-Scopes'].should == 'foo' + end + + it 'allows overriding default scope' do + settings.set default_scope: :foo + get('/').should_not be_ok + headers['X-Accepted-OAuth-Scopes'].should == 'foo' + headers['X-OAuth-Scopes'].should == 'public' + end + + it 'allows overriding default scope and anonymous scope' do + settings.set default_scope: :foo, anonymous_scopes: [:foo, :bar] + get('/').should be_ok + headers['X-Accepted-OAuth-Scopes'].should == 'foo' + headers['X-OAuth-Scopes'].should == 'foo,bar' + end + + it 'takes the scope from the access token' do + with_scopes('/', :foo).should_not be_ok + headers['X-Accepted-OAuth-Scopes'].should == 'public' + headers['X-OAuth-Scopes'].should == 'foo' + end + + it 'accepts the scope from the condition' do + with_scopes('/private', :foo, :bar, :private).should be_ok + headers['X-Accepted-OAuth-Scopes'].should == 'private' + headers['X-OAuth-Scopes'].should == 'foo,bar,private' + end + + it 'rejects if scope from condition is missing' do + with_scopes('/private', :foo, :bar).should_not be_ok + headers['X-Accepted-OAuth-Scopes'].should == 'private' + headers['X-OAuth-Scopes'].should == 'foo,bar' + end +end diff --git a/spec/middleware/access_token_spec.rb b/spec/middleware/access_token_spec.rb deleted file mode 100644 index 355e37b6..00000000 --- a/spec/middleware/access_token_spec.rb +++ /dev/null @@ -1,16 +0,0 @@ -require 'spec_helper' - -describe Travis::Api::App::Middleware::AccessToken do - before do - mock_app do - use Travis::Api::App::Middleware::AccessToken - get('/check_cors') { 'ok' } - end - end - - it 'sets associated scope properly' - it 'lets through requests without a token' - it 'reject requests with an invalide token' - it 'rejects expired tokens' - it 'checks that the token corresponds to Origin' -end \ No newline at end of file diff --git a/spec/middleware/scope_check_spec.rb b/spec/middleware/scope_check_spec.rb new file mode 100644 index 00000000..3e6649aa --- /dev/null +++ b/spec/middleware/scope_check_spec.rb @@ -0,0 +1,63 @@ +require 'spec_helper' + +describe Travis::Api::App::Middleware::ScopeCheck do + include Travis::Testing::Stubs + + let :access_token do + Travis::Api::App::AccessToken.create(user: user, scope: :foo) + end + + before do + mock_app do + use Travis::Api::App::Middleware::ScopeCheck + get('/') { 'ok' } + get('/token') { env['travis.access_token'].to_s } + end + + User.stubs(:find).with(user.id).returns(user) + end + + it 'lets through requests without a token' do + get('/').should be_ok + body.should == 'ok' + headers['X-OAuth-Scopes'].should_not == 'foo' + end + + describe 'sets associated scope properly' do + it 'accepts Authorization token header' do + get('/', {}, 'HTTP_AUTHORIZATION' => "token #{access_token}").should be_ok + headers['X-OAuth-Scopes'].should == 'foo' + end + + it 'accepts basic auth' do + authorize access_token.to_s, 'x' + get('/').should be_ok + headers['X-OAuth-Scopes'].should == 'foo' + end + + it 'accepts query parameters' do + get('/', access_token: access_token.to_s).should be_ok + headers['X-OAuth-Scopes'].should == 'foo' + end + end + + describe 'reject requests with an invalide token' do + it 'rejects Authorization token header' do + get('/', {}, 'HTTP_AUTHORIZATION' => "token foo").should_not be_ok + end + + it 'rejects basic auth' do + authorize 'foo', 'x' + get('/').should_not be_ok + end + + it 'rejects query parameters' do + get('/', access_token: 'foo').should_not be_ok + end + end + + it 'sets env["travis.access_token"]' do + authorize access_token.to_s, 'x' + get('/token').body.should == access_token.to_s + end +end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index accd27bb..bc8a121e 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -4,14 +4,46 @@ require 'rspec' require 'travis/api/app' require 'sinatra/test_helpers' require 'logger' +require 'gh' +require 'multi_json' Travis.logger = Logger.new(StringIO.new) Travis::Api::App.setup Backports.require_relative_dir 'support' -RSpec.configure do |config| - config.expect_with :rspec, :stdlib - config.include Sinatra::TestHelpers - config.before(:each) { set_app Travis::Api::App.new } +module TestHelpers + include Sinatra::TestHelpers + + def custom_endpoints + @custom_endpoints ||= [] + end + + def add_endpoint(prefix, &block) + endpoint = Sinatra.new(Travis::Api::App::Endpoint, &block) + endpoint.set(prefix: prefix) + set_app Travis::Api::App.new + custom_endpoints << endpoint + end + + def parsed_body + MultiJson.decode(body) + end +end + +RSpec.configure do |config| + config.mock_framework = :mocha + config.expect_with :rspec, :stdlib + config.include TestHelpers + + config.before :each do + ::Redis.connect(url: Travis.config.redis.url).flushdb + set_app Travis::Api::App.new + end + + config.after :each do + custom_endpoints.each do |endpoint| + endpoint.superclass.direct_subclasses.delete(endpoint) + end + end end diff --git a/travis-api.gemspec b/travis-api.gemspec index 76792ae9..aa4cf14c 100644 --- a/travis-api.gemspec +++ b/travis-api.gemspec @@ -25,6 +25,7 @@ Gem::Specification.new do |s| "README.md", "Rakefile", "config.ru", + "config/database.yml", "lib/travis/api/app.rb", "lib/travis/api/app/access_token.rb", "lib/travis/api/app/endpoint.rb", @@ -33,6 +34,36 @@ Gem::Specification.new do |s| "lib/travis/api/app/endpoint/branches.rb", "lib/travis/api/app/endpoint/builds.rb", "lib/travis/api/app/endpoint/documentation.rb", + "lib/travis/api/app/endpoint/documentation/css/bootstrap-responsive.css", + "lib/travis/api/app/endpoint/documentation/css/bootstrap-responsive.min.css", + "lib/travis/api/app/endpoint/documentation/css/bootstrap.css", + "lib/travis/api/app/endpoint/documentation/css/bootstrap.min.css", + "lib/travis/api/app/endpoint/documentation/css/prettify.css", + "lib/travis/api/app/endpoint/documentation/img/glyphicons-halflings-white.png", + "lib/travis/api/app/endpoint/documentation/img/glyphicons-halflings.png", + "lib/travis/api/app/endpoint/documentation/img/grid-18px-masked.png", + "lib/travis/api/app/endpoint/documentation/js/bootstrap.js", + "lib/travis/api/app/endpoint/documentation/js/bootstrap.min.js", + "lib/travis/api/app/endpoint/documentation/js/jquery.js", + "lib/travis/api/app/endpoint/documentation/js/lang-apollo.js", + "lib/travis/api/app/endpoint/documentation/js/lang-clj.js", + "lib/travis/api/app/endpoint/documentation/js/lang-css.js", + "lib/travis/api/app/endpoint/documentation/js/lang-go.js", + "lib/travis/api/app/endpoint/documentation/js/lang-hs.js", + "lib/travis/api/app/endpoint/documentation/js/lang-lisp.js", + "lib/travis/api/app/endpoint/documentation/js/lang-lua.js", + "lib/travis/api/app/endpoint/documentation/js/lang-ml.js", + "lib/travis/api/app/endpoint/documentation/js/lang-n.js", + "lib/travis/api/app/endpoint/documentation/js/lang-proto.js", + "lib/travis/api/app/endpoint/documentation/js/lang-scala.js", + "lib/travis/api/app/endpoint/documentation/js/lang-sql.js", + "lib/travis/api/app/endpoint/documentation/js/lang-tex.js", + "lib/travis/api/app/endpoint/documentation/js/lang-vb.js", + "lib/travis/api/app/endpoint/documentation/js/lang-vhdl.js", + "lib/travis/api/app/endpoint/documentation/js/lang-wiki.js", + "lib/travis/api/app/endpoint/documentation/js/lang-xq.js", + "lib/travis/api/app/endpoint/documentation/js/lang-yaml.js", + "lib/travis/api/app/endpoint/documentation/js/prettify.js", "lib/travis/api/app/endpoint/endpoints.rb", "lib/travis/api/app/endpoint/home.rb", "lib/travis/api/app/endpoint/hooks.rb",