Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Initial commit.

  • Loading branch information...
commit 8ae8f1f5b08ff0f25805ece7580914afd735fa94 0 parents
@niho niho authored Niklas Holmgren committed
2  .gitignore
@@ -0,0 +1,2 @@
+*.gem
+Gemfile.lock
1  .rvmrc
@@ -0,0 +1 @@
+rvm 1.9.2@auth --create
3  CHANGELOG
@@ -0,0 +1,3 @@
+*1.0.0*
+
+* First public release
3  Gemfile
@@ -0,0 +1,3 @@
+source 'http://rubygems.org/'
+
+gemspec
20 LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2010, 2011 Niklas Holmgren, Sutajio
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
6 README.md
@@ -0,0 +1,6 @@
+Auth
+====
+
+A high performance OAuth2 authorization server using Sinatra and Redis,
+inspired by Resque. Can be run both as a standalone server or as a rack
+middleware.
11 Rakefile
@@ -0,0 +1,11 @@
+require 'rake/testtask'
+
+$LOAD_PATH.unshift 'lib'
+
+task :default => [:test]
+
+task :test do
+ Dir.glob('test/**/*_test.rb').each do |file|
+ require File.expand_path(file)
+ end
+end
31 auth.gemspec
@@ -0,0 +1,31 @@
+$LOAD_PATH.unshift 'lib'
+require 'auth/version'
+
+Gem::Specification.new do |s|
+ s.name = 'auth'
+ s.version = Auth::Version
+ s.summary = 'Auth is a Redis-backed high performance OAuth2 authorization server.'
+ s.description = 'A high performance OAuth2 authorization server using Sinatra and Redis, inspired by Resque. Can be run both as a standalone server or as a rack middleware.'
+
+ s.author = 'Niklas Holmgren'
+ s.email = 'niklas@sutajio.se'
+ s.homepage = 'http://github.com/sutajio/auth/'
+
+ s.files = Dir['README', 'LICENSE', 'CHANGELOG', 'Gemfile', 'Gemfile.lock', 'init.rb', 'config.ru', 'Rakefile', 'test/**/*', 'lib/**/{*,.[a-z]*}']
+ s.require_path = 'lib'
+
+ s.files = %w( README.md Rakefile LICENSE CHANGELOG )
+ s.files += Dir.glob("lib/**/*")
+ s.files += Dir.glob("test/**/*")
+ s.files += Dir.glob("tasks/**/*")
+
+ s.extra_rdoc_files = [ "LICENSE", "README.md" ]
+ s.rdoc_options = ["--charset=UTF-8"]
+
+ s.add_dependency('rack-contrib', '~> 1.0.0')
+ s.add_dependency('sinatra', '~> 1.0.0')
+ s.add_dependency('redis', '~> 2.0.0')
+ s.add_dependency('redis-namespace', '~> 0.8.0')
+
+ s.add_development_dependency('rack-test', '~> 0.5.6')
+end
11 config.ru
@@ -0,0 +1,11 @@
+#!/usr/bin/env ruby
+
+$LOAD_PATH.unshift ::File.expand_path(::File.dirname(__FILE__) + '/lib')
+require 'auth/server'
+
+if ENV['REDISTOGO_URL'] || ENV['REDIS_URL']
+ Auth.redis = ENV['REDISTOGO_URL'] || ENV['REDIS_URL']
+end
+
+use Rack::ShowExceptions
+run Auth::Server.new
1  init.rb
@@ -0,0 +1 @@
+require 'auth'
190 lib/auth.rb
@@ -0,0 +1,190 @@
+require 'redis'
+require 'redis/namespace'
+require 'json'
+
+ENV['AUTH_HASH_ALGORITHM'] ||= 'sha256'
+ENV['AUTH_TOKEN_TTL'] ||= '3600'
+
+require 'auth/version'
+require 'auth/exceptions'
+require 'auth/helpers'
+require 'auth/client'
+require 'auth/sentry'
+
+module Auth
+ include Helpers
+ extend self
+
+ # Accepts:
+ # 1. A 'hostname:port' string
+ # 2. A 'hostname:port:db' string (to select the Redis db)
+ # 3. A 'hostname:port/namespace' string (to set the Redis namespace)
+ # 4. A redis URL string 'redis://host:port'
+ # 5. An instance of `Redis`, `Redis::Client`, `Redis::DistRedis`,
+ # or `Redis::Namespace`.
+ def redis=(server)
+ if server.respond_to? :split
+ if server =~ /redis\:\/\//
+ redis = Redis.connect(:url => server)
+ else
+ server, namespace = server.split('/', 2)
+ host, port, db = server.split(':')
+ redis = Redis.new(:host => host, :port => port,
+ :thread_safe => true, :db => db)
+ end
+ namespace ||= :auth
+ @redis = Redis::Namespace.new(namespace, :redis => redis)
+ elsif server.respond_to? :namespace=
+ @redis = server
+ else
+ @redis = Redis::Namespace.new(:auth, :redis => server)
+ end
+ end
+
+ # Returns the current Redis connection. If none has been created, will
+ # create a new one.
+ def redis
+ return @redis if @redis
+ self.redis = 'localhost:6379'
+ self.redis
+ end
+
+ #
+ # Accounts
+ #
+
+ def register_account(username, password)
+ raise if username.nil? || username == ''
+ raise if password.nil? || password == ''
+ unless redis.exists("account:#{username}")
+ hash = ENV['AUTH_HASH_ALGORITHM']
+ salt = generate_secret
+ crypted_password = encrypt_password(password, salt, hash)
+ redis.hset("account:#{username}", 'crypted_password', crypted_password)
+ redis.hset("account:#{username}", 'password_hash', hash)
+ redis.hset("account:#{username}", 'password_salt', salt)
+ return true
+ else
+ return false
+ end
+ end
+
+ def authenticate_account(username, password)
+ account = redis.hgetall("account:#{username}")
+ if account['crypted_password']
+ crypted_password = encrypt_password(password,
+ account['password_salt'],
+ account['password_hash'])
+ if crypted_password == account['crypted_password']
+ return true
+ else
+ return false
+ end
+ else
+ return false
+ end
+ end
+
+ def change_password(username, old_password, new_password)
+ if authenticate_account(username, old_password)
+ hash = ENV['AUTH_HASH_ALGORITHM']
+ salt = generate_secret
+ crypted_password = encrypt_password(new_password, salt, hash)
+ redis.hset("account:#{username}", 'crypted_password', crypted_password)
+ redis.hset("account:#{username}", 'password_hash', hash)
+ redis.hset("account:#{username}", 'password_salt', salt)
+ end
+ end
+
+ def remove_account(username)
+ redis.del("account:#{username}")
+ end
+
+ #
+ # Clients
+ #
+
+ def register_client(client_id, name, redirect_uri)
+ raise if client_id.nil? || client_id == ''
+ raise if name.nil? || name == ''
+ raise if redirect_uri.nil? || redirect_uri == ''
+ unless redis.exists("client:#{client_id}")
+ secret = generate_secret
+ client = { :id => client_id,
+ :secret => secret,
+ :name => name,
+ :redirect_uri => redirect_uri }
+ client.each do |key,val|
+ redis.hset("client:#{client_id}", key, val)
+ end
+ return Client.new(client)
+ end
+ end
+
+ def authenticate_client(client_id, client_secret = nil)
+ client = redis.hgetall("client:#{client_id}")
+ if client_secret
+ return client['id'] && client['secret'] == client_secret ? Client.new(client) : false
+ else
+ return client['id'] ? Client.new(client) : false
+ end
+ end
+
+ def remove_client(client_id)
+ redis.del("client:#{client_id}")
+ end
+
+ #
+ # Authorization codes
+ #
+
+ def issue_code(account_id, client_id, redirect_uri, scopes = nil)
+ code = generate_secret
+ redis.set("code:#{client_id}:#{redirect_uri}:#{code}:account", account_id)
+ decode_scopes(scopes).each do |scope|
+ redis.sadd("code:#{client_id}:#{redirect_uri}:#{code}:scopes", scope)
+ end
+ redis.expire("code:#{client_id}:#{redirect_uri}:#{code}:account", 3600)
+ redis.expire("code:#{client_id}:#{redirect_uri}:#{code}:scopes", 3600)
+ return code
+ end
+
+ def validate_code(code, client_id, redirect_uri)
+ account_id = redis.get("code:#{client_id}:#{redirect_uri}:#{code}:account")
+ scopes = redis.smembers("code:#{client_id}:#{redirect_uri}:#{code}:scopes")
+ if account_id
+ return account_id, encode_scopes(scopes)
+ else
+ return false
+ end
+ end
+
+ #
+ # Access tokens
+ #
+
+ def issue_token(account_id, scopes = nil, ttl = nil)
+ token = generate_secret
+ redis.set("token:#{token}:account", account_id)
+ decode_scopes(scopes).each do |scope|
+ redis.sadd("token:#{token}:scopes", scope)
+ end
+ if ttl
+ redis.expire("token:#{token}:account", ttl)
+ redis.expire("token:#{token}:scopes", ttl)
+ end
+ return token
+ end
+
+ def validate_token(token, scopes = nil)
+ account_id = redis.get("token:#{token}:account")
+ if account_id &&
+ decode_scopes(scopes).all? {|scope|
+ redis.sismember("token:#{token}:scopes", scope) }
+ return account_id
+ else
+ return false
+ end
+ end
+
+end
11 lib/auth/client.rb
@@ -0,0 +1,11 @@
+module Auth
+ class Client
+ def initialize(attributes)
+ @attributes = attributes
+ end
+
+ def method_missing(method)
+ @attributes[method.to_s] || @attributes[method.to_sym]
+ end
+ end
+end
8 lib/auth/exceptions.rb
@@ -0,0 +1,8 @@
+module Auth
+ class AuthException < RuntimeError; end
+ class InvalidRequest < AuthException; end
+ class UnauthorizedClient < AuthException; end
+ class AccessDenied < AuthException; end
+ class UnsupportedResponseType < AuthException; end
+ class InvalidScope < AuthException; end
+end
54 lib/auth/helpers.rb
@@ -0,0 +1,54 @@
+require 'base64'
+require 'digest/sha2'
+
+module Auth
+ module Helpers
+
+ # Generate a unique cryptographically secure secret
+ def generate_secret
+ Base64.encode64(
+ Digest::SHA256.digest("#{Time.now}-#{rand}")
+ ).gsub('/','x').gsub('+','y').gsub('=','').strip
+ end
+
+ # Obfuscate a password using a salt and a cryptographic hash function
+ def encrypt_password(password, salt, hash)
+ case hash.to_s
+ when 'sha256'
+ Digest::SHA256.hexdigest("#{password}-#{salt}")
+ else
+ raise 'Unsupported hash algorithm'
+ end
+ end
+
+ # Given a Ruby object, returns a string suitable for storage in a
+ # queue.
+ def encode(object)
+ object.to_json
+ end
+
+ # Given a string, returns a Ruby object.
+ def decode(object)
+ return unless object
+ begin
+ JSON.parse(object)
+ rescue JSON::ParserError
+ end
+ end
+
+ # Decode a space delimited string of security scopes and return an array
+ def decode_scopes(scopes)
+ if scopes.is_a?(Array)
+ scopes.map {|s| s.to_s.strip }
+ else
+ scopes.to_s.split(' ').map {|s| s.strip }
+ end
+ end
+
+ def encode_scopes(*scopes)
+ scopes = scopes.flatten.compact
+ scopes.map {|s| s.to_s.strip.gsub(' ','_') }.sort.join(' ')
+ end
+
+ end
+end
24 lib/auth/sentry.rb
@@ -0,0 +1,24 @@
+module Auth
+ class Sentry
+ class User
+ def initialize(id); @id = id; end
+ def id; @id; end
+ end
+
+ def initialize(request)
+ @request = request
+ end
+
+ def authenticate!
+ if Auth.authenticate_account(@request.params['username'], @request.params['password'])
+ @user_id = @request.params['username']
+ else
+ raise AuthException, 'Invalid username or password'
+ end
+ end
+
+ def user
+ @user_id ? User.new(@user_id) : nil
+ end
+ end
+end
234 lib/auth/server.rb
@@ -0,0 +1,234 @@
+require 'rubygems'
+require 'sinatra/base'
+require 'erb'
+require 'cgi'
+require 'uri'
+require 'auth'
+
+module Auth
+ class Server < Sinatra::Base
+ dir = File.dirname(File.expand_path(__FILE__))
+
+ set :views, "#{dir}/server/views"
+ set :public, "#{dir}/server/public"
+ set :static, true
+
+ helpers do
+ include Rack::Utils
+ alias_method :h, :escape_html
+
+ def cgi_escape(text)
+ URI.escape(CGI.escape(text.to_s), '.').gsub(' ','+')
+ end
+
+ def query_string(parameters, escape = true)
+ if escape
+ parameters.map{|key,val| val ? "#{cgi_escape(key)}=#{cgi_escape(val)}" : nil }.compact.join('&')
+ else
+ parameters.map{|key,val| val ? "#{key}=#{val}" : nil }.compact.join('&')
+ end
+ end
+
+ def merge_uri_with_query_parameters(uri, parameters = {})
+ parameters = query_string(parameters)
+ if uri.to_s =~ /\?/
+ parameters = "&#{parameters}"
+ else
+ parameters = "?#{parameters}"
+ end
+ URI.escape(uri.to_s) + parameters.to_s
+ end
+
+ def merge_uri_with_fragment_parameters(uri, parameters = {})
+ parameters = query_string(parameters)
+ parameters = "##{parameters}"
+ URI.escape(uri.to_s) + parameters.to_s
+ end
+
+ def merge_uri_based_on_response_type(uri, parameters = {})
+ case params[:response_type]
+ when 'code', nil
+ merge_uri_with_query_parameters(uri, parameters)
+ when 'token', 'code_and_token'
+ merge_uri_with_fragment_parameters(uri, parameters)
+ end
+ end
+
+ def sentry
+ @sentry ||= request.env['warden'] || request.env['rack.auth'] || Sentry.new(request)
+ end
+
+ def require_client_identification!
+ @client = Auth.authenticate_client(params[:client_id])
+ halt(403, 'Invalid client identifier') unless @client
+ end
+
+ def require_client_authentication!
+ @client = Auth.authenticate_client(params[:client_id], params[:client_secret])
+ halt(403, 'Invalid client identifier or client secret') unless @client
+ end
+
+ def validate_redirect_uri!
+ params[:redirect_uri] ||= @client.redirect_uri
+ if URI(params[:redirect_uri]).host.downcase != URI(@client.redirect_uri).host.downcase
+ halt(400, 'Invalid redirect URI')
+ end
+ rescue URI::InvalidURIError
+ halt(400, 'Invalid redirect URI')
+ end
+ end
+
+ error AuthException do
+ headers['Content-Type'] = 'application/json;charset=utf-8'
+ [400, {
+ :error => {
+ :type => 'OAuthException',
+ :message => request.env['sinatra.error'].message
+ }
+ }.to_json]
+ end
+
+ error UnsupportedResponseType do
+ redirect_uri = merge_uri_based_on_response_type(
+ params[:redirect_uri],
+ :error => 'unsupported_response_type',
+ :error_description => request.env['sinatra.error'].message,
+ :state => params[:state])
+ redirect redirect_uri
+ end
+
+ before do
+ headers['Cache-Control'] = 'no-store'
+ end
+
+ ['', '/authorize'].each do |action|
+ get action do
+ require_client_identification!
+ validate_redirect_uri!
+ sentry.authenticate!
+ unless ['code', 'token', 'code_and_token', nil].include?(params[:response_type])
+ raise UnsupportedResponseType,
+ 'The authorization server does not support obtaining an ' +
+ 'authorization code using this method.'
+ end
+ erb(:authorize)
+ end
+ end
+
+ ['', '/authorize'].each do |action|
+ post action do
+ require_client_identification!
+ validate_redirect_uri!
+ sentry.authenticate!
+ case params[:response_type]
+ when 'code', nil
+ authorization_code = Auth.issue_code(sentry.user.id,
+ params[:client_id],
+ params[:redirect_uri],
+ params[:scope])
+ redirect_uri = merge_uri_with_query_parameters(
+ params[:redirect_uri],
+ :code => authorization_code,
+ :state => params[:state])
+ redirect redirect_uri
+ when 'token'
+ ttl = ENV['AUTH_TOKEN_TTL'].to_i
+ access_token = Auth.issue_token(sentry.user.id, params[:scope], ttl)
+ redirect_uri = merge_uri_with_fragment_parameters(
+ params[:redirect_uri],
+ :access_token => access_token,
+ :token_type => 'bearer',
+ :expires_in => ttl,
+ :expires => ttl, # Facebook compatibility
+ :scope => params[:scope],
+ :state => params[:state])
+ redirect redirect_uri
+ when 'code_and_token'
+ ttl = ENV['AUTH_TOKEN_TTL'].to_i
+ authorization_code = Auth.issue_code(sentry.user.id,
+ params[:client_id],
+ params[:redirect_uri],
+ params[:scope])
+ access_token = Auth.issue_token(sentry.user.id, params[:scope], ttl)
+ redirect_uri = merge_uri_with_fragment_parameters(
+ params[:redirect_uri],
+ :code => authorization_code,
+ :access_token => access_token,
+ :token_type => 'bearer',
+ :expires_in => ttl,
+ :expires => ttl, # Facebook compatibility
+ :scope => params[:scope],
+ :state => params[:state])
+ redirect redirect_uri
+ else
+ raise UnsupportedResponseType,
+ 'The authorization server does not support obtaining an ' +
+ 'authorization code using this method.'
+ end
+ end
+ end
+
+ ['/token', '/access_token'].each do |action|
+ post action do
+ require_client_authentication!
+ validate_redirect_uri!
+ case params[:grant_type]
+ when 'authorization_code', nil
+ account_id, scopes = Auth.validate_code(
+ params[:code], params[:client_id], params[:redirect_uri])
+ if account_id
+ ttl = ENV['AUTH_TOKEN_TTL'].to_i
+ access_token = Auth.issue_token(account_id, scopes, ttl)
+ @token = {
+ :access_token => access_token,
+ :token_type => 'bearer',
+ :expires_in => ttl,
+ :expires => ttl, # Facebook compatibility
+ :scope => scopes
+ }
+ else
+ raise AuthException, 'Invalid authorization code'
+ end
+ when 'password'
+ sentry.authenticate!
+ ttl = ENV['AUTH_TOKEN_TTL'].to_i
+ access_token = Auth.issue_token(sentry.user.id, params[:scope], ttl)
+ @token = {
+ :access_token => access_token,
+ :token_type => 'bearer',
+ :expires_in => ttl,
+ :expires => ttl, # Facebook compatibility
+ :scope => params[:scope]
+ }
+ when 'refresh_token'
+ raise AuthException, 'Unsupported grant type'
+ when 'client_credentials'
+ access_token = Auth.issue_token("client:#{@client.id}")
+ @token = {
+ :access_token => access_token,
+ :token_type => 'client'
+ }
+ else
+ raise AuthException, 'Unsupported grant type'
+ end
+ if request.accept.include?('application/json')
+ headers['Content-Type'] = 'application/json;charset=utf-8'
+ [200, @token.to_json]
+ else
+ headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=utf-8'
+ [200, query_string(@token)]
+ end
+ end
+ end
+
+ get '/validate' do
+ require_client_authentication!
+ headers['Content-Type'] = 'text/plain;charset=utf-8'
+ if account_id = Auth.validate_token(params[:access_token], params[:scope])
+ [200, account_id]
+ else
+ [403, 'Forbidden']
+ end
+ end
+ end
+end
26 lib/auth/server/views/authorize.erb
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Authorize <%= h(@client.name) %> to access your account</title>
+</head>
+<body>
+ <div class="dialog secure">
+ <h1>Authorize <%= h(@client.name) %> to access your account</h1>
+ <p><%= h(@client.name) %> is asking for access to your account.
+ We need to make sure it is OK with you.</p>
+ <form method="post">
+ <% if params[:response_type] %>
+ <input type="hidden" name="response_type" value="<%= cgi_escape(params[:response_type]) %>" />
+ <% end %>
+ <input type="hidden" name="client_id" value="<%= cgi_escape(params[:client_id]) %>" />
+ <input type="hidden" name="redirect_uri" value="<%= cgi_escape(params[:redirect_uri] || @client.redirect_uri) %>" />
+ <input type="hidden" name="scope" value="<%= cgi_escape(params[:scope]) %>" />
+ <input type="hidden" name="state" value="<%= cgi_escape(params[:state]) %>" />
+ <button type="submit">Yes, allow access</button>
+ <a href="<%= merge_uri_based_on_response_type(@client.redirect_uri, :error => 'access_denied', :error_reason => 'user_denied', :error_description => 'The user denied your request.', :state => params[:state]) %>">
+ No thanks, take me back to <%= h(@client.name) %>
+ </a>
+ </form>
+ </div>
+</body>
+</html>
3  lib/auth/version.rb
@@ -0,0 +1,3 @@
+module Auth
+ Version = VERSION = '0.0.1'
+end
140 test/auth_test.rb
@@ -0,0 +1,140 @@
+require File.expand_path('test/test_helper')
+
+class AuthTest < Test::Unit::TestCase
+
+ def setup
+ Auth.redis.flushall
+ end
+
+ def test_can_set_a_namespace_through_a_url_like_string
+ assert Auth.redis
+ assert_equal :auth, Auth.redis.namespace
+ Auth.redis = 'localhost:9736/namespace'
+ assert_equal 'namespace', Auth.redis.namespace
+ end
+
+ def test_can_register_an_account
+ assert Auth.register_account('test', 'test')
+ end
+
+ def test_can_only_register_an_account_once
+ assert_equal true, Auth.register_account('test', 'test')
+ assert_equal false, Auth.register_account('test', 'test')
+ end
+
+ def test_can_authenticate_account
+ Auth.register_account('test', 'test')
+ assert_equal true, Auth.authenticate_account('test', 'test')
+ assert_equal false, Auth.authenticate_account('test', 'wrong')
+ assert_equal false, Auth.authenticate_account('wrong', 'wrong')
+ assert_equal false, Auth.authenticate_account('wrong', 'test')
+ end
+
+ def test_can_change_password_for_an_account
+ Auth.register_account('test', 'test')
+ Auth.change_password('test', 'test', '123456')
+ assert_equal false, Auth.authenticate_account('test', 'test')
+ assert_equal true, Auth.authenticate_account('test', '123456')
+ end
+
+ def test_can_remove_account
+ Auth.register_account('test', 'test')
+ Auth.remove_account('test')
+ assert_equal false, Auth.authenticate_account('test', 'test')
+ end
+
+ def test_can_register_a_client
+ client = Auth.register_client('test-client', 'Test client', 'http://example.org/')
+ assert_equal 'test-client', client.id
+ assert_equal 'Test client', client.name
+ assert_equal 'http://example.org/', client.redirect_uri
+ assert client.secret
+ end
+
+ def test_can_authenticate_a_client
+ client = Auth.register_client('test-client', 'Test client', 'http://example.org/')
+ client = Auth.authenticate_client('test-client', client.secret)
+ assert_equal 'test-client', client.id
+ assert_equal 'Test client', client.name
+ assert_equal 'http://example.org/', client.redirect_uri
+ assert client.secret
+ assert_equal false, Auth.authenticate_client('test-client', 'wrong')
+ assert_equal false, Auth.authenticate_client('wrong', 'wrong')
+ assert_equal false, Auth.authenticate_client('wrong', client.secret)
+ assert_equal false, Auth.authenticate_client('wrong')
+ end
+
+ def test_can_authenticate_a_client_without_a_client_secret
+ client = Auth.register_client('test-client', 'Test client', 'http://example.org/')
+ client = Auth.authenticate_client('test-client')
+ assert_equal 'test-client', client.id
+ assert_equal 'Test client', client.name
+ assert_equal 'http://example.org/', client.redirect_uri
+ assert client.secret
+ end
+
+ def test_can_remove_client
+ Auth.register_client('test-client', 'Test client', 'http://example.org/')
+ Auth.remove_client('test-client')
+ assert_equal false, Auth.authenticate_client('test-client')
+ end
+
+ def test_can_issue_a_token_for_an_account
+ assert Auth.issue_token('test-account')
+ end
+
+ def test_can_validate_a_token_and_return_the_associated_account_id
+ token = Auth.issue_token('test-account')
+ assert_equal 'test-account', Auth.validate_token(token)
+ assert_equal false, Auth.validate_token('gibberish')
+ end
+
+ def test_can_issue_a_token_for_a_specified_set_of_scopes
+ assert Auth.issue_token('test-account', 'read write offline')
+ end
+
+ def test_can_validate_a_token_with_a_specified_set_of_scopes
+ token = Auth.issue_token('test-account', 'read write offline')
+ assert_equal 'test-account', Auth.validate_token(token)
+ assert_equal 'test-account', Auth.validate_token(token, 'read')
+ assert_equal 'test-account', Auth.validate_token(token, 'write offline')
+ assert_equal 'test-account', Auth.validate_token(token, 'offline read write')
+ assert_equal false, Auth.validate_token('gibberish', 'read')
+ assert_equal false, Auth.validate_token(token, 'delete')
+ assert_equal false, Auth.validate_token(token, 'read delete')
+ end
+
+ def test_can_issue_a_time_limited_token
+ assert Auth.issue_token('test-account', nil, 3600)
+ end
+
+ def test_can_issue_a_refresh_token
+ flunk
+ end
+
+ def test_can_redeem_a_refresh_token
+ flunk
+ end
+
+ def test_can_issue_an_authorization_code
+ assert Auth.issue_code('test-account', 'test-client', 'https://example.com/callback')
+ end
+
+ def test_can_validate_an_authentication_code
+ code = Auth.issue_code('test-account', 'test-client', 'https://example.com/callback')
+ assert_equal ['test-account', ''], Auth.validate_code(code, 'test-client', 'https://example.com/callback')
+ assert_equal false, Auth.validate_code(code, 'wrong-client', 'https://example.com/callback')
+ assert_equal false, Auth.validate_code(code, 'test-client', 'https://example.com/wrong-callback')
+ end
+
+ def test_can_issue_an_authorization_code_for_a_specified_set_of_scopes
+ assert Auth.issue_code('test-account', 'test-client', 'https://example.com/callback', 'read write offline')
+ end
+
+ def test_can_validate_an_authentication_code_with_a_specified_set_of_scopes
+ code = Auth.issue_code('test-account', 'test-client', 'https://example.com/callback', 'read write offline')
+ account_id, scopes = Auth.validate_code(code, 'test-client', 'https://example.com/callback')
+ assert_equal 'test-account', account_id
+ assert_equal 'offline read write', scopes
+ end
+end
115 test/redis-test.conf
@@ -0,0 +1,115 @@
+# Redis configuration file example
+
+# By default Redis does not run as a daemon. Use 'yes' if you need it.
+# Note that Redis will write a pid file in /var/run/redis.pid when daemonized.
+daemonize yes
+
+# When run as a daemon, Redis write a pid file in /var/run/redis.pid by default.
+# You can specify a custom pid file location here.
+pidfile ./test/redis-test.pid
+
+# Accept connections on the specified port, default is 6379
+port 9736
+
+# If you want you can bind a single interface, if the bind option is not
+# specified all the interfaces will listen for connections.
+#
+# bind 127.0.0.1
+
+# Close the connection after a client is idle for N seconds (0 to disable)
+timeout 300
+
+# Save the DB on disk:
+#
+# save <seconds> <changes>
+#
+# Will save the DB if both the given number of seconds and the given
+# number of write operations against the DB occurred.
+#
+# In the example below the behaviour will be to save:
+# after 900 sec (15 min) if at least 1 key changed
+# after 300 sec (5 min) if at least 10 keys changed
+# after 60 sec if at least 10000 keys changed
+save 900 1
+save 300 10
+save 60 10000
+
+# The filename where to dump the DB
+dbfilename dump.rdb
+
+# For default save/load DB in/from the working directory
+# Note that you must specify a directory not a file name.
+dir ./test/
+
+# Set server verbosity to 'debug'
+# it can be one of:
+# debug (a lot of information, useful for development/testing)
+# notice (moderately verbose, what you want in production probably)
+# warning (only very important / critical messages are logged)
+loglevel debug
+
+# Specify the log file name. Also 'stdout' can be used to force
+# the demon to log on the standard output. Note that if you use standard
+# output for logging but daemonize, logs will be sent to /dev/null
+logfile stdout
+
+# Set the number of databases. The default database is DB 0, you can select
+# a different one on a per-connection basis using SELECT <dbid> where
+# dbid is a number between 0 and 'databases'-1
+databases 16
+
+################################# REPLICATION #################################
+
+# Master-Slave replication. Use slaveof to make a Redis instance a copy of
+# another Redis server. Note that the configuration is local to the slave
+# so for example it is possible to configure the slave to save the DB with a
+# different interval, or to listen to another port, and so on.
+
+# slaveof <masterip> <masterport>
+
+################################## SECURITY ###################################
+
+# Require clients to issue AUTH <PASSWORD> before processing any other
+# commands. This might be useful in environments in which you do not trust
+# others with access to the host running redis-server.
+#
+# This should stay commented out for backward compatibility and because most
+# people do not need auth (e.g. they run their own servers).
+
+# requirepass foobared
+
+################################### LIMITS ####################################
+
+# Set the max number of connected clients at the same time. By default there
+# is no limit, and it's up to the number of file descriptors the Redis process
+# is able to open. The special value '0' means no limts.
+# Once the limit is reached Redis will close all the new connections sending
+# an error 'max number of clients reached'.
+
+# maxclients 128
+
+# Don't use more memory than the specified amount of bytes.
+# When the memory limit is reached Redis will try to remove keys with an
+# EXPIRE set. It will try to start freeing keys that are going to expire
+# in little time and preserve keys with a longer time to live.
+# Redis will also try to remove objects from free lists if possible.
+#
+# If all this fails, Redis will start to reply with errors to commands
+# that will use more memory, like SET, LPUSH, and so on, and will continue
+# to reply to most read-only commands like GET.
+#
+# WARNING: maxmemory can be a good idea mainly if you want to use Redis as a
+# 'state' server or cache, not as a real DB. When Redis is used as a real
+# database the memory usage will grow over the weeks, it will be obvious if
+# it is going to use too much memory in the long run, and you'll have the time
+# to upgrade. With maxmemory after the limit is reached you'll start to get
+# errors for write operations, and this may even lead to DB inconsistency.
+
+# maxmemory <bytes>
+
+############################### ADVANCED CONFIG ###############################
+
+# Glue small output buffers together in order to send small replies in a
+# single TCP packet. Uses a bit more CPU but most of the times it is a win
+# in terms of number of queries per second. Use 'yes' if unsure.
+glueoutputbuf yes
178 test/server_test.rb
@@ -0,0 +1,178 @@
+require File.expand_path('test/test_helper')
+require 'auth/server'
+
+class ServerTest < Test::Unit::TestCase
+ include Rack::Test::Methods
+
+ def app
+ Auth::Server.new
+ end
+
+ def setup
+ Auth.redis.flushall
+ Auth.register_account('test', 'test')
+ @client = Auth.register_client('test-client', 'Test', 'https://example.com/callback')
+ @authorization_code = Auth.issue_code('test-account', @client.id, @client.redirect_uri, 'read write')
+ end
+
+ def test_should_not_allow_invalid_redirect_uri
+ get '/authorize', :client_id => @client.id, :redirect_uri => 'invalid uri'
+ assert_equal 400, last_response.status
+ get '/authorize', :client_id => @client.id, :redirect_uri => 'https://wrong.com/callback'
+ assert_equal 400, last_response.status
+ get '/authorize', :client_id => @client.id, :redirect_uri => 'https://wrong.example.com/callback'
+ assert_equal 400, last_response.status
+ end
+
+ def test_obtaining_end_user_authorization
+ get '/authorize',
+ :response_type => 'code',
+ :client_id => @client.id,
+ :redirect_uri => @client.redirect_uri,
+ :scope => 'read write',
+ :state => 'opaque',
+ :username => 'test',
+ :password => 'test'
+ assert_equal 200, last_response.status
+ assert_equal 'text/html;charset=utf-8', last_response.headers['Content-Type']
+ assert_equal 'no-store', last_response.headers['Cache-Control']
+ assert_match 'code', last_response.body
+ assert_match @client.id.to_s, last_response.body
+ assert_match 'https%3A%2F%2Fexample%2Ecom%2Fcallback', last_response.body
+ assert_match 'read+write', last_response.body
+ assert_match 'opaque', last_response.body
+ end
+
+ def test_request_for_authorization_code
+ post '/authorize',
+ :response_type => 'code',
+ :client_id => @client.id,
+ :redirect_uri => @client.redirect_uri,
+ :scope => 'read write',
+ :state => 'opaque',
+ :username => 'test',
+ :password => 'test'
+ assert_equal 302, last_response.status
+ assert_equal 'no-store', last_response.headers['Cache-Control']
+ location_uri = URI(last_response.headers['Location'])
+ assert_equal 'https', location_uri.scheme
+ assert_equal 'example.com', location_uri.host
+ assert_equal '/callback', location_uri.path
+ assert_match /code=[^&]+/, location_uri.query
+ assert_match /state=opaque/, location_uri.query
+ end
+
+ def test_request_for_access_token_using_authorization_code
+ post '/access_token', {
+ :grant_type => 'authorization_code',
+ :client_id => @client.id,
+ :client_secret => @client.secret,
+ :redirect_uri => @client.redirect_uri,
+ :code => @authorization_code
+ }, 'HTTP_ACCEPT' => 'application/json'
+ assert_equal 200, last_response.status
+ assert_equal 'application/json;charset=utf-8', last_response.headers['Content-Type']
+ assert_equal 'no-store', last_response.headers['Cache-Control']
+ token = JSON.parse(last_response.body)
+ assert token['access_token']
+ assert_equal 'bearer', token['token_type']
+ assert_equal 3600, token['expires_in']
+ assert_equal 'read write', token['scope']
+ end
+
+ def test_request_for_access_token_using_password
+ post '/access_token', {
+ :grant_type => 'password',
+ :client_id => @client.id,
+ :client_secret => @client.secret,
+ :redirect_uri => @client.redirect_uri,
+ :scope => 'read write',
+ :username => 'test',
+ :password => 'test'
+ }, 'HTTP_ACCEPT' => 'application/json'
+ assert_equal 200, last_response.status
+ assert_equal 'application/json;charset=utf-8', last_response.headers['Content-Type']
+ assert_equal 'no-store', last_response.headers['Cache-Control']
+ token = JSON.parse(last_response.body)
+ assert token['access_token']
+ assert_equal 'bearer', token['token_type']
+ assert_equal 3600, token['expires_in']
+ assert_equal 'read write', token['scope']
+ end
+
+ def test_request_for_access_token_using_refresh_token
+ post '/access_token', {
+ :grant_type => 'refresh_token',
+ :client_id => @client.id,
+ :client_secret => @client.secret,
+ :redirect_uri => @client.redirect_uri,
+ :refresh_token => '?'
+ }, 'HTTP_ACCEPT' => 'application/json'
+ assert_equal 200, last_response.status
+ assert_equal 'application/json;charset=utf-8', last_response.headers['Content-Type']
+ assert_equal 'no-store', last_response.headers['Cache-Control']
+ token = JSON.parse(last_response.body)
+ assert token['access_token']
+ assert_equal 'bearer', token['token_type']
+ assert_equal 3600*24, token['expires_in']
+ assert_equal 'read write', token['scope']
+ end
+
+ def test_request_for_access_token_using_client_credentials
+ post '/access_token', {
+ :grant_type => 'client_credentials',
+ :client_id => @client.id,
+ :client_secret => @client.secret,
+ :redirect_uri => @client.redirect_uri
+ }, 'HTTP_ACCEPT' => 'application/json'
+ assert_equal 200, last_response.status
+ assert_equal 'application/json;charset=utf-8', last_response.headers['Content-Type']
+ assert_equal 'no-store', last_response.headers['Cache-Control']
+ token = JSON.parse(last_response.body)
+ assert token['access_token']
+ assert_equal 'client', token['token_type']
+ end
+
+ # def test_request_for_both_code_and_token
+ # Warden.on_next_request do |warden|
+ # post '/test/authorize',
+ # :response_type => 'code_and_token',
+ # :client_id => 'test-client',
+ # :redirect_uri => 'https://example.com/callback',
+ # :scope => 'read write',
+ # :state => 'opaque'
+ # assert_equal 302, last_response.status
+ # location_uri = URI(last_response.headers['Location'])
+ # assert_equal 'https', location_uri.scheme
+ # assert_equal 'example.com', location_uri.host
+ # assert_equal '/callback', location_uri.path
+ # assert_match /code=/, location_uri.query
+ # location_uri_fragment_parts = location_uri.fragment.split('&')
+ # assert_equal true, location_uri_fragment_parts.include?('code=')
+ # assert_equal true, location_uri_fragment_parts.include?('access_token=')
+ # assert_equal true, location_uri_fragment_parts.include?('token_type=')
+ # assert_equal true, location_uri_fragment_parts.include?('expires_in=')
+ # assert_equal true, location_uri_fragment_parts.include?('scope=')
+ # assert_equal true, location_uri_fragment_parts.include?('state=')
+ # end
+ # end
+
+ # def test_validate_access_token
+ # basic_authorize @client.username, @client.password
+ # get '/test/validate', :token => 'xxx', :client_id => 'test', :scope => 'read write'
+ # assert_equal 200, last_response.status
+ # end
+ #
+ # def test_validate_expired_access_token
+ # basic_authorize @client.username, @client.password
+ # get '/test/validate', :token => 'xxx', :client_id => 'test', :scope => 'read write'
+ # assert_equal 403, last_response.status
+ # end
+ #
+ # def test_validate_invalid_access_token
+ # basic_authorize @client.username, @client.password
+ # get '/test/validate', :token => 'invalid', :client_id => 'test', :scope => 'read write'
+ # assert_equal 403, last_response.status
+ # end
+
+end
44 test/test_helper.rb
@@ -0,0 +1,44 @@
+ENV['RACK_ENV'] = 'test'
+
+dir = File.dirname(File.expand_path(__FILE__))
+$LOAD_PATH.unshift dir + '/../lib'
+
+require 'test/unit'
+require 'rack/test'
+require 'auth'
+
+#
+# make sure we can run redis
+#
+
+if !system("which redis-server")
+ puts '', "** can't find `redis-server` in your path"
+ puts "** try running `sudo rake install`"
+ abort ''
+end
+
+
+#
+# start our own redis when the tests start,
+# kill it when they end
+#
+
+at_exit do
+ next if $!
+
+ if defined?(MiniTest)
+ exit_code = MiniTest::Unit.new.run(ARGV)
+ else
+ exit_code = Test::Unit::AutoRunner.run
+ end
+
+ pid = `ps -A -o pid,command | grep [r]edis-test`.split(" ")[0]
+ puts "Killing test redis server..."
+ `rm -f #{dir}/dump.rdb`
+ Process.kill("KILL", pid.to_i)
+ exit exit_code
+end
+
+puts "Starting redis for testing at localhost:9736..."
+`redis-server #{dir}/redis-test.conf`
+Auth.redis = 'localhost:9736'
Please sign in to comment.
Something went wrong with that request. Please try again.