├── .gitignore ├── .rubocop.yml ├── .rubocop_todo.yml ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── Guardfile ├── LICENSE.md ├── README.md ├── Rakefile ├── coverage └── .gitkeep ├── doc ├── alpha_passport_modified.mscgen ├── alpha_passport_modified.png ├── alpha_requests_passport_for_emily.mscgen ├── alpha_requests_passport_for_emily.png ├── alpha_sends_emily_to_login.mscgen ├── alpha_sends_emily_to_login.png ├── alpha_verification_times_out.mscgen ├── alpha_verification_times_out.png ├── alpha_verifies_emilys_passport.mscgen ├── alpha_verifies_emilys_passport.png ├── alpha_verifies_iphone_passport.mscgen ├── alpha_verifies_iphone_passport.png ├── alpha_verifies_iphone_passport_again.mscgen ├── alpha_verifies_iphone_passport_again.png ├── alpha_verifies_iphone_passport_fails.mscgen ├── alpha_verifies_iphone_passport_fails.png ├── alpha_yields_resource_to_emily.mscgen ├── alpha_yields_resource_to_emily.png ├── beta_comes_into_play.mscgen ├── beta_comes_into_play.png ├── beta_oauth_dance.mscgen ├── beta_oauth_dance.png ├── bouncer_sends_to_login_form.png ├── doorkeeper_hands_out_alpha_access_token.mscgen ├── doorkeeper_hands_out_alpha_access_token.png ├── doorkeeper_hands_out_alpha_grant.mscgen ├── doorkeeper_hands_out_alpha_grant.png ├── emily_knocks_onto_alphas_door.mscgen ├── emily_knocks_onto_alphas_door.png ├── emily_presents_bouncer_credentials.mscgen ├── emily_presents_bouncer_credentials.png ├── emily_wants_beta_resource_again.mscgen ├── emily_wants_beta_resource_again.png ├── emily_wants_resource_again.mscgen ├── emily_wants_resource_again.png ├── iphone_comes_into_play.mscgen ├── iphone_comes_into_play.png ├── iphone_requests_alpha_resource.mscgen └── iphone_requests_alpha_resource.png ├── lib ├── sso.rb └── sso │ ├── benchmarking.rb │ ├── client.rb │ ├── client │ ├── README.md │ ├── authentications │ │ └── passport.rb │ ├── omniauth │ │ └── strategies │ │ │ └── sso.rb │ ├── passport.rb │ ├── passport_verifier.rb │ └── warden │ │ ├── hooks │ │ └── after_fetch.rb │ │ └── strategies │ │ └── passport.rb │ ├── configuration.rb │ ├── configure.rb │ ├── logging.rb │ ├── meter.rb │ ├── server.rb │ └── server │ ├── README.md │ ├── authentications │ └── passport.rb │ ├── doorkeeper │ ├── access_token_marker.rb │ ├── grant_marker.rb │ └── resource_owner_authenticator.rb │ ├── engine.rb │ ├── errors.rb │ ├── middleware │ ├── passport_destruction.rb │ ├── passport_exchange.rb │ └── passport_verification.rb │ ├── passport.rb │ ├── passports.rb │ ├── passports │ └── activity.rb │ └── warden │ ├── hooks │ ├── after_authentication.rb │ └── before_logout.rb │ └── strategies │ └── passport.rb ├── spec ├── dummy │ ├── Rakefile │ ├── app │ │ ├── assets │ │ │ ├── javascripts │ │ │ │ └── application.js │ │ │ └── stylesheets │ │ │ │ └── application.css │ │ ├── controllers │ │ │ ├── application_controller.rb │ │ │ ├── home_controller.rb │ │ │ └── sessions_controller.rb │ │ ├── models │ │ │ ├── .keep │ │ │ └── user.rb │ │ └── views │ │ │ ├── home │ │ │ └── index.html.erb │ │ │ ├── layouts │ │ │ └── application.html.erb │ │ │ └── sessions │ │ │ └── new.html.erb │ ├── bin │ │ ├── bundle │ │ ├── rails │ │ ├── rake │ │ └── setup │ ├── config.ru │ ├── config │ │ ├── application.rb │ │ ├── boot.rb │ │ ├── database.yml │ │ ├── environment.rb │ │ ├── environments │ │ │ ├── development.rb │ │ │ └── test.rb │ │ ├── initializers │ │ │ ├── assets.rb │ │ │ ├── cookies_serializer.rb │ │ │ ├── doorkeeper.rb │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── secret_token.rb │ │ │ ├── session_store.rb │ │ │ ├── sso.rb │ │ │ ├── warden.rb │ │ │ └── wrap_parameters.rb │ │ ├── locales │ │ │ ├── doorkeeper.en.yml │ │ │ └── en.yml │ │ └── routes.rb │ ├── db │ │ ├── migrate │ │ │ ├── 20150302113121_add_users.rb │ │ │ ├── 20150303054803_create_doorkeeper_tables.rb │ │ │ └── 20150303132931_create_passports_table.rb │ │ └── schema.rb │ └── log │ │ └── .keep ├── integration │ └── oauth │ │ ├── authorization_code_spec.rb │ │ └── password_spec.rb ├── lib │ └── sso │ │ ├── benchmarking_spec.rb │ │ ├── client │ │ ├── authentications │ │ │ └── passport_spec.rb │ │ └── warden │ │ │ ├── hooks │ │ │ └── after_fetch_spec.rb │ │ │ └── strategies │ │ │ └── passport_spec.rb │ │ ├── logging_spec.rb │ │ └── server │ │ ├── configuration_spec.rb │ │ ├── middleware │ │ └── passport_destruction_spec.rb │ │ ├── passports_spec.rb │ │ └── warden │ │ └── hooks │ │ └── before_logout_spec.rb ├── spec_helper.rb └── support │ ├── factories │ ├── doorkeeper │ │ └── application.rb │ └── server │ │ ├── passport.rb │ │ └── user.rb │ └── sso │ ├── test.rb │ └── test │ ├── cookie_stripper.rb │ └── helpers.rb └── sso.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.gem 3 | /.bundle 4 | tmp 5 | coverage/* 6 | !coverage/.gitkeep 7 | !coverage/README.md 8 | 9 | /spec/dummy/log/*.log 10 | /spec/dummy/log/*.age 11 | /spec/dummy/tmp 12 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | AllCops: 4 | RunRailsCops: true 5 | Exclude: 6 | - spec/dummy/db/**/* 7 | - spec/dummy/bin/**/* 8 | 9 | # Yes, we have Kind::Of::Very::Long::Class::Names 10 | Metrics/ClassLength: 11 | Max: 170 12 | 13 | # On an 11-inch screen this works perfectly fine. 14 | # Metrics/LineLength: 15 | # Max: 150 16 | 17 | # The trailing comma prevents brain cells from dying on copy-paste errors 18 | Style/TrailingComma: 19 | Enabled: false 20 | 21 | # I'm not that picky. An empty line can even increase readability 22 | Style/EmptyLinesAroundBlockBody: 23 | Enabled: false 24 | Style/EmptyLinesAroundClassBody: 25 | Enabled: false 26 | Style/EmptyLinesAroundModuleBody: 27 | Enabled: false 28 | 29 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by `rubocop --auto-gen-config` 2 | # on 2015-04-17 13:35:21 +0200 using RuboCop version 0.29.1. 3 | # The point is for the user to remove these configuration records 4 | # one by one as the offenses are removed from the code base. 5 | # Note that changes in the inspected code, or installation of new 6 | # versions of RuboCop, may require this file to be generated again. 7 | 8 | # Offense count: 20 9 | Metrics/AbcSize: 10 | Max: 45 11 | 12 | # Offense count: 1 13 | Metrics/CyclomaticComplexity: 14 | Max: 8 15 | 16 | # Offense count: 13 17 | # Configuration parameters: AllowURI, URISchemes. 18 | Metrics/LineLength: 19 | Max: 280 20 | 21 | # Offense count: 14 22 | # Configuration parameters: CountComments. 23 | Metrics/MethodLength: 24 | Max: 28 25 | 26 | # Offense count: 1 27 | # Configuration parameters: CountKeywordArgs. 28 | Metrics/ParameterLists: 29 | Max: 6 30 | 31 | # Offense count: 1 32 | Metrics/PerceivedComplexity: 33 | Max: 8 34 | 35 | # Offense count: 24 36 | Style/Documentation: 37 | Enabled: false 38 | 39 | # Offense count: 1 40 | # Cop supports --auto-correct. 41 | Style/NumericLiterals: 42 | MinDigits: 7 43 | 44 | # Offense count: 1 45 | Style/RescueModifier: 46 | Enabled: false 47 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 2.1.5 3 | cache: bundler 4 | sudo: false 5 | 6 | before_script: 7 | - bundle exec rake db:create 8 | - bundle exec rake db:migrate 9 | 10 | script: 11 | - bundle exec rspec 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.1.0 / Unreleased 2 | 3 | * ... 4 | 5 | # 0.0.2 6 | 7 | * It's alive! 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | sso (0.1.4) 5 | doorkeeper (>= 2.0.0) 6 | httparty 7 | omniauth-oauth2 8 | operation (~> 0.0.3) 9 | signature (>= 0.1.8) 10 | warden (>= 1.2.3) 11 | 12 | GEM 13 | remote: https://rubygems.org/ 14 | specs: 15 | actionmailer (4.2.0) 16 | actionpack (= 4.2.0) 17 | actionview (= 4.2.0) 18 | activejob (= 4.2.0) 19 | mail (~> 2.5, >= 2.5.4) 20 | rails-dom-testing (~> 1.0, >= 1.0.5) 21 | actionpack (4.2.0) 22 | actionview (= 4.2.0) 23 | activesupport (= 4.2.0) 24 | rack (~> 1.6.0) 25 | rack-test (~> 0.6.2) 26 | rails-dom-testing (~> 1.0, >= 1.0.5) 27 | rails-html-sanitizer (~> 1.0, >= 1.0.1) 28 | actionview (4.2.0) 29 | activesupport (= 4.2.0) 30 | builder (~> 3.1) 31 | erubis (~> 2.7.0) 32 | rails-dom-testing (~> 1.0, >= 1.0.5) 33 | rails-html-sanitizer (~> 1.0, >= 1.0.1) 34 | activejob (4.2.0) 35 | activesupport (= 4.2.0) 36 | globalid (>= 0.3.0) 37 | activemodel (4.2.0) 38 | activesupport (= 4.2.0) 39 | builder (~> 3.1) 40 | activerecord (4.2.0) 41 | activemodel (= 4.2.0) 42 | activesupport (= 4.2.0) 43 | arel (~> 6.0) 44 | activesupport (4.2.0) 45 | i18n (~> 0.7) 46 | json (~> 1.7, >= 1.7.7) 47 | minitest (~> 5.1) 48 | thread_safe (~> 0.3, >= 0.3.4) 49 | tzinfo (~> 1.1) 50 | addressable (2.3.7) 51 | arel (6.0.0) 52 | ast (2.0.0) 53 | astrolabe (1.3.0) 54 | parser (>= 2.2.0.pre.3, < 3.0) 55 | builder (3.2.2) 56 | celluloid (0.16.0) 57 | timers (~> 4.0.0) 58 | coderay (1.1.0) 59 | crack (0.4.2) 60 | safe_yaml (~> 1.0.0) 61 | database_cleaner (1.4.0) 62 | diff-lcs (1.2.5) 63 | docile (1.1.5) 64 | doorkeeper (2.1.3) 65 | railties (>= 3.2) 66 | erubis (2.7.0) 67 | factory_girl (4.5.0) 68 | activesupport (>= 3.0.0) 69 | factory_girl_rails (4.5.0) 70 | factory_girl (~> 4.5.0) 71 | railties (>= 3.0.0) 72 | faraday (0.9.1) 73 | multipart-post (>= 1.2, < 3) 74 | ffi (1.9.6) 75 | formatador (0.2.5) 76 | globalid (0.3.3) 77 | activesupport (>= 4.1.0) 78 | guard (2.12.4) 79 | formatador (>= 0.2.4) 80 | listen (~> 2.7) 81 | lumberjack (~> 1.0) 82 | nenv (~> 0.1) 83 | notiffany (~> 0.0) 84 | pry (>= 0.9.12) 85 | shellany (~> 0.0) 86 | thor (>= 0.18.1) 87 | guard-compat (1.2.1) 88 | guard-rspec (4.5.0) 89 | guard (~> 2.1) 90 | guard-compat (~> 1.1) 91 | rspec (>= 2.99.0, < 4.0) 92 | guard-rubocop (1.2.0) 93 | guard (~> 2.0) 94 | rubocop (~> 0.20) 95 | hashie (3.4.0) 96 | hike (1.2.3) 97 | hitimes (1.2.2) 98 | httparty (0.13.3) 99 | json (~> 1.8) 100 | multi_xml (>= 0.5.2) 101 | i18n (0.7.0) 102 | json (1.8.2) 103 | jwt (1.4.1) 104 | listen (2.8.5) 105 | celluloid (>= 0.15.2) 106 | rb-fsevent (>= 0.9.3) 107 | rb-inotify (>= 0.9) 108 | loofah (2.0.1) 109 | nokogiri (>= 1.5.9) 110 | lumberjack (1.0.9) 111 | mail (2.6.3) 112 | mime-types (>= 1.16, < 3) 113 | method_source (0.8.2) 114 | mime-types (2.4.3) 115 | mini_portile (0.6.2) 116 | minitest (5.5.1) 117 | multi_json (1.11.0) 118 | multi_xml (0.5.5) 119 | multipart-post (2.0.0) 120 | nenv (0.2.0) 121 | nokogiri (1.6.6.2) 122 | mini_portile (~> 0.6.0) 123 | notiffany (0.0.6) 124 | nenv (~> 0.1) 125 | shellany (~> 0.0) 126 | oauth2 (1.0.0) 127 | faraday (>= 0.8, < 0.10) 128 | jwt (~> 1.0) 129 | multi_json (~> 1.3) 130 | multi_xml (~> 0.5) 131 | rack (~> 1.2) 132 | omniauth (1.2.2) 133 | hashie (>= 1.2, < 4) 134 | rack (~> 1.0) 135 | omniauth-oauth2 (1.2.0) 136 | faraday (>= 0.8, < 0.10) 137 | multi_json (~> 1.3) 138 | oauth2 (~> 1.0) 139 | omniauth (~> 1.2) 140 | operation (0.0.3) 141 | hashie 142 | parser (2.2.0.3) 143 | ast (>= 1.1, < 3.0) 144 | pg (0.18.1) 145 | powerpack (0.1.0) 146 | pry (0.10.1) 147 | coderay (~> 1.1.0) 148 | method_source (~> 0.8.1) 149 | slop (~> 3.4) 150 | rack (1.6.0) 151 | rack-test (0.6.3) 152 | rack (>= 1.0) 153 | rails (4.2.0) 154 | actionmailer (= 4.2.0) 155 | actionpack (= 4.2.0) 156 | actionview (= 4.2.0) 157 | activejob (= 4.2.0) 158 | activemodel (= 4.2.0) 159 | activerecord (= 4.2.0) 160 | activesupport (= 4.2.0) 161 | bundler (>= 1.3.0, < 2.0) 162 | railties (= 4.2.0) 163 | sprockets-rails 164 | rails-deprecated_sanitizer (1.0.3) 165 | activesupport (>= 4.2.0.alpha) 166 | rails-dom-testing (1.0.5) 167 | activesupport (>= 4.2.0.beta, < 5.0) 168 | nokogiri (~> 1.6.0) 169 | rails-deprecated_sanitizer (>= 1.0.1) 170 | rails-html-sanitizer (1.0.1) 171 | loofah (~> 2.0) 172 | railties (4.2.0) 173 | actionpack (= 4.2.0) 174 | activesupport (= 4.2.0) 175 | rake (>= 0.8.7) 176 | thor (>= 0.18.1, < 2.0) 177 | rainbow (2.0.0) 178 | rake (10.4.2) 179 | rb-fsevent (0.9.4) 180 | rb-inotify (0.9.5) 181 | ffi (>= 0.5.0) 182 | rspec (3.1.0) 183 | rspec-core (~> 3.1.0) 184 | rspec-expectations (~> 3.1.0) 185 | rspec-mocks (~> 3.1.0) 186 | rspec-core (3.1.7) 187 | rspec-support (~> 3.1.0) 188 | rspec-expectations (3.1.2) 189 | diff-lcs (>= 1.2.0, < 2.0) 190 | rspec-support (~> 3.1.0) 191 | rspec-mocks (3.1.3) 192 | rspec-support (~> 3.1.0) 193 | rspec-rails (3.1.0) 194 | actionpack (>= 3.0) 195 | activesupport (>= 3.0) 196 | railties (>= 3.0) 197 | rspec-core (~> 3.1.0) 198 | rspec-expectations (~> 3.1.0) 199 | rspec-mocks (~> 3.1.0) 200 | rspec-support (~> 3.1.0) 201 | rspec-support (3.1.2) 202 | rubocop (0.29.1) 203 | astrolabe (~> 1.3) 204 | parser (>= 2.2.0.1, < 3.0) 205 | powerpack (~> 0.1) 206 | rainbow (>= 1.99.1, < 3.0) 207 | ruby-progressbar (~> 1.4) 208 | ruby-progressbar (1.7.1) 209 | safe_yaml (1.0.4) 210 | shellany (0.0.1) 211 | signature (0.1.8) 212 | simplecov (0.9.2) 213 | docile (~> 1.1.0) 214 | multi_json (~> 1.0) 215 | simplecov-html (~> 0.9.0) 216 | simplecov-html (0.9.0) 217 | slop (3.6.0) 218 | sprockets (2.12.3) 219 | hike (~> 1.2) 220 | multi_json (~> 1.0) 221 | rack (~> 1.0) 222 | tilt (~> 1.1, != 1.3.0) 223 | sprockets-rails (2.2.4) 224 | actionpack (>= 3.0) 225 | activesupport (>= 3.0) 226 | sprockets (>= 2.8, < 4.0) 227 | thor (0.19.1) 228 | thread_safe (0.3.4) 229 | tilt (1.4.1) 230 | timecop (0.7.1) 231 | timers (4.0.1) 232 | hitimes 233 | tzinfo (1.2.2) 234 | thread_safe (~> 0.1) 235 | warden (1.2.3) 236 | rack (>= 1.0) 237 | webmock (1.20.4) 238 | addressable (>= 2.3.6) 239 | crack (>= 0.3.2) 240 | 241 | PLATFORMS 242 | ruby 243 | 244 | DEPENDENCIES 245 | database_cleaner 246 | factory_girl_rails 247 | guard-rspec (>= 4.2.3) 248 | guard-rubocop 249 | pg 250 | rails 251 | rspec-rails 252 | rubocop 253 | simplecov (>= 0.9.0) 254 | sso! 255 | timecop 256 | webmock 257 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | group :red_green_refactor, halt_on_fail: true do 2 | guard :rspec, cmd: 'bundle exec rspec' do 3 | require 'guard/rspec/dsl' 4 | dsl = Guard::RSpec::Dsl.new(self) 5 | 6 | rspec = dsl.rspec 7 | watch(rspec.spec_helper) { rspec.spec_dir } 8 | watch(rspec.spec_support) { rspec.spec_dir } 9 | watch(rspec.spec_files) 10 | 11 | ruby = dsl.ruby 12 | dsl.watch_spec_files_for(ruby.lib_files) 13 | end 14 | 15 | guard :rubocop do 16 | watch(/.+\.rb$/) 17 | watch(/(?:.+\/)?\.rubocop.+\.yml$/) { |m| File.dirname(m[0]) } 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2015 halo 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 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | begin 3 | require 'bundler/setup' 4 | rescue LoadError 5 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks' 6 | end 7 | 8 | Bundler::GemHelper.install_tasks 9 | 10 | # For your convenience, when you're in the root directory of this repository 11 | # running `rake` will proxy you to the `spec/dummy` Rakefile using the test environment. 12 | ENV['RAILS_ENV'] = 'test' 13 | 14 | # Delegate everything to the dummy 15 | APP_RAKEFILE = File.expand_path('../spec/dummy/Rakefile', __FILE__) 16 | load 'rails/tasks/engine.rake' 17 | -------------------------------------------------------------------------------- /coverage/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/coverage/.gitkeep -------------------------------------------------------------------------------- /doc/alpha_passport_modified.mscgen: -------------------------------------------------------------------------------- 1 | msc { 2 | hscale = "1.3"; 3 | firefox [label="Firefox"],alpha [label="Alpha"],bouncer [label="Bouncer"]; 4 | 5 | firefox => alpha [label="GET /some/resource", linecolor="#4682b4", textcolor="#4682b4"]; 6 | alpha => bouncer [label="sign(GET /oauth/sso/v1/passports/aiaiai)", linecolor="#4682b4", textcolor="#4682b4"]; 7 | alpha <<= bouncer [label="200 - Modified Passport", linecolor="#4682b4", textcolor="#4682b4"]; 8 | 9 | } 10 | -------------------------------------------------------------------------------- /doc/alpha_passport_modified.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/alpha_passport_modified.png -------------------------------------------------------------------------------- /doc/alpha_requests_passport_for_emily.mscgen: -------------------------------------------------------------------------------- 1 | msc { 2 | hscale = "1.1"; 3 | alpha [label="Alpha"],bouncer [label="Bouncer"]; 4 | 5 | alpha => bouncer [label="POST /oauth/sso/v1/passports access_token=2t2t2t", linecolor="#4682b4", textcolor="#4682b4"]; 6 | alpha <<= bouncer [label="200 - Passport", linecolor="#4682b4", textcolor="#4682b4"]; 7 | 8 | } 9 | -------------------------------------------------------------------------------- /doc/alpha_requests_passport_for_emily.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/alpha_requests_passport_for_emily.png -------------------------------------------------------------------------------- /doc/alpha_sends_emily_to_login.mscgen: -------------------------------------------------------------------------------- 1 | msc { 2 | firefox [label="Firefox"],alpha [label="Alpha"]; 3 | 4 | firefox <<= alpha [label="302 /oauth/sso", linecolor="#4682b4", textcolor="#4682b4"]; 5 | firefox => alpha [label="GET /oauth/sso", linecolor="#4682b4", textcolor="#4682b4"]; 6 | firefox <<= alpha [label="302 bouncer.dev/oauth/authorize?...", linecolor="#4682b4", textcolor="#4682b4"]; 7 | 8 | } 9 | -------------------------------------------------------------------------------- /doc/alpha_sends_emily_to_login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/alpha_sends_emily_to_login.png -------------------------------------------------------------------------------- /doc/alpha_verification_times_out.mscgen: -------------------------------------------------------------------------------- 1 | msc { 2 | hscale = "1.3"; 3 | firefox [label="Firefox"],alpha [label="Alpha"],bouncer [label="Bouncer"]; 4 | 5 | firefox => alpha [label="GET /another/resource", linecolor="#4682b4", textcolor="#4682b4"]; 6 | alpha -x bouncer [label="sign(GET /oauth/sso/v1/passports/aiaiai)", linecolor="#4682b4", textcolor="#4682b4"]; 7 | ...; 8 | firefox <<= alpha [label="200 - Resource", linecolor="#4682b4", textcolor="#4682b4"]; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /doc/alpha_verification_times_out.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/alpha_verification_times_out.png -------------------------------------------------------------------------------- /doc/alpha_verifies_emilys_passport.mscgen: -------------------------------------------------------------------------------- 1 | msc { 2 | alpha [label="Alpha"],bouncer [label="Bouncer"]; 3 | 4 | |||; 5 | alpha => bouncer [label="sign(GET /oauth/sso/v1/passports/aiaiai)", linecolor="#4682b4", textcolor="#4682b4"]; 6 | alpha <<= bouncer [label="200 - OK", linecolor="#4682b4", textcolor="#4682b4"]; 7 | 8 | } 9 | -------------------------------------------------------------------------------- /doc/alpha_verifies_emilys_passport.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/alpha_verifies_emilys_passport.png -------------------------------------------------------------------------------- /doc/alpha_verifies_iphone_passport.mscgen: -------------------------------------------------------------------------------- 1 | msc { 2 | hscale = "1.1"; 3 | alpha [label="Alpha"],bouncer [label="Bouncer"]; 4 | 5 | |||; 6 | alpha => bouncer [label="sign(GET /oauth/sso/v1/passports/bibibi w/o state)", linecolor="#4682b4", textcolor="#4682b4"]; 7 | alpha <<= bouncer [label="200 - Passport (with user)", linecolor="#4682b4", textcolor="#4682b4"]; 8 | 9 | } 10 | -------------------------------------------------------------------------------- /doc/alpha_verifies_iphone_passport.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/alpha_verifies_iphone_passport.png -------------------------------------------------------------------------------- /doc/alpha_verifies_iphone_passport_again.mscgen: -------------------------------------------------------------------------------- 1 | msc { 2 | alpha [label="Alpha"],bouncer [label="Bouncer"]; 3 | 4 | |||; 5 | alpha => bouncer [label="sign(GET /oauth/sso/v1/passports/bibibi)", linecolor="#4682b4", textcolor="#4682b4"]; 6 | alpha <<= bouncer [label="200 - OK (no data)", linecolor="#4682b4", textcolor="#4682b4"]; 7 | 8 | } 9 | -------------------------------------------------------------------------------- /doc/alpha_verifies_iphone_passport_again.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/alpha_verifies_iphone_passport_again.png -------------------------------------------------------------------------------- /doc/alpha_verifies_iphone_passport_fails.mscgen: -------------------------------------------------------------------------------- 1 | msc { 2 | alpha [label="Alpha"],bouncer [label="Bouncer"]; 3 | 4 | |||; 5 | alpha -x bouncer [label="sign(GET /oauth/sso/v1/passports/bibibi)", linecolor="#4682b4", textcolor="#4682b4"]; 6 | ...; 7 | 8 | } 9 | -------------------------------------------------------------------------------- /doc/alpha_verifies_iphone_passport_fails.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/alpha_verifies_iphone_passport_fails.png -------------------------------------------------------------------------------- /doc/alpha_yields_resource_to_emily.mscgen: -------------------------------------------------------------------------------- 1 | msc { 2 | firefox [label="Firefox"],alpha [label="Alpha"]; 3 | 4 | firefox <<= alpha [label="200 - Resource", linecolor="#4682b4", textcolor="#4682b4"]; 5 | 6 | } 7 | -------------------------------------------------------------------------------- /doc/alpha_yields_resource_to_emily.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/alpha_yields_resource_to_emily.png -------------------------------------------------------------------------------- /doc/beta_comes_into_play.mscgen: -------------------------------------------------------------------------------- 1 | msc { 2 | hscale = "1.2"; 3 | firefox [label="Firefox"],beta [label="Beta"],bouncer [label="Bouncer"]; 4 | 5 | firefox => beta [label="GET /beautiful/resource", linecolor="#4682b4", textcolor="#4682b4"]; 6 | firefox <<= beta [label="302 /oauth/sso", linecolor="#4682b4", textcolor="#4682b4"]; 7 | firefox => beta [label="GET /oauth/sso", linecolor="#4682b4", textcolor="#4682b4"]; 8 | firefox <<= beta [label="302 bouncer.dev/oauth/authorize?...", linecolor="#4682b4", textcolor="#4682b4"]; 9 | firefox => bouncer [label="GET /oauth/authorize?...", linecolor="#4682b4", textcolor="#4682b4"]; 10 | 11 | } 12 | -------------------------------------------------------------------------------- /doc/beta_comes_into_play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/beta_comes_into_play.png -------------------------------------------------------------------------------- /doc/beta_oauth_dance.mscgen: -------------------------------------------------------------------------------- 1 | msc { 2 | hscale = "1.5"; 3 | firefox [label="Firefox"],beta [label="Alpha"],bouncer [label="Bouncer"]; 4 | 5 | |||; 6 | firefox <<= bouncer [label="302 alpha.dev/auth/sso/callback?code=2g2g2g...", linecolor="#4682b4", textcolor="#4682b4"]; 7 | firefox => beta [label="GET /auth/sso/callback?code=2g2g2g...", linecolor="#4682b4", textcolor="#4682b4"]; 8 | beta =>> bouncer [label="POST /oauth/token?code=2g2g2g...", linecolor="#4682b4", textcolor="#4682b4"]; 9 | beta <= bouncer [label="201 - Access Token 4t4t4t...", linecolor="#4682b4", textcolor="#4682b4"]; 10 | beta => bouncer [label="POST /oauth/sso/v1/passports access_token=4t4t4t", linecolor="#4682b4", textcolor="#4682b4"]; 11 | beta <<= bouncer [label="200 - Passport", linecolor="#4682b4", textcolor="#4682b4"]; 12 | firefox <<= beta [label="302 /beatutiful/resource", linecolor="#4682b4", textcolor="#4682b4"]; 13 | firefox => beta [label="GET /beatutiful/resource", linecolor="#4682b4", textcolor="#4682b4"]; 14 | beta => bouncer [label="sign(GET /oauth/sso/v1/passports/aiaiai)", linecolor="#4682b4", textcolor="#4682b4"]; 15 | beta <<= bouncer [label="200 - OK", linecolor="#4682b4", textcolor="#4682b4"]; 16 | firefox <<= beta [label="200 - Resource", linecolor="#4682b4", textcolor="#4682b4"]; 17 | 18 | } 19 | -------------------------------------------------------------------------------- /doc/beta_oauth_dance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/beta_oauth_dance.png -------------------------------------------------------------------------------- /doc/bouncer_sends_to_login_form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/bouncer_sends_to_login_form.png -------------------------------------------------------------------------------- /doc/doorkeeper_hands_out_alpha_access_token.mscgen: -------------------------------------------------------------------------------- 1 | msc { 2 | hscale = "1.2"; 3 | alpha [label="Alpha"],bouncer [label="Bouncer"]; 4 | 5 | |||; 6 | alpha <<= bouncer [label="201 - Access Token 2t2t2t...", linecolor="#4682b4", textcolor="#4682b4"]; 7 | 8 | } 9 | -------------------------------------------------------------------------------- /doc/doorkeeper_hands_out_alpha_access_token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/doorkeeper_hands_out_alpha_access_token.png -------------------------------------------------------------------------------- /doc/doorkeeper_hands_out_alpha_grant.mscgen: -------------------------------------------------------------------------------- 1 | msc { 2 | hscale = "1.5"; 3 | firefox [label="Firefox"],alpha [label="Alpha"],bouncer [label="Bouncer"]; 4 | 5 | |||; 6 | firefox <<= bouncer [label="302 alpha.dev/auth/sso/callback?code=1g1g1g...", linecolor="#4682b4", textcolor="#4682b4"]; 7 | firefox => alpha [label="GET /auth/sso/callback?code=1g1g1g...", linecolor="#4682b4", textcolor="#4682b4"]; 8 | alpha =>> bouncer [label="POST /oauth/token?code=1g1g1g...", linecolor="#4682b4", textcolor="#4682b4"]; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /doc/doorkeeper_hands_out_alpha_grant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/doorkeeper_hands_out_alpha_grant.png -------------------------------------------------------------------------------- /doc/emily_knocks_onto_alphas_door.mscgen: -------------------------------------------------------------------------------- 1 | msc { 2 | firefox [label="Firefox"],alpha [label="Alpha"]; 3 | 4 | firefox => alpha [label="GET /some/resource", linecolor="#4682b4", textcolor="#4682b4"]; 5 | 6 | } 7 | -------------------------------------------------------------------------------- /doc/emily_knocks_onto_alphas_door.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/emily_knocks_onto_alphas_door.png -------------------------------------------------------------------------------- /doc/emily_presents_bouncer_credentials.mscgen: -------------------------------------------------------------------------------- 1 | msc { 2 | emily [label="Emily"],firefox [label="Firefox"],bouncer [label="Bouncer"]; 3 | 4 | emily => bouncer [label="POST /sessions?username=emily&password=p4ssword", linecolor="#4682b4", textcolor="#4682b4"]; 5 | 6 | } 7 | -------------------------------------------------------------------------------- /doc/emily_presents_bouncer_credentials.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/emily_presents_bouncer_credentials.png -------------------------------------------------------------------------------- /doc/emily_wants_beta_resource_again.mscgen: -------------------------------------------------------------------------------- 1 | msc { 2 | firefox [label="Firefox"],alpha [label="Alpha"]; 3 | 4 | firefox <<= alpha [label="302 /some/resource", linecolor="#4682b4", textcolor="#4682b4"]; 5 | firefox => alpha [label="GET /some/resource", linecolor="#4682b4", textcolor="#4682b4"]; 6 | 7 | } 8 | -------------------------------------------------------------------------------- /doc/emily_wants_beta_resource_again.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/emily_wants_beta_resource_again.png -------------------------------------------------------------------------------- /doc/emily_wants_resource_again.mscgen: -------------------------------------------------------------------------------- 1 | msc { 2 | firefox [label="Firefox"],alpha [label="Alpha"]; 3 | 4 | firefox <<= alpha [label="302 /some/resource", linecolor="#4682b4", textcolor="#4682b4"]; 5 | firefox => alpha [label="GET /some/resource", linecolor="#4682b4", textcolor="#4682b4"]; 6 | 7 | } 8 | -------------------------------------------------------------------------------- /doc/emily_wants_resource_again.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/emily_wants_resource_again.png -------------------------------------------------------------------------------- /doc/iphone_comes_into_play.mscgen: -------------------------------------------------------------------------------- 1 | msc { 2 | hscale = "1.1"; 3 | iphone [label="iPhone"],bouncer [label="Bouncer"]; 4 | 5 | iphone => bouncer [label="POST /oauth/token?...", linecolor="#4682b4", textcolor="#4682b4"]; 6 | iphone <<= bouncer [label="201 - Access Token 4t4t4t...", linecolor="#4682b4", textcolor="#4682b4"]; 7 | iphone => bouncer [label="POST /oauth/sso/v1/passports?access_token=5t5t5t", linecolor="#4682b4", textcolor="#4682b4"]; 8 | iphone <<= bouncer [label="200 - Passport", linecolor="#4682b4", textcolor="#4682b4"]; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /doc/iphone_comes_into_play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/iphone_comes_into_play.png -------------------------------------------------------------------------------- /doc/iphone_requests_alpha_resource.mscgen: -------------------------------------------------------------------------------- 1 | msc { 2 | iphone [label="iPhone"],alpha [label="Alpha"]; 3 | 4 | iphone => alpha [label="sign(GET /cool/resource + state + chip)", linecolor="#4682b4", textcolor="#4682b4"]; 5 | 6 | } 7 | -------------------------------------------------------------------------------- /doc/iphone_requests_alpha_resource.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/doc/iphone_requests_alpha_resource.png -------------------------------------------------------------------------------- /lib/sso.rb: -------------------------------------------------------------------------------- 1 | require 'operation' 2 | 3 | require 'sso/logging' 4 | require 'sso/meter' 5 | require 'sso/benchmarking' 6 | require 'sso/configuration' 7 | require 'sso/configure' 8 | 9 | require 'sso/client/omniauth/strategies/sso' 10 | 11 | module SSO 12 | end 13 | -------------------------------------------------------------------------------- /lib/sso/benchmarking.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | # Helper to log results of benchmarks. 3 | module Benchmarking 4 | include ::SSO::Logging 5 | include ::SSO::Meter 6 | 7 | def benchmark(name: nil, metric: nil, &block) 8 | return unless block_given? 9 | result = nil 10 | seconds = Benchmark.realtime do 11 | result = block.call 12 | end 13 | milliseconds = (seconds * 1000).round 14 | debug { "#{name || metric || 'Benchmark'} took #{milliseconds}ms" } 15 | timing key: metric, value: milliseconds if metric 16 | result 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/sso/client.rb: -------------------------------------------------------------------------------- 1 | require 'httparty' 2 | require 'signature' 3 | require 'warden' 4 | 5 | require 'sso' 6 | require 'sso/client/passport' 7 | require 'sso/client/passport_verifier' 8 | require 'sso/client/omniauth/strategies/sso' 9 | require 'sso/client/warden/hooks/after_fetch' 10 | require 'sso/client/authentications/passport' 11 | require 'sso/client/warden/strategies/passport' 12 | -------------------------------------------------------------------------------- /lib/sso/client/README.md: -------------------------------------------------------------------------------- 1 | # Setting up an SSO client 2 | 3 | ## Assumptions 4 | 5 | * You have a rack app (e.g. Rails) 6 | * You are not going to have a database table with users in your OAuth Clients. That information is only available in the [Rails OAuth server](https://github.com/halo/sso/blob/master/lib/sso/server/README.md). 7 | * To avoid implementing your own solutions, you should use `warden.user` to persist your user in the session in the OAuth rails clients. It is no problem to use warden scopes here in the client. 8 | 9 | ## How it works 10 | 11 | #### Trusted OAuth clients 12 | 13 | * A trusted OAuth client, let's call it `Alpha`, uses the `Authorization Code Grant` to obtain an OAuth `access_token` with the OAuth permission scope `insider`. 14 | * The browser of the end user actually "visits" `Bouncer` for the login. That's where the user is persisted into the session. And that's where a passport is created for the user. So basically, through the OAuth server cookie, the SSO session is tied together. As long as it is there, you are logged in (in that browser e.g.). 15 | 16 | #### Unstrusted OAuth clients 17 | 18 | * A public OAuth Client, such as an `iPhone`, uses the `Resource Owner Password Credentials Grant` to exchange the `username` and `password` of the end user for an OAuth `access_token` with the OAuth permission scope `outsider`. 19 | * You exchange the `access_token` for a passport token. That is effectively your API token used to communicate with the OAuth Rails clients. 20 | * The OAuth Rails clients verify that token with the OAuth server at every request. 21 | * In effect, this turns your iPhone app into a Browser, technically not a trusted OAuth Client. 22 | 23 | #### Also good to know 24 | 25 | * If the passport verification request times out (like 100ms), the authentication/authorization of the previous request is assumed to still be valid. 26 | 27 | ## Setup (trusted client) 28 | 29 | #### Add the gem to your Gemfile 30 | 31 | ```ruby 32 | # Gemfile 33 | gem 'sso', require: 'sso/client' 34 | ``` 35 | 36 | #### Make sure you activated the Warden middleware provided by the `warden` gem 37 | 38 | See [the Warden wiki](https://github.com/hassox/warden/wiki/Setup). 39 | However, one thing is special here, you must not store the entire object, but only a reference to the passport. 40 | If you store the entire object, that would be a major security risk and allow for cookie replay attacks. 41 | 42 | ``` 43 | class Warden::SessionSerializer 44 | def serialize(passport) 45 | Redis.set passport.id, passport.to_json 46 | passport.id 47 | end 48 | 49 | def deserialize(passport_id) 50 | json = Redis.get passport_id 51 | SSO::Client::Passport.new JSON.parse(json) 52 | end 53 | end 54 | ``` 55 | 56 | #### Set the URL to the SSO Server 57 | 58 | See [also this piece of code](https://github.com/halo/sso/blob/master/lib/sso/client/omniauth/strategies/sso.rb#L7-L17). 59 | 60 | ```bash 61 | OMNIAUTH_SSO_ENDPOINT="http://server.example.com" 62 | ``` 63 | 64 | #### Setup your login logic 65 | 66 | Rails Example: 67 | 68 | ```ruby 69 | class SessionsController < ApplicationController 70 | delegate :logout, to: :warden 71 | 72 | def new 73 | redirect_to '/auth/sso' 74 | end 75 | 76 | def create 77 | warden.set_user auth_hash.info.to_hash 78 | redirect_to root_path 79 | end 80 | 81 | def destroy 82 | warden.logout 83 | end 84 | 85 | private 86 | 87 | def auth_hash 88 | request.env['omniauth.auth] 89 | end 90 | 91 | def warden 92 | request.env['warden'] 93 | end 94 | 95 | end 96 | ```` 97 | 98 | #### Activate the middleware 99 | 100 | This is done by making use of [Warden callbacks](https://github.com/hassox/warden/wiki/Callbacks). See [this piece of code](https://github.com/halo/sso/blob/master/lib/sso/client/warden/hooks/after_fetch.rb#L18-L22). 101 | 102 | ```ruby 103 | # e.g. config/initializers/warden.rb 104 | # The options are passed on to `::Warden::Manager.after_fetch` 105 | SSO::Client::Warden::Hooks::AfterFetch.activate scope: :vip 106 | `` 107 | #### Profit 108 | 109 | -------------------------------------------------------------------------------- /lib/sso/client/authentications/passport.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Client 3 | module Authentications 4 | # Logic to authenticate a Passport provided by an outsider app to an insider app. 5 | class Passport 6 | include ::SSO::Logging 7 | include ::SSO::Benchmarking 8 | 9 | delegate :params, to: :request 10 | 11 | def initialize(request) 12 | @request = request 13 | end 14 | 15 | def authenticate 16 | debug { 'Performing authentication...' } 17 | result = authenticate! 18 | 19 | if result.success? 20 | debug { 'Authentication succeeded.' } 21 | return result 22 | end 23 | 24 | debug { "The Client Passport authentication failed: #{result.code}" } 25 | Operations.failure :passport_authentication_failed, object: failure_rack_array 26 | end 27 | 28 | private 29 | 30 | attr_reader :request, :passport_id 31 | 32 | def authenticate! 33 | chip_decryption { |failure| return failure } 34 | check_request_signature { |failure| return failure } 35 | passport = retrieve_passport { |failure| return failure } 36 | passport.verified! 37 | 38 | Operations.success :passport_received, object: passport 39 | end 40 | 41 | def retrieve_passport 42 | debug { 'Retrieving Passport from server...' } 43 | if verification.success? && verification.code == :passport_valid_and_modified 44 | passport = verification.object 45 | passport.modified! 46 | 47 | debug { "Successfully retrieved Passport with ID #{passport_id} from server." } 48 | return passport 49 | else 50 | debug { 'Could not obtain Passport from server.' } 51 | yield verification 52 | end 53 | end 54 | 55 | def check_request_signature 56 | debug { "Verifying request signature using Passport secret #{chip_passport_secret.inspect}" } 57 | signature_request.authenticate do |passport_id| 58 | Signature::Token.new passport_id, chip_passport_secret 59 | end 60 | debug { 'Signature looks legit.' } 61 | Operations.success :passport_signature_valid 62 | 63 | rescue ::Signature::AuthenticationError => exception 64 | debug { "The Signature Authentication failed. #{exception.message}" } 65 | yield Operations.failure :invalid_passport_signature 66 | end 67 | 68 | def verifier 69 | ::SSO::Client::PassportVerifier.new verifier_options 70 | end 71 | 72 | def verifier_options 73 | { 74 | passport_id: passport_id, 75 | passport_state: 'refresh', 76 | passport_secret: chip_passport_secret, 77 | user_ip: ip, 78 | user_agent: agent, 79 | device_id: device_id, 80 | } 81 | end 82 | 83 | def verification 84 | @verification ||= verifier.call 85 | end 86 | 87 | def failure_rack_array 88 | payload = { success: false, code: :passport_verification_failed } 89 | [200, { 'Content-Type' => 'application/json' }, [payload.to_json]] 90 | end 91 | 92 | def signature_request 93 | debug { "Verifying signature of #{request.request_method.inspect} #{request.path.inspect} #{request.params.inspect}" } 94 | ::Signature::Request.new request.request_method, request.path, request.params 95 | end 96 | 97 | def passport_id 98 | return @passport_id if @passport_id 99 | signature_request.authenticate do |auth_key| 100 | return @passport_id = auth_key 101 | end 102 | 103 | rescue ::Signature::AuthenticationError 104 | nil 105 | end 106 | 107 | def chip_decryption 108 | debug { "Validating chip decryptability of raw chip #{chip.inspect}" } 109 | yield Operations.failure(:missing_chip, object: params) if chip.blank? 110 | yield Operations.failure(:missing_chip_key) unless chip_key 111 | yield Operations.failure(:missing_chip_iv) unless chip_iv 112 | yield Operations.failure(:chip_does_not_belong_to_passport) unless chip_belongs_to_passport? 113 | Operations.success :here_is_your_chip_plaintext, object: decrypt_chip 114 | 115 | rescue OpenSSL::Cipher::CipherError => exception 116 | yield Operations.failure :chip_decryption_failed, object: exception.message 117 | end 118 | 119 | def decrypt_chip 120 | @decrypt_chip ||= decrypt_chip! 121 | end 122 | 123 | def decrypt_chip! 124 | benchmark(name: 'Passport chip decryption') do 125 | decipher = chip_digest 126 | decipher.decrypt 127 | decipher.key = chip_key 128 | decipher.iv = chip_iv 129 | plaintext = decipher.update(chip_ciphertext) + decipher.final 130 | logger.debug { "Decrypted chip plaintext #{plaintext.inspect} using key #{chip_key.inspect} and iv #{chip_iv.inspect} and ciphertext #{chip_ciphertext.inspect}" } 131 | plaintext 132 | end 133 | end 134 | 135 | def chip_passport_secret 136 | decrypt_chip.to_s.split('|').last 137 | end 138 | 139 | def chip_passport_id 140 | decrypt_chip.to_s.split('|').first 141 | end 142 | 143 | def chip_belongs_to_passport? 144 | unless passport_id 145 | debug { 'Unknown passport_id' } 146 | return false 147 | end 148 | 149 | unless chip_passport_id 150 | debug { 'Unknown passport_id' } 151 | return false 152 | end 153 | 154 | if passport_id.to_s == chip_passport_id 155 | debug { "The chip on passport #{passport_id.inspect} appears to belong to it." } 156 | true 157 | else 158 | info { "The passport with ID #{passport_id.inspect} has a chip with the wrong ID #{chip_passport_id.inspect}" } 159 | false 160 | end 161 | end 162 | 163 | def chip_key 164 | ::SSO.config.passport_chip_key 165 | end 166 | 167 | def user_state_digest 168 | ::OpenSSL::Digest.new 'sha1' 169 | end 170 | 171 | def chip_ciphertext 172 | Base64.decode64 encoded_chip_ciphertext 173 | end 174 | 175 | def encoded_chip_ciphertext 176 | chip_ciphertext_and_iv.first 177 | end 178 | 179 | def chip_iv 180 | Base64.decode64 chip_ciphertext_and_iv.last 181 | end 182 | 183 | def encoded_chip_iv 184 | chip_iv 185 | end 186 | 187 | def chip_ciphertext_and_iv 188 | chip.to_s.split '|' 189 | end 190 | 191 | def chip 192 | params['passport_chip'] 193 | end 194 | 195 | def chip_digest 196 | ::OpenSSL::Cipher::AES256.new :CBC 197 | end 198 | 199 | # TODO: Use ActionDispatch::Request#remote_ip 200 | def ip 201 | request.ip 202 | end 203 | 204 | def agent 205 | request.user_agent 206 | end 207 | 208 | def device_id 209 | request.params['device_id'] 210 | end 211 | 212 | end 213 | end 214 | end 215 | end 216 | -------------------------------------------------------------------------------- /lib/sso/client/omniauth/strategies/sso.rb: -------------------------------------------------------------------------------- 1 | require 'omniauth-oauth2' 2 | 3 | module OmniAuth 4 | module Strategies 5 | class SSO < OmniAuth::Strategies::OAuth2 6 | 7 | def self.endpoint 8 | if ENV['OMNIAUTH_SSO_ENDPOINT'].to_s != '' 9 | ENV['OMNIAUTH_SSO_ENDPOINT'].to_s 10 | elsif development_environment? 11 | ENV['OMNIAUTH_SSO_ENDPOINT'] || 'http://sso.dev:8080' 12 | elsif test_environment? 13 | 'https://sso.example.com' 14 | else 15 | fail 'You must set OMNIAUTH_SSO_ENDPOINT to point to the SSO OAuth server' 16 | end 17 | end 18 | 19 | def self.development_environment? 20 | defined?(Rails) && Rails.env.development? 21 | end 22 | 23 | def self.test_environment? 24 | defined?(Rails) && Rails.env.test? || ENV['RACK_ENV'] == 'test' 25 | end 26 | 27 | def self.passports_path 28 | if ENV['OMNIAUTH_SSO_PASSPORTS_PATH'].to_s != '' 29 | ENV['OMNIAUTH_SSO_PASSPORTS_PATH'].to_s 30 | else 31 | # We know this namespace is not occupied because /oauth is owned by Doorkeeper 32 | '/oauth/sso/v1/passports' 33 | end 34 | end 35 | 36 | option :name, :sso 37 | option :client_options, site: endpoint, authorize_path: '/oauth/authorize' 38 | 39 | uid { raw_info['id'] if raw_info } 40 | 41 | info do 42 | { 43 | # Passport 44 | id: uid, 45 | state: raw_info['state'], 46 | secret: raw_info['secret'], 47 | user: raw_info['user'], 48 | } 49 | end 50 | 51 | def raw_info 52 | params = { ip: request.ip, agent: request.user_agent } 53 | @raw_info ||= access_token.post(self.class.passports_path, params: params).parsed 54 | end 55 | 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/sso/client/passport.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Client 3 | class Passport 4 | 5 | attr_reader :id, :secret, :chip 6 | attr_accessor :state, :user 7 | 8 | def initialize(id:, secret:, state:, user:, chip: nil) 9 | @id = id 10 | @secret = secret 11 | @state = state 12 | @user = user 13 | @chip = chip 14 | end 15 | 16 | def verified! 17 | @verified = true 18 | end 19 | 20 | def verified? 21 | @verified == true 22 | end 23 | 24 | def unverified? 25 | !verified? 26 | end 27 | 28 | def modified! 29 | @modified = true 30 | end 31 | 32 | def modified? 33 | @modified == true 34 | end 35 | 36 | def unmodified? 37 | !modified? 38 | end 39 | 40 | def delta 41 | { state: state, user: user } 42 | end 43 | 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/sso/client/passport_verifier.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Client 3 | class PassportVerifier 4 | include ::SSO::Benchmarking 5 | 6 | attr_reader :passport_id, :passport_state, :passport_secret, :user_ip, :user_agent, :device_id 7 | 8 | def initialize(passport_id:, passport_state:, passport_secret:, user_ip:, user_agent: nil, device_id: nil) 9 | @passport_id = passport_id 10 | @passport_state = passport_state 11 | @passport_secret = passport_secret 12 | @user_ip = user_ip 13 | @user_agent = user_agent 14 | @device_id = device_id 15 | end 16 | 17 | def call 18 | fetch_response { |failure| return failure } 19 | interpret_response 20 | 21 | rescue ::JSON::ParserError 22 | error { 'SSO Server response is not valid JSON.' } 23 | error { response.inspect } 24 | Operations.failure :server_response_not_parseable, object: response 25 | end 26 | 27 | def human_readable_timeout_in_ms 28 | "#{timeout_in_milliseconds}ms" 29 | end 30 | 31 | private 32 | 33 | def fetch_response 34 | yield Operations.failure(:server_unreachable, object: response) unless response.code.to_s == '200' 35 | yield Operations.failure(:server_response_not_parseable, object: response) unless parsed_response 36 | yield Operations.failure(:server_response_missing_success_flag, object: response) unless response_has_success_flag? 37 | Operations.success :server_response_looks_legit 38 | end 39 | 40 | def interpret_response 41 | debug { "Interpreting response code #{response_code.inspect}" } 42 | 43 | case response_code 44 | when :passpord_unmodified then Operations.success(:passport_valid) 45 | when :passport_changed then Operations.success(:passport_valid_and_modified, object: received_passport) 46 | when :passport_invalid then Operations.failure(:passport_invalid) 47 | else Operations.failure(:unexpected_server_response_status, object: response) 48 | end 49 | end 50 | 51 | def response_code 52 | return :unknown_response_code if parsed_response['code'].to_s == '' 53 | parsed_response['code'].to_s.to_sym 54 | end 55 | 56 | def received_passport 57 | ::SSO::Client::Passport.new received_passport_attributes 58 | 59 | rescue ArgumentError 60 | error { "Could not instantiate Passport from serialized response #{received_passport_attributes.inspect}" } 61 | raise 62 | end 63 | 64 | def received_passport_attributes 65 | attributes = parsed_response['passport'] 66 | attributes.keys.each do |key| 67 | attributes[(key.to_sym rescue key) || key] = attributes.delete(key) 68 | end 69 | attributes 70 | end 71 | 72 | def params 73 | result = { ip: user_ip, agent: user_agent, device_id: device_id, state: passport_state } 74 | result.merge! insider_id: insider_id, insider_signature: insider_signature 75 | result 76 | end 77 | 78 | def insider_id 79 | ::SSO.config.oauth_client_id 80 | end 81 | 82 | def insider_secret 83 | ::SSO.config.oauth_client_secret 84 | end 85 | 86 | def insider_signature 87 | ::OpenSSL::HMAC.hexdigest signature_digest, insider_secret, user_ip 88 | end 89 | 90 | def signature_digest 91 | OpenSSL::Digest.new 'sha1' 92 | end 93 | 94 | def token 95 | Signature::Token.new passport_id, passport_secret 96 | end 97 | 98 | def signature_request 99 | Signature::Request.new('GET', path, params) 100 | end 101 | 102 | def auth_hash 103 | signature_request.sign token 104 | end 105 | 106 | def timeout_in_milliseconds 107 | ::SSO.config.passport_verification_timeout_ms.to_i 108 | end 109 | 110 | def timeout_in_seconds 111 | (timeout_in_milliseconds / 1000).round 2 112 | end 113 | 114 | # TODO: Needs to be configurable 115 | def path 116 | OmniAuth::Strategies::SSO.passports_path 117 | end 118 | 119 | def base_endpoint 120 | OmniAuth::Strategies::SSO.endpoint 121 | end 122 | 123 | def endpoint 124 | URI.join(base_endpoint, path).to_s 125 | end 126 | 127 | def query_params 128 | params.merge auth_hash 129 | end 130 | 131 | def response 132 | @response ||= response! 133 | end 134 | 135 | def response! 136 | debug { "Fetching Passport from #{endpoint.inspect}" } 137 | benchmark(name: 'Passport verification request', metric: 'client.passport.verification.duration') do 138 | ::HTTParty.get endpoint, timeout: timeout_in_seconds, query: query_params, headers: { 'Accept' => 'application/json' } 139 | end 140 | end 141 | 142 | def parsed_response 143 | response.parsed_response 144 | end 145 | 146 | def response_has_success_flag? 147 | parsed_response && parsed_response.respond_to?(:key?) && (parsed_response.key?('success') || parsed_response.key?(:success)) 148 | end 149 | 150 | end 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /lib/sso/client/warden/hooks/after_fetch.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Client 3 | module Warden 4 | module Hooks 5 | # This is a helpful `Warden::Manager.after_fetch` hook for Alpha and Beta. 6 | # Whenever Carol is fetched out of the session, we also verify her passport. 7 | # 8 | # Usage: 9 | # 10 | # SSO::Client::Warden::Hooks::AfterFetch.activate scope: :vip 11 | # 12 | class AfterFetch 13 | include ::SSO::Logging 14 | include ::SSO::Benchmarking 15 | include ::SSO::Meter 16 | 17 | attr_reader :passport, :warden, :options 18 | delegate :request, to: :warden 19 | delegate :params, to: :request 20 | 21 | def self.activate(warden_options) 22 | ::Warden::Manager.after_fetch(warden_options) do |passport, warden, options| 23 | ::SSO::Client::Warden::Hooks::AfterFetch.new(passport: passport, warden: warden, options: options).call 24 | end 25 | end 26 | 27 | def initialize(passport:, warden:, options:) 28 | @passport, @warden, @options = passport, warden, options 29 | end 30 | 31 | def call 32 | return unless passport.is_a?(::SSO::Client::Passport) 33 | verify 34 | 35 | rescue ::Timeout::Error 36 | error { 'SSO Server timed out. Continuing with last known authentication/authorization...' } 37 | meter :timeout, timeout_ms: verifier.human_readable_timeout_in_ms 38 | Operations.failure :server_request_timed_out 39 | 40 | rescue => exception 41 | ::SSO.config.exception_handler.call exception 42 | Operations.failure :client_exception_caught 43 | end 44 | 45 | private 46 | 47 | def verifier 48 | ::SSO::Client::PassportVerifier.new passport_id: passport.id, passport_state: passport.state, passport_secret: passport.secret, user_ip: ip, user_agent: agent, device_id: device_id 49 | end 50 | 51 | def verification 52 | @verification ||= verifier.call 53 | end 54 | 55 | def verification_code 56 | verification.code 57 | end 58 | 59 | def verification_object 60 | verification.object 61 | end 62 | 63 | def verify 64 | debug { "Validating Passport #{passport.id.inspect} of logged in #{passport.user.class} in scope #{warden_scope.inspect}" } 65 | 66 | case verification_code 67 | when :server_unreachable then server_unreachable! 68 | when :server_response_not_parseable then server_response_not_parseable! 69 | when :server_response_missing_success_flag then server_response_missing_success_flag! 70 | when :passport_valid then passport_valid! 71 | when :passport_valid_and_modified then passport_valid_and_modified!(verification.object) 72 | when :passport_invalid then passport_invalid! 73 | else unexpected_server_response_status! 74 | end 75 | end 76 | 77 | def passport_valid_and_modified!(modified_passport) 78 | debug { 'Valid passport, but state changed' } 79 | passport.verified! 80 | passport.modified! 81 | passport.user = modified_passport.user 82 | passport.state = modified_passport.state 83 | meter :valid_and_modified 84 | Operations.success :valid_and_modified 85 | end 86 | 87 | def passport_valid! 88 | debug { 'Valid passport, no changes' } 89 | passport.verified! 90 | meter :valid 91 | Operations.success :valid 92 | end 93 | 94 | def passport_invalid! 95 | info { 'Your Passport is not valid any more.' } 96 | warden.logout warden_scope 97 | meter :invalid 98 | Operations.failure :invalid 99 | end 100 | 101 | def server_unreachable! 102 | error { "SSO Server responded with an unexpected HTTP status code (#{verification_code.inspect} instead of 200). #{verification_object.inspect}" } 103 | meter :server_unreachable 104 | Operations.failure :server_unreachable 105 | end 106 | 107 | def server_response_missing_success_flag! 108 | error { 'SSO Server response did not include the expected success flag.' } 109 | meter :server_response_missing_success_flag 110 | Operations.failure :server_response_missing_success_flag 111 | end 112 | 113 | def unexpected_server_response_status! 114 | error { "SSO Server response did not include a known passport status code. #{verification_code.inspect}" } 115 | meter :unexpected_server_response_status 116 | Operations.failure :unexpected_server_response_status 117 | end 118 | 119 | def server_response_not_parseable! 120 | error { 'SSO Server response could not be parsed at all.' } 121 | meter :server_response_not_parseable 122 | Operations.failure :server_response_not_parseable 123 | end 124 | 125 | def meter(key, data = {}) 126 | metrics = {} 127 | metrics[:key] = "client.warden.hooks.after_fetch.#{key}" 128 | metrics[:tags] = { scope: warden_scope } 129 | data[:passport_id] = passport.id 130 | metrics[:data] = data 131 | track metrics 132 | end 133 | 134 | # TODO: Use ActionDispatch remote IP or you might get the Load Balancer's IP instead :( 135 | def ip 136 | request.ip 137 | end 138 | 139 | def agent 140 | request.user_agent 141 | end 142 | 143 | def device_id 144 | params['device_id'] 145 | end 146 | 147 | def warden_scope 148 | options[:scope] 149 | end 150 | 151 | end 152 | end 153 | end 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /lib/sso/client/warden/strategies/passport.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Client 3 | module Warden 4 | module Strategies 5 | # When the iPhone presents a Passport to Alpha, this is how Alpha verifies it with Bouncer. 6 | class Passport < ::Warden::Strategies::Base 7 | include ::SSO::Logging 8 | include ::SSO::Benchmarking 9 | 10 | def valid? 11 | params['auth_version'].to_s != '' && params['state'] != '' 12 | end 13 | 14 | def authenticate! 15 | debug { 'Authenticating from Passport...' } 16 | 17 | authentication = passport_authentication 18 | track key: 'client.warden.strategies.passport.authentication', tags: { scope: scope } 19 | 20 | if authentication.success? 21 | debug { 'Authentication on Client from Passport successful.' } 22 | debug { "Persisting trusted Passport #{authentication.object.inspect}" } 23 | track key: "client.warden.strategies.passport.#{authentication.code}", tags: { scope: scope } 24 | success! authentication.object 25 | else 26 | debug { 'Authentication from Passport on Client failed.' } 27 | debug { "Responding with #{authentication.object.inspect}" } 28 | track key: "client.warden.strategies.passport.#{authentication.code}", tags: { scope: scope } 29 | custom! authentication.object 30 | end 31 | 32 | rescue => exception 33 | ::SSO.config.exception_handler.call exception 34 | end 35 | 36 | def passport_authentication 37 | benchmark(name: 'Passport proxy verification request', metric: 'client.passport.proxy_verification.duration') do 38 | ::SSO::Client::Authentications::Passport.new(request).authenticate 39 | end 40 | end 41 | 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/sso/configuration.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | 3 | module SSO 4 | class Configuration 5 | include ::SSO::Logging 6 | 7 | # Server 8 | 9 | def human_readable_location_for_ip 10 | @human_readable_location_for_ip || default_human_readable_location_for_ip 11 | end 12 | attr_writer :human_readable_location_for_ip 13 | 14 | def user_state_base 15 | @user_state_base || fail('You need to configure user_state_base, see SSO::Configuration for more info.') 16 | end 17 | attr_writer :user_state_base 18 | 19 | def find_user_for_passport 20 | @find_user_for_passport || fail('You need to configure find_user_for_passport, see SSO::Configuration for more info.') 21 | end 22 | attr_writer :find_user_for_passport 23 | 24 | def user_state_key 25 | @user_state_key || fail('You need to configure a secret user_state_key, see SSO::Configuration for more info.') 26 | end 27 | attr_writer :user_state_key 28 | 29 | # Client 30 | 31 | def session_backend 32 | @session_backend || default_session_backend 33 | end 34 | attr_writer :session_backend 35 | 36 | def passport_verification_timeout_ms 37 | @passport_verification_timeout_ms || default_passport_verification_timeout_ms 38 | end 39 | attr_writer :passport_verification_timeout_ms 40 | 41 | def oauth_client_id 42 | @oauth_client_id || fail('You need to configure the oauth_client_id, see SSO::Configuration for more info.') 43 | end 44 | attr_writer :oauth_client_id 45 | 46 | def oauth_client_secret 47 | @oauth_client_secret || fail('You need to configure the oauth_client_secret, see SSO::Configuration for more info.') 48 | end 49 | attr_writer :oauth_client_secret 50 | 51 | # Both 52 | 53 | def exception_handler 54 | @exception_handler || default_exception_handler 55 | end 56 | attr_writer :exception_handler 57 | 58 | def metric 59 | @metric || default_metric 60 | end 61 | attr_writer :metric 62 | 63 | def passport_chip_key 64 | @passport_chip_key || fail('You need to configure a secret passport_chip_key, see SSO::Configuration for more info.') 65 | end 66 | attr_writer :passport_chip_key 67 | 68 | def logger 69 | @logger ||= default_logger 70 | end 71 | attr_writer :logger 72 | 73 | def environment 74 | @environment ||= default_environment 75 | end 76 | 77 | def environment=(new_environment) 78 | @environment = new_environment.to_s 79 | end 80 | 81 | private 82 | 83 | def default_logger 84 | return ::Rails.logger if defined?(::Rails) 85 | instance = ::Logger.new STDOUT 86 | instance.level = default_log_level 87 | instance 88 | end 89 | 90 | def default_log_level 91 | case environment 92 | when 'production' then ::Logger::WARN 93 | when 'test' then ::Logger::UNKNOWN 94 | else ::Logger::DEBUG 95 | end 96 | end 97 | 98 | def default_environment 99 | return ::Rails.env if defined?(::Rails) 100 | return ENV['RACK_ENV'].to_s if ENV['RACK_ENV'].to_s != '' 101 | 'unknown' 102 | end 103 | 104 | def default_exception_handler 105 | proc do |exception| 106 | return unless ::SSO.config.logger 107 | ::SSO.config.logger.error(self.class) do 108 | "An internal error occurred #{exception.class.name} #{exception.message} #{exception.backtrace[0..5].join(' ') if exception.backtrace}" 109 | end 110 | end 111 | end 112 | 113 | def default_human_readable_location_for_ip 114 | proc do 115 | 'Unknown' 116 | end 117 | end 118 | 119 | def default_metric 120 | proc do |type:, key:, value:, tags:, data:| 121 | debug { "Measuring #{type.inspect} with key #{key.inspect} and value #{value.inspect} and tags #{tags} and data #{data}" } 122 | end 123 | end 124 | 125 | def default_session_backend 126 | fail('You need to configure session_backend, see SSO::Configuration for more info.') unless %w(development test).include?(environment) 127 | end 128 | 129 | def default_passport_verification_timeout_ms 130 | 100 131 | end 132 | 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/sso/configure.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | 3 | # Public: Lazy-loads and returns the the configuration instance. 4 | # 5 | def self.config 6 | @config ||= ::SSO::Configuration.new 7 | end 8 | 9 | # Public: Yields the configuration instance. 10 | # 11 | def self.configure 12 | yield config 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /lib/sso/logging.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | # One thing tha bugs me is when I cannot see which part of the code caused a log message. 3 | # This mixin will include the current class name as Logger `progname` so you can show that it in your logfiles. 4 | # 5 | module Logging 6 | 7 | def debug(&block) 8 | logger && logger.debug(progname, &block) 9 | end 10 | 11 | def info(&block) 12 | logger && logger.info(progname, &block) 13 | end 14 | 15 | def warn(&block) 16 | logger && logger.warn(progname, &block) 17 | end 18 | 19 | def error(&block) 20 | logger && logger.error(progname, &block) 21 | end 22 | 23 | def fatal(&block) 24 | logger && logger.fatal(progname, &block) 25 | end 26 | 27 | def progname 28 | self.class.name 29 | end 30 | 31 | def logger 32 | ::SSO.config.logger 33 | end 34 | 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/sso/meter.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Meter 3 | include ::SSO::Logging 4 | 5 | def track(key:, value: 1, tags: nil, data: {}) 6 | data[:caller] = caller_name 7 | debug { "Measuring increment #{key.inspect} with value #{value.inspect} and tags #{tags.inspect} and data #{data.inspect}" } 8 | metric.call type: :increment, key: "sso.#{key}", value: value, tags: tags, data: data 9 | 10 | rescue => exception 11 | ::SSO.config.exception_handler.call exception 12 | end 13 | 14 | def timing(key:, value:, tags: nil, data: {}) 15 | data[:caller] = caller_name 16 | debug { "Measuring timing #{key.inspect} with value #{value.inspect} and tags #{tags.inspect} and data #{data.inspect}" } 17 | metric.call type: :timing, key: "sso.#{key}", value: value, tags: tags, data: data 18 | 19 | rescue => exception 20 | ::SSO.config.exception_handler.call exception 21 | end 22 | 23 | def caller_name 24 | self.class.name 25 | end 26 | 27 | def metric 28 | ::SSO.config.metric 29 | end 30 | 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/sso/server.rb: -------------------------------------------------------------------------------- 1 | require 'rails' # <- Doorkeeper secretly depends on this 2 | require 'doorkeeper' 3 | require 'operation' 4 | require 'httparty' 5 | require 'omniauth' 6 | require 'signature' 7 | require 'warden' 8 | 9 | require 'sso' 10 | require 'sso/server/errors' 11 | require 'sso/server/passport' 12 | require 'sso/server/passports' 13 | require 'sso/server/passports/activity' 14 | require 'sso/server/engine' 15 | 16 | require 'sso/server/authentications/passport' 17 | require 'sso/server/middleware/passport_verification' 18 | require 'sso/server/middleware/passport_destruction' 19 | require 'sso/server/middleware/passport_exchange' 20 | 21 | require 'sso/server/warden/hooks/after_authentication' 22 | require 'sso/server/warden/hooks/before_logout' 23 | require 'sso/server/warden/strategies/passport' 24 | 25 | require 'sso/server/doorkeeper/resource_owner_authenticator' 26 | require 'sso/server/doorkeeper/grant_marker' 27 | require 'sso/server/doorkeeper/access_token_marker' 28 | -------------------------------------------------------------------------------- /lib/sso/server/README.md: -------------------------------------------------------------------------------- 1 | # Setting up an SSO server 2 | 3 | ### Assumptions 4 | 5 | * You use doorkeeper as a Rails OAuth server. 6 | * You want to provide single-sign-on for the end-users. 7 | * All OAuth clients ("consumers") are developed by you. You have full control over them and can automatically trust them (i.e. you can set `skip_authorization { true }` in your doorkeeper.rb initializer). This makes sense, because why would you ask the end-user for permission to login to another subsystem of your SSO world? The whole idea with SSO is that your users don't need to notice switching between the OAuth clients. 8 | * The SSO session is to be browser-wide and app-wide. If you click on "login" you will be logged in on every client web app in that browser. If you click on "logout" you will be logged out of every client web app in that browser. 9 | * You use warden to login at the SSO server, it is, however, **not** okay to use scopes here. That's an assumption which makes this gem dramatically more simple and I didn't find a downside yet (Warden scopes are not really an ideal authorization solution anyway). 10 | 11 | ### Setup 12 | 13 | For now, see [these point of interests](https://github.com/halo/sso/search?q=POI) to see how exactly a rails app can be setup. Other than that, I'll try to give you an overview here. 14 | 15 | First, you'll need to make sure you're using the Warden Rack middleware. 16 | It's entirely up to you to configure that, but it will probably look something like this if you're using Rails: 17 | 18 | ```ruby 19 | # config/application.rb 20 | config.middleware.insert_after ::ActionDispatch::Flash, '::Warden::Manager' do |manager| 21 | manager.failure_app = SessionsController.action :new 22 | manager.intercept_401 = false 23 | 24 | manager.serialize_into_session(&:id) 25 | manager.serialize_from_session { |id| User.find_by_id(id) } 26 | end 27 | ``` 28 | 29 | Next, you might want to use the middleware provided by this gem. 30 | They won't be loaded automatically, so you have to pick the ones you choose to use. 31 | 32 | ```ruby 33 | # config/application.rb 34 | 35 | # These two augment passports with the related outgoing access tokens 36 | config.middleware.insert_after ::Warden::Manager, ::SSO::Server::Doorkeeper::AccessTokenMarker 37 | config.middleware.insert_after ::Warden::Manager, ::SSO::Server::Doorkeeper::GrantMarker 38 | 39 | # This one responds to incoming passport verification requests. 40 | config.middleware.insert_after ::Warden::Manager, ::SSO::Server::Middleware::PassportVerification 41 | 42 | # This is a little more experimental at the moment, 43 | # Provided an Access Token, you can create Passports. 44 | # This is most likely needed if you use the iPhone client. 45 | config.middleware.insert_after ::Warden::Manager, ::SSO::Server::Middleware::PassportCreation 46 | ``` 47 | -------------------------------------------------------------------------------- /lib/sso/server/authentications/passport.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Server 3 | module Authentications 4 | class Passport 5 | include ::SSO::Logging 6 | 7 | def initialize(request) 8 | @request = request 9 | end 10 | 11 | def authenticate 12 | result = authenticate! 13 | 14 | if result.success? 15 | result 16 | else 17 | # TODO: Prevent Flooding here. 18 | debug { "The Server Passport authentication failed: #{result.code}" } 19 | Operations.failure :passport_authentication_failed, object: failure_rack_array 20 | end 21 | end 22 | 23 | private 24 | 25 | attr_reader :request 26 | 27 | def authenticate! 28 | check_arguments { |failure| return failure } 29 | 30 | unless valid_signature? 31 | warn { 'I found the corresponding passport, but the request was not properly signed with it.' } 32 | return Operations.failure :invalid_signature, object: failure_rack_array 33 | end 34 | 35 | debug { 'The request was properly signed, I found the corresponding passport. Updating activity...' } 36 | update_passport 37 | debug { 'Attaching user to passport' } 38 | passport.load_user! 39 | 40 | if passport.state == state 41 | debug { "The current user state #{passport.state.inspect} did not change." } 42 | Operations.success :signature_approved_no_changes, object: success_same_state_rack_array 43 | else 44 | debug { "The current user state #{passport.state.inspect} does not match the provided state #{state.inspect}" } 45 | Operations.success :signature_approved_state_changed, object: success_new_state_rack_array 46 | end 47 | end 48 | 49 | def check_arguments 50 | debug { 'Checking arguments...' } 51 | yield Operations.failure :missing_verb if verb.blank? 52 | yield Operations.failure :missing_passport_id if passport_id.blank? 53 | yield Operations.failure :missing_state if state.blank? 54 | yield Operations.failure :passport_not_found if passport.blank? 55 | yield Operations.failure :passport_revoked if passport.invalid? 56 | debug { 'Arguments are fine.' } 57 | Operations.success :arguments_are_valid 58 | end 59 | 60 | def success_new_state_rack_array 61 | payload = { success: true, code: :passport_changed, passport: passport.export } 62 | [200, { 'Content-Type' => 'application/json' }, [payload.to_json]] 63 | end 64 | 65 | def success_same_state_rack_array 66 | payload = { success: true, code: :passpord_unmodified } 67 | [200, { 'Content-Type' => 'application/json' }, [payload.to_json]] 68 | end 69 | 70 | # You might be wondering why we don't simply return a 401 or 404 status code. 71 | # The reason is that the receiving end would have no way to determine whether that reply is due to a 72 | # nginx configuration error or because the passport is actually invalid. We don't want to revoke 73 | # all passports simply because a load balancer is pointing to the wrong Rails application or something. 74 | # 75 | def failure_rack_array 76 | payload = { success: false, code: :passport_invalid } 77 | [200, { 'Content-Type' => 'application/json' }, [payload.to_json]] 78 | end 79 | 80 | def passport 81 | @passport ||= passport! 82 | end 83 | 84 | def passport! 85 | record = backend.find_by_id(passport_id) 86 | return unless record 87 | debug { "Successfully loaded Passport #{passport_id} from database." } 88 | record 89 | end 90 | 91 | def passport_id 92 | signature_request.authenticate { |passport_id| return passport_id } 93 | 94 | rescue Signature::AuthenticationError 95 | nil 96 | end 97 | 98 | def valid_signature? 99 | debug { 'Checking request signature...' } 100 | signature_request.authenticate { Signature::Token.new passport_id, passport.secret } 101 | true 102 | rescue Signature::AuthenticationError 103 | debug { 'It looks like the API signature for the passport verification was incorrect.' } 104 | false 105 | end 106 | 107 | def signature_request 108 | @signature_request ||= Signature::Request.new verb, path, params 109 | end 110 | 111 | def update_passport 112 | ::SSO::Server::Passports.update_activity passport_id: passport.id, request: request 113 | end 114 | 115 | def verb 116 | request.request_method 117 | end 118 | 119 | def path 120 | request.path 121 | end 122 | 123 | def params 124 | request.params 125 | end 126 | 127 | def state 128 | params['state'] 129 | end 130 | 131 | def backend 132 | ::SSO::Server::Passport 133 | end 134 | 135 | end 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /lib/sso/server/doorkeeper/access_token_marker.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Server 3 | module Doorkeeper 4 | class AccessTokenMarker 5 | include ::SSO::Logging 6 | 7 | attr_reader :request, :response 8 | delegate :params, to: :request 9 | 10 | def initialize(app) 11 | @app = app 12 | end 13 | 14 | def call(env) 15 | @env = env 16 | @request = ::ActionDispatch::Request.new @env 17 | @response = @app.call @env 18 | 19 | return response unless applicable? 20 | 21 | if authorization_grant_flow? 22 | handle_authorization_grant_flow 23 | elsif password_flow? 24 | handle_password_flow 25 | else 26 | fail NotImplementedError 27 | end 28 | 29 | response 30 | end 31 | 32 | def applicable? 33 | request.method == 'POST' && 34 | (authorization_grant_flow? || password_flow?) && 35 | response_code == 200 && 36 | response_body && 37 | outgoing_access_token 38 | end 39 | 40 | def handle_authorization_grant_flow 41 | # We cannot rely on looking up session[:passport_id] here because the end-user might have cookies disabled. 42 | # The only thing we can really rely on to identify the Passport is the incoming grant token. 43 | debug { %(Detected outgoing "Access Token" #{outgoing_access_token.inspect} of the "Authorization Code Grant" flow) } 44 | debug { %(This Access Token belongs to "Authorization Grant Token" #{grant_token.inspect}. Augmenting related Passport with it...) } 45 | registration = ::SSO::Server::Passports.register_access_token_from_grant grant_token: grant_token, access_token: outgoing_access_token 46 | 47 | return if registration.success? 48 | warn { 'The passport could not be augmented via the authorizaton grant. Destroying warden session.' } 49 | warden.logout 50 | end 51 | 52 | def handle_password_flow 53 | local_passport_id = session[:passport_id] # <- We know this always exists because it was set in this very response 54 | debug { %(Detected outgoing "Access Token" #{outgoing_access_token.inspect} of the "Resource Owner Password Credentials Grant" flow.) } 55 | debug { %(Augmenting local Passport #{local_passport_id.inspect} with this outgoing Access Token...) } 56 | registration = ::SSO::Server::Passports.register_access_token_from_id passport_id: local_passport_id, access_token: outgoing_access_token 57 | 58 | return if registration.success? 59 | warn { 'The passport could not be augmented via the access token. Destroying warden session.' } 60 | warden.logout 61 | end 62 | 63 | def response_body 64 | response.last.first.presence 65 | end 66 | 67 | def response_code 68 | response.first 69 | end 70 | 71 | def parsed_response_body 72 | return unless response_body 73 | ::JSON.parse response_body 74 | rescue JSON::ParserError => exception 75 | Trouble.notify exception 76 | nil 77 | end 78 | 79 | def outgoing_access_token 80 | return unless parsed_response_body 81 | parsed_response_body['access_token'] 82 | end 83 | 84 | def warden 85 | request.env['warden'] 86 | end 87 | 88 | def authorization_grant_flow? 89 | grant_token.present? 90 | end 91 | 92 | def password_flow? 93 | grant_type == 'password' 94 | end 95 | 96 | def grant_token 97 | params['code'] 98 | end 99 | 100 | def grant_type 101 | params['grant_type'] 102 | end 103 | 104 | def session 105 | @env['rack.session'] 106 | end 107 | 108 | end 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/sso/server/doorkeeper/grant_marker.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Server 3 | module Doorkeeper 4 | class GrantMarker 5 | include ::SSO::Logging 6 | 7 | attr_reader :response 8 | delegate :session, to: :request 9 | 10 | def initialize(app) 11 | @app = app 12 | end 13 | 14 | def call(env) 15 | @env = env 16 | @response = @app.call @env 17 | 18 | return response unless outgoing_grant_token 19 | 20 | if passport_id 21 | debug { %(Detected outgoing "Authorization Grant Token" #{outgoing_grant_token.inspect} of the "Authorization Code Grant" flow.) } 22 | debug { %(Augmenting Passport #{passport_id.inspect} with this outgoing Grant Token...) } 23 | registration = ::SSO::Server::Passports.register_authorization_grant passport_id: passport_id, token: outgoing_grant_token 24 | 25 | if registration.failure? 26 | warn { 'The passport could not be augmented. Destroying warden session.' } 27 | warden.logout 28 | end 29 | end 30 | 31 | response 32 | end 33 | 34 | def request 35 | ::ActionDispatch::Request.new @env 36 | end 37 | 38 | def code 39 | response.first 40 | end 41 | 42 | def warden 43 | request.env['warden'] 44 | end 45 | 46 | def passport_id 47 | session['passport_id'] 48 | end 49 | 50 | def location_header 51 | unless code == 302 52 | # debug { "Uninteresting response, because it is not a redirect" } 53 | return 54 | end 55 | 56 | response.second['Location'] 57 | end 58 | 59 | def redirect_uri 60 | unless location_header 61 | # debug { "Uninteresting response, because there is no Location header" } 62 | return 63 | end 64 | 65 | ::URI.parse location_header 66 | end 67 | 68 | def redirect_uri_params 69 | return unless redirect_uri 70 | ::Rack::Utils.parse_query redirect_uri.query 71 | end 72 | 73 | def outgoing_grant_token 74 | unless redirect_uri_params && redirect_uri_params['code'] 75 | # debug { "Uninteresting response, because there is no code parameter sent" } 76 | return 77 | end 78 | 79 | redirect_uri_params['code'] 80 | end 81 | 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/sso/server/doorkeeper/resource_owner_authenticator.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Server 3 | module Doorkeeper 4 | class ResourceOwnerAuthenticator 5 | include ::SSO::Logging 6 | 7 | attr_reader :controller 8 | 9 | def self.to_proc 10 | proc { ::SSO::Server::Doorkeeper::ResourceOwnerAuthenticator.new(controller: self).call } 11 | end 12 | 13 | def initialize(controller:) 14 | @controller = controller 15 | end 16 | 17 | def call 18 | debug { 'Detected "Authorization Code Grant" flow. Checking resource owner authentication...' } 19 | 20 | unless warden 21 | fail ::SSO::Server::Errors::WardenMissing, 'Please use the Warden middleware.' 22 | end 23 | 24 | if current_user 25 | debug { "Yes, User with ID #{current_user.inspect} has a session." } 26 | current_user 27 | else 28 | debug { 'No, no User is logged in right now. Initializing authentication procedure...' } 29 | warden.authenticate! :password 30 | end 31 | end 32 | 33 | def warden 34 | controller.request.env['warden'] 35 | end 36 | 37 | def current_user 38 | warden.user 39 | end 40 | 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/sso/server/engine.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | class Engine < ::Rails::Engine 3 | isolate_namespace SSO 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/sso/server/errors.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Server 3 | module Errors 4 | 5 | Error = Class.new(StandardError) 6 | 7 | WardenMissing = Class.new(Error) 8 | 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/sso/server/middleware/passport_destruction.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Server 3 | module Middleware 4 | class PassportDestruction 5 | include ::SSO::Logging 6 | 7 | def initialize(app) 8 | @app = app 9 | end 10 | 11 | def call(env) 12 | request = Rack::Request.new(env) 13 | 14 | unless request.delete? && request.path.start_with?(passports_path) 15 | debug { "I'm not interested in this #{request.request_method.inspect} request to #{request.path.inspect} I only care for DELETE #{passports_path.inspect}" } 16 | return @app.call(env) 17 | end 18 | 19 | passport_id = request.path.to_s.split('/').last 20 | revocation = ::SSO::Server::Passports.logout passport_id: passport_id 21 | env['warden'].logout 22 | 23 | payload = { success: true, code: revocation.code } 24 | debug { "Revoked Passport with ID #{passport_id.inspect}" } 25 | 26 | [200, { 'Content-Type' => 'application/json' }, [payload.to_json]] 27 | end 28 | 29 | def json_code(code) 30 | [200, { 'Content-Type' => 'application/json' }, [{ success: false, code: code }.to_json]] 31 | end 32 | 33 | def passports_path 34 | OmniAuth::Strategies::SSO.passports_path 35 | end 36 | 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/sso/server/middleware/passport_exchange.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Server 3 | module Middleware 4 | # Hands out the Passport when presented with the corresponding Access Token. 5 | # 6 | class PassportExchange 7 | include ::SSO::Logging 8 | 9 | def initialize(app) 10 | @app = app 11 | end 12 | 13 | def call(env) 14 | request = Rack::Request.new(env) 15 | 16 | unless request.post? && request.path == passports_path 17 | debug { "I'm not interested in this #{request.request_method.inspect} request to #{request.path.inspect} I only care for POST #{passports_path.inspect}" } 18 | return @app.call(env) 19 | end 20 | 21 | token = request.params['access_token'] 22 | debug { "Detected incoming Passport exchange request for access token #{token.inspect}" } 23 | access_token = ::Doorkeeper::AccessToken.find_by_token token 24 | 25 | return json_error :access_token_not_found unless access_token 26 | return json_error :access_token_invalid unless access_token.valid? 27 | 28 | finding = ::SSO::Server::Passports.find_by_access_token_id(access_token.id) 29 | if finding.failure? 30 | # This should never happen. Every Access Token should be connected to a Passport. 31 | return json_error :passport_not_found 32 | end 33 | passport = finding.object 34 | 35 | ::SSO::Server::Passports.update_activity passport_id: passport.id, request: request 36 | 37 | debug { "Attaching user and chip to passport #{passport.inspect}" } 38 | passport.load_user! 39 | passport.create_chip! 40 | 41 | payload = { success: true, code: :here_is_your_passport, passport: passport.export } 42 | debug { "Created Passport #{passport.id}, sending it including user #{passport.user.inspect}}" } 43 | 44 | [200, { 'Content-Type' => 'application/json' }, [payload.to_json]] 45 | end 46 | 47 | def json_error(code) 48 | [200, { 'Content-Type' => 'application/json' }, [{ success: false, code: code }.to_json]] 49 | end 50 | 51 | def passports_path 52 | OmniAuth::Strategies::SSO.passports_path 53 | end 54 | 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/sso/server/middleware/passport_verification.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Server 3 | module Middleware 4 | class PassportVerification 5 | include ::SSO::Logging 6 | 7 | def initialize(app) 8 | @app = app 9 | end 10 | 11 | def call(env) 12 | request = Rack::Request.new(env) 13 | 14 | if request.get? && request.path == passports_path 15 | debug { 'Detected incoming Passport verification request.' } 16 | env['warden'].authenticate! :passport 17 | else 18 | debug { "I'm not interested in this #{request.request_method.inspect} request to #{request.path.inspect} I only care for GET #{passports_path.inspect}" } 19 | @app.call(env) 20 | end 21 | end 22 | 23 | def passports_path 24 | ::OmniAuth::Strategies::SSO.passports_path 25 | end 26 | 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/sso/server/passport.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | 3 | module SSO 4 | module Server 5 | # This could be MongoDB or whatever 6 | class Passport < ActiveRecord::Base 7 | include ::SSO::Logging 8 | include ::SSO::Benchmarking 9 | 10 | self.table_name = 'passports' 11 | 12 | before_validation :ensure_secret 13 | before_validation :ensure_activity_at 14 | 15 | before_save :update_location 16 | 17 | validates :secret, presence: true 18 | validates :oauth_access_token_id, uniqueness: { scope: [:owner_id, :revoked_at], allow_blank: true } 19 | validates :revoke_reason, allow_blank: true, format: { with: /\A[a-z_]+\z/ } 20 | 21 | attr_accessor :user 22 | attr_reader :chip 23 | 24 | def export 25 | debug { "Exporting Passport #{id} including the encapsulated user." } 26 | { 27 | id: id, 28 | secret: secret, 29 | state: state, 30 | chip: chip, 31 | user: user, 32 | } 33 | end 34 | 35 | def to_s 36 | ['Passport', owner_id, ip, activity_at].join ', ' 37 | end 38 | 39 | def state 40 | if user 41 | @state ||= state! 42 | else 43 | warn { 'Wait a minute, this Passport is not encapsulating a user!' } 44 | 'missing_user_for_state_calculation' 45 | end 46 | end 47 | 48 | def state! 49 | result = benchmark(name: 'Passport user state calculation') do 50 | OpenSSL::HMAC.hexdigest user_state_digest, user_state_key, user_state_base 51 | end 52 | debug { "The user state is #{result.inspect}" } 53 | result 54 | end 55 | 56 | def load_user! 57 | @user = SSO.config.find_user_for_passport.call passport: reload 58 | end 59 | 60 | def create_chip! 61 | @chip = chip! 62 | end 63 | 64 | def chip! 65 | benchmark(name: 'Passport chip encryption') do 66 | ensure_secret 67 | cipher = chip_digest 68 | cipher.encrypt 69 | cipher.key = chip_key 70 | chip_iv = cipher.random_iv 71 | ciphertext = cipher.update chip_plaintext 72 | ciphertext << cipher.final 73 | debug { "The Passport chip plaintext #{chip_plaintext.inspect} was encrypted using key #{chip_key.inspect} and IV #{chip_iv.inspect} and resultet in ciphertext #{ciphertext.inspect}" } 74 | chip = [Base64.encode64(ciphertext).strip, Base64.encode64(chip_iv).strip].join('|') 75 | logger.debug { "Augmented passport #{id.inspect} with chip #{chip.inspect}" } 76 | chip 77 | end 78 | end 79 | 80 | def user_state_digest 81 | OpenSSL::Digest.new 'sha1' 82 | end 83 | 84 | def chip_digest 85 | OpenSSL::Cipher::AES256.new :CBC 86 | end 87 | 88 | def chip_key 89 | SSO.config.passport_chip_key 90 | end 91 | 92 | # Don't get confused, the chip plaintext is the passport secret 93 | def chip_plaintext 94 | [id, secret].join '|' 95 | end 96 | 97 | def user_state_key 98 | ::SSO.config.user_state_key 99 | end 100 | 101 | def user_state_base 102 | ::SSO.config.user_state_base.call user 103 | end 104 | 105 | private 106 | 107 | def ensure_secret 108 | self.secret ||= SecureRandom.uuid 109 | end 110 | 111 | def ensure_activity_at 112 | self.activity_at ||= Time.now 113 | end 114 | 115 | def update_location 116 | location_name = ::SSO.config.human_readable_location_for_ip.call(ip) 117 | debug { "Updating geolocation for #{ip} which is #{location_name}" } 118 | self.location = location_name 119 | end 120 | 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /lib/sso/server/passports.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Server 3 | # This is the one interaction point with persisting and querying Passports. 4 | module Passports 5 | extend ::SSO::Logging 6 | 7 | def self.find(id) 8 | record = backend.find_by_id(id) 9 | 10 | if record 11 | Operations.success(:record_found, object: record) 12 | else 13 | Operations.failure :record_not_found 14 | end 15 | 16 | rescue => exception 17 | Operations.failure :backend_error, object: exception 18 | end 19 | 20 | def self.find_by_access_token_id(id) 21 | record = backend.where(revoked_at: nil).find_by_oauth_access_token_id(id) 22 | 23 | if record 24 | Operations.success(:record_found, object: record) 25 | else 26 | Operations.failure :record_not_found 27 | end 28 | end 29 | 30 | def self.generate(owner_id:, ip:, agent:, device: nil) 31 | debug { "Generating Passport for user ID #{owner_id.inspect} and IP #{ip.inspect} and Agent #{agent.inspect} and Device #{device.inspect}" } 32 | 33 | record = backend.create owner_id: owner_id, ip: ip, agent: agent, device: device 34 | 35 | if record.persisted? 36 | debug { "Successfully generated passport with ID #{record.id}" } 37 | Operations.success :generation_successful, object: record.id 38 | else 39 | Operations.failure :persistence_failed, object: record.errors.to_hash 40 | end 41 | end 42 | 43 | def self.update_activity(passport_id:, request:) 44 | record = find_valid_passport(passport_id) { |failure| return failure } 45 | 46 | ::SSO::Server::Passports::Activity.new(passport: record, request: request).call 47 | end 48 | 49 | def self.register_authorization_grant(passport_id:, token:) 50 | record = find_valid_passport(passport_id) { |failure| return failure } 51 | access_grant = find_valid_access_grant(token) { |failure| return failure } 52 | 53 | if record.update_attribute :oauth_access_grant_id, access_grant.id 54 | debug { "Successfully augmented Passport #{record.id} with Authorization Grant ID #{access_grant.id} which is #{access_grant.token}" } 55 | Operations.success :passport_augmented_with_access_token 56 | else 57 | Operations.failure :could_not_augment_passport_with_access_token 58 | end 59 | end 60 | 61 | def self.register_access_token_from_grant(grant_token:, access_token:) 62 | access_grant = find_valid_access_grant(grant_token) { |failure| return failure } 63 | access_token = find_valid_access_token(access_token) { |failure| return failure } 64 | record = find_valid_passport_by_grant_id(access_grant.id) { |failure| return failure } 65 | 66 | is_insider = access_token.scopes.include? 'insider' 67 | 68 | if record.update_attributes oauth_access_token_id: access_token.id, insider: is_insider 69 | debug { "Successfully augmented Passport #{record.id} with Access Token ID #{access_token.id} which is #{access_token.token}" } 70 | Operations.success :passport_known_by_grant_augmented_with_access_token 71 | else 72 | Operations.failure :could_not_augment_passport_known_by_grant_with_access_token 73 | end 74 | end 75 | 76 | def self.register_access_token_from_id(passport_id:, access_token:) 77 | access_token = find_valid_access_token(access_token) { |failure| return failure } 78 | record = find_valid_passport(passport_id) { |failure| return failure } 79 | 80 | is_insider = access_token.scopes.include? 'insider' 81 | 82 | if record.update_attributes oauth_access_token_id: access_token.id, insider: is_insider 83 | debug { "Successfully augmented #{is_insider ? :insider : :outsider} Passport #{record.id} with Access Token ID #{access_token.id} which is #{access_token.token}" } 84 | Operations.success :passport_augmented_with_access_token 85 | else 86 | Operations.failure :could_not_augment_passport_with_access_token 87 | end 88 | end 89 | 90 | def self.logout(passport_id:) 91 | return Operations.failure(:missing_passport_id) if passport_id.blank? 92 | 93 | debug { "Logging out Passport with ID #{passport_id.inspect}" } 94 | record = backend.find_by_id passport_id 95 | return Operations.success(:passport_does_not_exist) unless record 96 | return Operations.success(:passport_already_revoked) if record.revoked_at 97 | 98 | if record.update_attributes revoked_at: Time.now, revoke_reason: :logout 99 | Operations.success :passport_revoked 100 | else 101 | Operations.failure :backend_could_not_revoke_passport 102 | end 103 | end 104 | 105 | private 106 | 107 | def self.find_valid_passport(id) 108 | record = backend.where(revoked_at: nil).find_by_id(id) 109 | return record if record 110 | 111 | debug { "Could not find valid passport with ID #{id.inspect}" } 112 | yield Operations.failure :passport_not_found if block_given? 113 | nil 114 | end 115 | 116 | def self.find_valid_passport_by_grant_id(id) 117 | record = backend.where(revoked_at: nil).find_by_oauth_access_grant_id(id) 118 | return record if record 119 | 120 | warn { "Could not find valid passport by Authorization Grant ID #{id.inspect}" } 121 | yield Operations.failure :passport_not_found 122 | nil 123 | end 124 | 125 | def self.find_valid_access_grant(token) 126 | record = ::Doorkeeper::AccessGrant.find_by_token token 127 | 128 | if record && record.valid? 129 | record 130 | else 131 | warn { "Could not find valid Authorization Grant Token #{token.inspect}" } 132 | yield Operations.failure :access_grant_not_found 133 | nil 134 | end 135 | end 136 | 137 | def self.find_valid_access_token(token) 138 | record = ::Doorkeeper::AccessToken.find_by_token token 139 | 140 | if record && record.valid? 141 | record 142 | else 143 | warn { "Could not find valid OAuth Access Token #{token.inspect}" } 144 | yield Operations.failure :access_token_not_found 145 | nil 146 | end 147 | end 148 | 149 | def self.backend 150 | ::SSO::Server::Passport 151 | end 152 | 153 | end 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /lib/sso/server/passports/activity.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Server 3 | module Passports 4 | class Activity 5 | include ::SSO::Logging 6 | 7 | attr_reader :passport, :request 8 | 9 | def initialize(passport:, request:) 10 | @passport = passport 11 | @request = request 12 | end 13 | 14 | def call 15 | if passport.insider? || trusted_proxy_app? 16 | proxied_ip = request['ip'] 17 | unless proxied_ip 18 | warn { "There should have been a proxied IP param, but there was none. I will use the immediate IP #{immediate_ip} now." } 19 | proxied_ip = immediate_ip 20 | end 21 | attributes = { ip: proxied_ip, agent: request['agent'], device: request['device_id'] } 22 | else 23 | attributes = { ip: immediate_ip, agent: request.user_agent, device: request['device_id'] } 24 | end 25 | attributes.merge! activity_at: Time.now 26 | 27 | passport.stamps ||= {} # <- Not thread-safe, this may potentially delete all existing stamps, I guess 28 | passport.stamps[attributes[:ip]] = Time.now.to_i 29 | 30 | debug { "Updating activity of #{passport.insider? ? :insider : :outsider} Passport #{passport.id.inspect} using IP #{attributes[:ip]} agent #{attributes[:agent]} and device #{attributes[:device]}" } 31 | if passport.update_attributes(attributes) 32 | Operations.success :passport_metadata_updated 33 | else 34 | Operations.failure :could_not_update_passport_activity, object: passport.errors.to_hash 35 | end 36 | end 37 | 38 | def trusted_proxy_app? 39 | unless insider_id 40 | debug { 'This is an immediate request because there is no insider_id param' } 41 | return 42 | end 43 | 44 | unless insider_signature 45 | debug { 'This is an immediate request because there is no insider_signature param' } 46 | return 47 | end 48 | 49 | application = ::Doorkeeper::Application.find_by_id(insider_id) 50 | unless application 51 | warn { 'The insider_id param does not correspond to an existing Doorkeeper Application' } 52 | return 53 | end 54 | 55 | unless application.scopes.include?('insider') 56 | warn { 'The Doorkeeper Application belonging to this insider_id param is considered an outsider' } 57 | return 58 | end 59 | 60 | expected_signature = ::OpenSSL::HMAC.hexdigest signature_digest, application.secret, proxied_ip 61 | unless insider_signature == expected_signature 62 | warn { "The insider signature #{insider_signature.inspect} does not match my expectation #{expected_signature.inspect}" } 63 | return 64 | end 65 | 66 | debug { 'This is a proxied request because insider_id and insider_signature are valid' } 67 | true 68 | end 69 | 70 | def signature_digest 71 | OpenSSL::Digest.new 'sha1' 72 | end 73 | 74 | def proxied_ip 75 | request['ip'] 76 | end 77 | 78 | def insider_id 79 | request['insider_id'] 80 | end 81 | 82 | def insider_signature 83 | request['insider_signature'] 84 | end 85 | 86 | def immediate_ip 87 | request.respond_to?(:remote_ip) ? request.remote_ip : request.ip 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/sso/server/warden/hooks/after_authentication.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Server 3 | module Warden 4 | module Hooks 5 | class AfterAuthentication 6 | include ::SSO::Logging 7 | 8 | attr_reader :user, :warden, :options 9 | 10 | def self.to_proc 11 | proc do |user, warden, options| 12 | begin 13 | new(user: user, warden: warden, options: options).call 14 | rescue => exception 15 | ::SSO.config.exception_handler.call exception 16 | # The show must go on 17 | end 18 | end 19 | end 20 | 21 | def initialize(user:, warden:, options:) 22 | @user, @warden, @options = user, warden, options 23 | end 24 | 25 | def call 26 | debug { 'Starting hook because this is considered the first login of the current session...' } 27 | request = warden.request 28 | session = warden.env['rack.session'] 29 | 30 | debug { "Generating a passport for user #{user.id.inspect} for the session with the SSO server..." } 31 | attributes = { owner_id: user.id, ip: request.ip, agent: request.user_agent } 32 | 33 | generation = SSO::Server::Passports.generate attributes 34 | if generation.success? 35 | debug { "Passport with ID #{generation.object.inspect} generated successfuly. Persisting it in session..." } 36 | session[:passport_id] = generation.object 37 | else 38 | fail generation.code.inspect + generation.object.inspect 39 | end 40 | 41 | debug { 'Finished.' } 42 | end 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/sso/server/warden/hooks/before_logout.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Server 3 | module Warden 4 | module Hooks 5 | class BeforeLogout 6 | include ::SSO::Logging 7 | 8 | attr_reader :user, :warden, :options 9 | delegate :request, to: :warden 10 | delegate :params, to: :request 11 | delegate :session, to: :request 12 | 13 | def self.to_proc 14 | proc do |user, warden, options| 15 | begin 16 | new(user: user, warden: warden, options: options).call 17 | rescue => exception 18 | ::SSO.config.exception_handler.call exception 19 | nil 20 | end 21 | end 22 | end 23 | 24 | def initialize(user:, warden:, options:) 25 | @user, @warden, @options = user, warden, options 26 | end 27 | 28 | def call 29 | debug { 'Before warden destroys the passport in the cookie, it will revoke all connected Passports as well.' } 30 | revoking = Passports.logout passport_id: params['passport_id'] 31 | 32 | error { 'Could not revoke the Passports.' } if revoking.failure? 33 | debug { 'Finished.' } 34 | nil 35 | end 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/sso/server/warden/strategies/passport.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Server 3 | module Warden 4 | module Strategies 5 | class Passport < ::Warden::Strategies::Base 6 | include ::SSO::Logging 7 | include ::SSO::Benchmarking 8 | 9 | def valid? 10 | params['auth_version'].to_s != '' && params['state'] != '' 11 | end 12 | 13 | def authenticate! 14 | debug { 'Authenticating from Passport...' } 15 | 16 | authentication = passport_authentication 17 | track key: 'server.warden.strategies.passport.authentication' 18 | 19 | if authentication.success? 20 | debug { 'Authentication on Server from Passport successful.' } 21 | debug { "Responding with #{authentication.object}" } 22 | track key: "server.warden.strategies.passport.#{authentication.code}" 23 | custom! authentication.object 24 | else 25 | debug { 'Authentication from Passport on Server failed.' } 26 | track key: "server.warden.strategies.passport.#{authentication.code}" 27 | custom! authentication.object 28 | end 29 | 30 | rescue => exception 31 | ::SSO.config.exception_handler.call exception 32 | end 33 | 34 | def passport_authentication 35 | benchmark(name: 'Passport verification') do 36 | ::SSO::Server::Authentications::Passport.new(request).authenticate 37 | end 38 | end 39 | 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | require 'rails' 2 | require 'doorkeeper' 3 | require 'sso' 4 | require 'sso/server' 5 | require 'sso/client' 6 | 7 | require File.expand_path('../config/application', __FILE__) 8 | 9 | Rails.application.load_tasks 10 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. 9 | // 10 | // Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require_tree . 14 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any styles 10 | * defined in the other CSS/SCSS files in this directory. It is generally better to create a new 11 | * file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | protect_from_forgery with: :exception 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/home_controller.rb: -------------------------------------------------------------------------------- 1 | class HomeController < ApplicationController 2 | def index 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/sessions_controller.rb: -------------------------------------------------------------------------------- 1 | class SessionsController < ApplicationController 2 | include ::SSO::Logging 3 | delegate :logout, to: :warden 4 | 5 | before_action :prevent_json, only: [:new] 6 | 7 | # POI 8 | def new 9 | return_path = env['warden.options'][:attempted_path] 10 | debug { "Remembering the return path #{return_path.inspect}" } 11 | session[:return_path] = return_path 12 | end 13 | 14 | # POI 15 | def create 16 | warden.authenticate! :password 17 | 18 | if session[:return_path] 19 | debug { "Sending you back to #{session[:return_path]}" } 20 | redirect_to session[:return_path] 21 | session[:return_path] = nil 22 | else 23 | debug { "I don't know where you came from, sending you to #{root_url}" } 24 | redirect_to root_url 25 | end 26 | end 27 | 28 | private 29 | 30 | def prevent_json 31 | return unless request.format.to_sym == :json 32 | warn { "This request is asking for JSON where it shouldn't" } 33 | render status: :unauthorized, json: { status: :error, code: :authentication_failed } 34 | end 35 | 36 | def warden 37 | request.env['warden'] 38 | end 39 | 40 | end 41 | -------------------------------------------------------------------------------- /spec/dummy/app/models/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/spec/dummy/app/models/.keep -------------------------------------------------------------------------------- /spec/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | include ::SSO::Logging 3 | 4 | # This is a test implementation only, do not try this at home. 5 | # 6 | def self.authenticate(username, password) 7 | Rails.logger.debug('User') { "Checking password of user #{username.inspect}..." } 8 | where(email: username, password: password).first 9 | end 10 | 11 | # Don't try this at home, you should include the *encrypted* password, not the plaintext here. 12 | # 13 | def state_base 14 | result = [email.to_s, password.to_s, tags.map(&:to_s).sort].join 15 | debug { "The user state base is #{result.inspect}" } 16 | result 17 | end 18 | 19 | end 20 | -------------------------------------------------------------------------------- /spec/dummy/app/views/home/index.html.erb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/spec/dummy/app/views/home/index.html.erb -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |