├── config.ru ├── env-example ├── Gemfile ├── README.md ├── .gitignore ├── Rakefile ├── LICENSE ├── views └── auth.erb ├── Gemfile.lock └── app.rb /config.ru: -------------------------------------------------------------------------------- 1 | env = ENV['RACK_ENV'].to_sym 2 | 3 | require "bundler/setup" 4 | Bundler.require(:default, env) 5 | 6 | Dotenv.load unless env == :production 7 | 8 | require './app' 9 | run Sinatra::Application 10 | -------------------------------------------------------------------------------- /env-example: -------------------------------------------------------------------------------- 1 | # rename to .env when developing locally 2 | 3 | GITHUB_KEY= 4 | GITHUB_SECRET= 5 | GITHUB_USERNAME=barryf 6 | 7 | REDIS_URL=redis://127.0.0.1:6379 8 | 9 | COOKIE_SECRET=choose_a_strong_password_or_phrase 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | ruby '2.7.3' 4 | 5 | gem 'sinatra' 6 | gem 'thin' 7 | gem 'rack-ssl' 8 | gem 'redis' 9 | gem 'omniauth' 10 | gem 'omniauth-github' 11 | gem 'foreman' 12 | 13 | group :development do 14 | gem 'shotgun' 15 | gem 'dotenv' 16 | end 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Acquiescence 2 | 3 | Acquiescence is a very simple [IndieAuth](https://indieweb.org/indieauth) authorization and token endpoint. 4 | 5 | It requires a [Redis](https://redis.io/) server. 6 | 7 | It's currently only set up to authorize using GitHub because that's my preferred provider, but adding in additional [OmniAuth](https://github.com/omniauth/omniauth)-compatible providers should be reasonably simple. 8 | 9 | --- 10 | 11 | [Barry Frost](https://barryfrost.com/) 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | .env 15 | 16 | ## Environment normalization: 17 | /.bundle/ 18 | /vendor/bundle 19 | /lib/bundler/man/ 20 | 21 | # for a library or gem, you might want to ignore these files since the code is 22 | # intended to run in multiple environments; otherwise, check them in: 23 | # Gemfile.lock 24 | # .ruby-version 25 | # .ruby-gemset 26 | 27 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 28 | .rvmrc 29 | .rubocop.yml 30 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | env = (ENV['RACK_ENV'] || 'development').to_sym 2 | 3 | require "bundler/setup" 4 | Bundler.require(:default, env) 5 | 6 | Dotenv.load unless env == :production 7 | 8 | REDIS = Redis.new(url: ENV['REDIS_URL']) 9 | 10 | desc "List all keys in the Redis store." 11 | task :keys do 12 | keys = REDIS.scan(0)[1] 13 | keys.each do |key| 14 | puts key 15 | end 16 | end 17 | 18 | desc "Get a key from the Redis store and display its value." 19 | task :key, :key do |t, args| 20 | key = args[:key] 21 | v = REDIS.get(key) 22 | if v 23 | puts v 24 | else 25 | puts "Key was not found." 26 | end 27 | end 28 | 29 | desc "Flush the Redis store." 30 | task :flush do 31 | REDIS.flushall 32 | puts "Flushed." 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Barry Frost 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /views/auth.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Acquiescence 6 | 7 | 11 | 12 | 13 |
14 |
15 |
16 | 17 |

Acquiescence

18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
me<%= h params[:me] %>
client_id<%= h params[:client_id] %>
redirect_uri<%= h params[:redirect_uri] %>
scope(s)<%= h params[:scope] %>
39 | 40 |
41 |
42 | 45 |

46 | 47 | 48 |
49 |
50 |
51 | 52 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | connection_pool (2.2.5) 5 | daemons (1.3.1) 6 | dotenv (2.7.6) 7 | eventmachine (1.2.7) 8 | excon (0.80.1) 9 | faraday (1.4.0) 10 | faraday-excon (~> 1.0) 11 | faraday-net_http (~> 1.0) 12 | faraday-net_http_persistent (~> 1.0) 13 | multipart-post (>= 1.2, < 3) 14 | ruby2_keywords (>= 0.0.4) 15 | faraday-excon (1.0.0) 16 | excon (>= 0.27.4) 17 | faraday-net_http (1.0.1) 18 | faraday-net_http_persistent (1.0.3) 19 | net-http-persistent (>= 3.1) 20 | foreman (0.87.2) 21 | hashie (4.1.0) 22 | jwt (2.2.2) 23 | multi_json (1.15.0) 24 | multi_xml (0.6.0) 25 | multipart-post (2.1.1) 26 | mustermann (1.1.1) 27 | ruby2_keywords (~> 0.0.1) 28 | net-http-persistent (4.0.1) 29 | connection_pool (~> 2.2) 30 | oauth2 (1.4.7) 31 | faraday (>= 0.8, < 2.0) 32 | jwt (>= 1.0, < 3.0) 33 | multi_json (~> 1.3) 34 | multi_xml (~> 0.5) 35 | rack (>= 1.2, < 3) 36 | omniauth (2.0.4) 37 | hashie (>= 3.4.6) 38 | rack (>= 1.6.2, < 3) 39 | rack-protection 40 | omniauth-github (2.0.0) 41 | omniauth (~> 2.0) 42 | omniauth-oauth2 (~> 1.7.1) 43 | omniauth-oauth2 (1.7.1) 44 | oauth2 (~> 1.4) 45 | omniauth (>= 1.9, < 3) 46 | rack (2.2.3) 47 | rack-protection (2.1.0) 48 | rack 49 | rack-ssl (1.4.1) 50 | rack 51 | redis (4.2.5) 52 | ruby2_keywords (0.0.4) 53 | shotgun (0.9.2) 54 | rack (>= 1.0) 55 | sinatra (2.1.0) 56 | mustermann (~> 1.0) 57 | rack (~> 2.2) 58 | rack-protection (= 2.1.0) 59 | tilt (~> 2.0) 60 | thin (1.8.0) 61 | daemons (~> 1.0, >= 1.0.9) 62 | eventmachine (~> 1.0, >= 1.0.4) 63 | rack (>= 1, < 3) 64 | tilt (2.0.10) 65 | 66 | PLATFORMS 67 | x86_64-darwin-20 68 | x86_64-linux 69 | 70 | DEPENDENCIES 71 | dotenv 72 | foreman 73 | omniauth 74 | omniauth-github 75 | rack-ssl 76 | redis 77 | shotgun 78 | sinatra 79 | thin 80 | 81 | RUBY VERSION 82 | ruby 2.7.3p183 83 | 84 | BUNDLED WITH 85 | 2.2.16 86 | -------------------------------------------------------------------------------- /app.rb: -------------------------------------------------------------------------------- 1 | configure do 2 | # use a cookie that lasts for 1 minute 3 | secret = ENV['COOKIE_SECRET'] || SecureRandom.hex(20) 4 | use Rack::Session::Cookie, secret: secret, expire_after: 60 5 | 6 | use Rack::SSL if settings.production? 7 | 8 | REDIS = Redis.new(url: ENV['REDIS_URL']) 9 | 10 | use OmniAuth::Builder do 11 | provider :github, ENV['GITHUB_KEY'], ENV['GITHUB_SECRET'], scope: 'user' 12 | end 13 | end 14 | 15 | helpers do 16 | def set_auth(code, redirect_uri, client_id, me, scope) 17 | key = [code, redirect_uri, client_id].join("_") 18 | json = { me: me, scope: scope }.to_json 19 | REDIS.set(key, json) 20 | logger.info "Setting auth key #{key} with json #{json.to_s}" 21 | REDIS.expire(key, 60) 22 | end 23 | 24 | def get_auth(code, redirect_uri, client_id) 25 | key = [code, redirect_uri, client_id].join("_") 26 | json = REDIS.get(key) 27 | logger.info "Getting auth key #{key} and found json #{json.to_s}" 28 | data = JSON.parse(json) 29 | data 30 | end 31 | 32 | def set_token(token, me, scope, client_id) 33 | json = { me: me, scope: scope, client_id: client_id }.to_json 34 | REDIS.set(token, json) 35 | logger.info "Setting token #{token} with json #{json.to_s}" 36 | set_token_expiry(token) 37 | end 38 | 39 | def get_token(token) 40 | json = REDIS.get(token) 41 | logger.info "Getting token #{token} and found json #{json.to_s}" 42 | data = JSON.parse(json) 43 | # reset expiry with every use 44 | set_token_expiry(token) 45 | data 46 | end 47 | 48 | def set_token_expiry(token) 49 | # token lasts for 30 days 50 | REDIS.expire(token, 2_592_000) 51 | end 52 | 53 | def render_data(data) 54 | if request.accept?('application/json') 55 | content_type :json 56 | data.to_json 57 | else 58 | content_type 'application/x-www-form-urlencoded' 59 | URI.encode_www_form(data) 60 | end 61 | end 62 | 63 | def halt_error(message) 64 | logger.info "Halted on error #{message}" 65 | halt message 66 | end 67 | 68 | def h(text) 69 | Rack::Utils.escape_html(text) 70 | end 71 | end 72 | 73 | get '/' do 74 | "Authorization server" 75 | end 76 | 77 | get '/auth' do 78 | %w(me client_id redirect_uri state).each do |param| 79 | unless params.key?(param) && !params[param].empty? 80 | halt_error("Authorization request was missing '#{param}' parameter.") 81 | end 82 | end 83 | 84 | session[:redirect_uri] = params[:redirect_uri] 85 | session[:client_id] = params[:client_id] 86 | session[:me] = params[:me] 87 | session[:state] = params[:state] 88 | session[:scope] = params[:scope] || "" 89 | 90 | erb :auth 91 | end 92 | 93 | get '/auth/github/callback' do 94 | # confirm auth'd github username matches my github username 95 | username = request.env['omniauth.auth']['info']['nickname'] 96 | unless username == ENV['GITHUB_USERNAME'] 97 | halt_error("GitHub username (#{username}) does not match.") 98 | end 99 | 100 | if session.empty? 101 | halt_error("Session has expired during authorization. Please try again.") 102 | end 103 | 104 | code = SecureRandom.hex(20) 105 | set_auth(code, session[:redirect_uri], session[:client_id], session[:me], 106 | session[:scope]) 107 | 108 | query = URI.encode_www_form({ 109 | code: code, 110 | state: session[:state], 111 | me: session[:me] 112 | }) 113 | url = "#{session[:redirect_uri]}?#{query}" 114 | session.clear 115 | 116 | logger.info "Callback is redirecting to #{url}" 117 | redirect url 118 | end 119 | 120 | get '/auth/failure' do 121 | params[:message] 122 | end 123 | 124 | post '/auth' do 125 | me = get_auth(params[:code], params[:redirect_uri], params[:client_id]) 126 | data = {me: me} 127 | render_data(data) 128 | end 129 | 130 | post '/token' do 131 | %w(code me redirect_uri client_id).each do |param| 132 | unless params.key?(param) && !params[param].empty? 133 | halt_error("Authorization request was missing '#{param}' parameter.") 134 | end 135 | end 136 | 137 | # verify against auth 138 | auth = get_auth(params[:code], params[:redirect_uri], params[:client_id]) 139 | if auth.nil? || auth.empty? || params[:me] != auth['me'] 140 | halt_error("Authorization could not be found (or has expired).") 141 | end 142 | 143 | token = SecureRandom.hex(50) 144 | set_token(token, auth['me'], auth['scope'], params[:client_id]) 145 | 146 | data = { 147 | access_token: token, 148 | scope: auth['scope'], 149 | me: auth['me'] 150 | } 151 | render_data(data) 152 | end 153 | 154 | get '/token' do 155 | token = request.env['HTTP_AUTHORIZATION'] || params['access_token'] || "" 156 | token.sub!(/^Bearer /,'') 157 | if token.empty? 158 | halt_error("Access token was not found in request header or body.") 159 | end 160 | 161 | data = get_token(token) 162 | if data.nil? || data.empty? 163 | halt_error("Token not found (or has expired).") 164 | end 165 | render_data(data) 166 | end 167 | --------------------------------------------------------------------------------