├── .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 | Dummy 5 | <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %> 6 | <%= javascript_include_tag 'application', 'data-turbolinks-track' => true %> 7 | <%= csrf_meta_tags %> 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /spec/dummy/app/views/sessions/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_for(:session, url: sessions_path) do |f| %> 2 | 3 | <%= f.label :username %> 4 | <%= f.text_field :username %> 5 | 6 | <%= f.label :password %> 7 | <%= f.password_field :password %> 8 | 9 | <%= f.submit "Login" %> 10 | <% end %> 11 | -------------------------------------------------------------------------------- /spec/dummy/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../../config/application', __FILE__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | 4 | # path to your application root. 5 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 6 | 7 | Dir.chdir APP_ROOT do 8 | # This script is a starting point to setup your application. 9 | # Add necessary setup steps to this file: 10 | 11 | puts "== Installing dependencies ==" 12 | system "gem install bundler --conservative" 13 | system "bundle check || bundle install" 14 | 15 | # puts "\n== Copying sample files ==" 16 | # unless File.exist?("config/database.yml") 17 | # system "cp config/database.yml.sample config/database.yml" 18 | # end 19 | 20 | puts "\n== Preparing database ==" 21 | system "bin/rake db:setup" 22 | 23 | puts "\n== Removing old logs and tempfiles ==" 24 | system "rm -f log/*" 25 | system "rm -rf tmp/cache" 26 | 27 | puts "\n== Restarting application server ==" 28 | system "touch tmp/restart.txt" 29 | end 30 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run Rails.application 5 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../boot', __FILE__) 2 | 3 | require 'active_record/railtie' 4 | require 'action_controller/railtie' 5 | require 'action_view/railtie' 6 | 7 | Bundler.require(*Rails.groups) 8 | 9 | module Dummy 10 | class Application < Rails::Application 11 | config.active_record.raise_in_transactional_callbacks = true 12 | 13 | config.log_formatter = proc do |severity, _, progname, message| 14 | severity = case severity 15 | when 'FATAL' then "\e[#31mFATAL\e[0m" 16 | when 'ERROR' then "\e[#31mERROR\e[0m" 17 | when 'WARN' then "\e[#33mWARN \e[0m" 18 | when 'INFO' then "\e[#32mINFO \e[0m" 19 | when 'DEBUG' then "\e[#35mDEBUG\e[0m" 20 | else severity 21 | end 22 | 23 | "#{severity.ljust 5} \e[34m#{progname || 'Rails'}\e[0m : #{message}\n" 24 | end 25 | 26 | # POI 27 | config.middleware.insert_after ::ActionDispatch::Flash, '::Warden::Manager' do |manager| 28 | manager.failure_app = SessionsController.action :new 29 | manager.intercept_401 = false 30 | 31 | manager.serialize_into_session(&:id) 32 | manager.serialize_from_session { |id| User.find_by_id(id) } 33 | end 34 | 35 | config.middleware.insert_after ::Warden::Manager, ::SSO::Server::Middleware::PassportVerification 36 | config.middleware.insert_after ::Warden::Manager, ::SSO::Server::Middleware::PassportDestruction 37 | config.middleware.insert_after ::Warden::Manager, ::SSO::Server::Middleware::PassportExchange 38 | config.middleware.insert_after ::Warden::Manager, ::SSO::Server::Doorkeeper::GrantMarker 39 | config.middleware.insert_after ::Warden::Manager, ::SSO::Server::Doorkeeper::AccessTokenMarker 40 | 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../../Gemfile', __FILE__) 2 | 3 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 4 | $LOAD_PATH.unshift File.expand_path('../../../../lib', __FILE__) 5 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | <% 2 | 3 | endpoint = ENV['SSO_DB_ENDPOINT'] 4 | database = ENV['SSO_DB_DATABASE'] 5 | username = ENV['SSO_DB_USERNAME'] 6 | password = ENV['SSO_DB_PASSWORD'] 7 | 8 | if Rails.env.development? && RbConfig::CONFIG['target_vendor'] == 'apple' 9 | endpoint ||= '127.0.0.1' 10 | database ||= 'sso_development' 11 | username ||= '' 12 | password ||= '' 13 | sslmode = 'disable' 14 | 15 | elsif Rails.env.test? 16 | endpoint ||= '127.0.0.1' 17 | database ||= 'sso_test' 18 | username ||= '' 19 | password ||= '' 20 | sslmode = 'disable' 21 | end 22 | 23 | raise 'You must set SSO_DB_ENDPOINT' unless endpoint.present? 24 | raise 'You must set SSO_DB_DATABASE' unless database.present? 25 | raise 'You must set SSO_DB_USERNAME' unless username 26 | raise 'You must set SSO_DB_PASSWORD' unless password 27 | 28 | %> 29 | 30 | <%= Rails.env %>: 31 | adapter: postgresql 32 | host: <%= endpoint %> 33 | database: <%= database %> 34 | username: <%= username %> 35 | password: <%= password %> 36 | encoding: unicode 37 | port: 5432 38 | sslmode: <%= sslmode %> 39 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require File.expand_path('../application', __FILE__) 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports and disable caching. 13 | config.consider_all_requests_local = true 14 | config.action_controller.perform_caching = false 15 | 16 | # Don't care if the mailer can't send. 17 | # config.action_mailer.raise_delivery_errors = false 18 | 19 | # Print deprecation notices to the Rails logger. 20 | config.active_support.deprecation = :log 21 | 22 | # Raise an error on page load if there are pending migrations. 23 | config.active_record.migration_error = :page_load 24 | 25 | # Debug mode disables concatenation and preprocessing of assets. 26 | # This option may cause significant delays in view rendering with a large 27 | # number of complex assets. 28 | config.assets.debug = true 29 | 30 | # Adds additional error checking when serving assets at runtime. 31 | # Checks for improperly declared sprockets dependencies. 32 | # Raises helpful error messages. 33 | config.assets.raise_runtime_errors = true 34 | 35 | # Raises error for missing translations 36 | # config.action_view.raise_on_missing_translations = true 37 | end 38 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure static file server for tests with Cache-Control for performance. 16 | config.serve_static_files = true 17 | config.static_cache_control = 'public, max-age=3600' 18 | 19 | # Show full error reports and disable caching. 20 | config.consider_all_requests_local = true 21 | config.action_controller.perform_caching = false 22 | 23 | # Raise exceptions instead of rendering exception templates. 24 | config.action_dispatch.show_exceptions = false 25 | 26 | # Disable request forgery protection in test environment. 27 | config.action_controller.allow_forgery_protection = false 28 | 29 | # Tell Action Mailer not to deliver emails to the real world. 30 | # The :test delivery method accumulates sent emails in the 31 | # ActionMailer::Base.deliveries array. 32 | # config.action_mailer.delivery_method = :test 33 | 34 | # Randomize the order test cases are executed. 35 | config.active_support.test_order = :random 36 | 37 | # Print deprecation notices to the stderr. 38 | config.active_support.deprecation = :stderr 39 | 40 | # Raises error for missing translations 41 | # config.action_view.raise_on_missing_translations = true 42 | end 43 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = '1.0' 5 | 6 | # Add additional assets to the asset load path 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in app/assets folder are already added. 11 | # Rails.application.config.assets.precompile += %w( search.js ) 12 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.action_dispatch.cookies_serializer = :json 4 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/doorkeeper.rb: -------------------------------------------------------------------------------- 1 | # POI 2 | 3 | ::Doorkeeper.configure do 4 | 5 | orm :active_record 6 | 7 | grant_flows %w(authorization_code password) 8 | 9 | resource_owner_authenticator ::SSO::Server::Doorkeeper::ResourceOwnerAuthenticator.to_proc 10 | resource_owner_from_credentials ::SSO::Server::Doorkeeper::ResourceOwnerAuthenticator.to_proc 11 | 12 | default_scopes :outsider 13 | optional_scopes :insider 14 | 15 | skip_authorization do 16 | true 17 | end 18 | 19 | admin_authenticator do 20 | nil 21 | end 22 | 23 | end 24 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/secret_token.rb: -------------------------------------------------------------------------------- 1 | secret_key_base = ENV['SSO_CONFIG_SECRET_TOKEN'].presence 2 | 3 | if Rails.env.development? || Rails.env.test? 4 | dummy_token = '1986c60cc8b4843e5a6426d6ef5e1c031be4f73a10b3c56aa9c0b8d2dc8e1eba385975689ca072f5e884c98d178b3e4fde47aa91a9a16173bfaad766905fb7f5' 5 | secret_key_base ||= dummy_token 6 | end 7 | 8 | fail 'You must set SSO_CONFIG_SECRET_TOKEN' if secret_key_base.blank? 9 | 10 | Rails.application.config.secret_key_base = secret_key_base 11 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.session_store :cookie_store, key: '_dummy_session' 4 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/sso.rb: -------------------------------------------------------------------------------- 1 | # POI 2 | 3 | # This is the minimal configuration you need to do for using the sso gem. 4 | 5 | SSO.configure do |config| 6 | 7 | config.find_user_for_passport = proc do |passport:| 8 | # This is your chance to modify the user instance before it is handed out to the OAuth client apps. 9 | # The Passport has already been updated with the most recent IP metadata, so you can take that into consideration. 10 | 11 | progname = 'SSO.config.find_user_for_passport' 12 | Rails.logger.debug(progname) { "Looking up User #{passport.owner_id.inspect} belonging to Passport #{passport.id.inspect} surfing with IP #{passport.ip} or #{passport.ip}..." } 13 | user = User.find_by_id passport.owner_id 14 | return unless user 15 | 16 | # The IP address, for example, might be used to set certain flags on the user object. 17 | # Note that the IP can be nil in which case we don't know it. 18 | 19 | if passport.ip == '198.51.100.74' 20 | user.tags << :is_at_the_office 21 | elsif passport.ip 22 | user.tags << :is_working_from_home 23 | else 24 | user.tags << :location_is_unknown 25 | end 26 | 27 | user 28 | end 29 | 30 | config.user_state_base = proc do |user| 31 | # Include the end-user credentials to force all OAuth client apps to refetch the end-user Passports. 32 | # This way you can revoke all relevant Passports on SSO-logout and the OAuth client apps are immediately aware of it. 33 | user.state_base 34 | end 35 | 36 | # This is a rather static key used to calculate whether a user state changed and needs to be propagated to the OAuth clients. 37 | # It's not a problem if this changes, as long as it's somewhat deterministic. 38 | # In our case, we simply derive it from the Rails secret_key_base so we don't have to remember yet another secret somewhere. 39 | generator = ActiveSupport::KeyGenerator.new Rails.application.config.secret_key_base, iterations: 1000 40 | config.user_state_key = Rails.application.config.user_state_digest_key = generator.generate_key 'user state digest key' 41 | end 42 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/warden.rb: -------------------------------------------------------------------------------- 1 | # POI 2 | # This is the logic to retrieve a user object given 3 | 4 | ::Warden::Strategies.add :password do 5 | def valid? 6 | params['username'].present? 7 | end 8 | 9 | def authenticate! 10 | Rails.logger.debug(progname) { 'Authenticating from username and password...' } 11 | user = ::User.authenticate params['username'], params['password'] 12 | 13 | if user 14 | Rails.logger.debug(progname) { 'Authentication from username and password successful.' } 15 | success! user 16 | else 17 | Rails.logger.debug(progname) { 'Authentication from username and password failed.' } 18 | fail! 'Could not login.' 19 | end 20 | end 21 | 22 | def progname 23 | 'Warden::Strategies.password' 24 | end 25 | end 26 | 27 | # POI 28 | Warden::Manager.after_authentication(&::SSO::Server::Warden::Hooks::AfterAuthentication.to_proc) 29 | Warden::Manager.before_logout(&::SSO::Server::Warden::Hooks::BeforeLogout.to_proc) 30 | Warden::Strategies.add :passport, ::SSO::Server::Warden::Strategies::Passport 31 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] if respond_to?(:wrap_parameters) 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /spec/dummy/config/locales/doorkeeper.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | activerecord: 3 | attributes: 4 | doorkeeper/application: 5 | name: 'Name' 6 | redirect_uri: 'Redirect URI' 7 | errors: 8 | models: 9 | doorkeeper/application: 10 | attributes: 11 | redirect_uri: 12 | fragment_present: 'cannot contain a fragment.' 13 | invalid_uri: 'must be a valid URI.' 14 | relative_uri: 'must be an absolute URI.' 15 | secured_uri: 'must be an HTTPS/SSL URI.' 16 | 17 | mongoid: 18 | attributes: 19 | doorkeeper/application: 20 | name: 'Name' 21 | redirect_uri: 'Redirect URI' 22 | errors: 23 | models: 24 | doorkeeper/application: 25 | attributes: 26 | redirect_uri: 27 | fragment_present: 'cannot contain a fragment.' 28 | invalid_uri: 'must be a valid URI.' 29 | relative_uri: 'must be an absolute URI.' 30 | secured_uri: 'must be an HTTPS/SSL URI.' 31 | 32 | mongo_mapper: 33 | attributes: 34 | doorkeeper/application: 35 | name: 'Name' 36 | redirect_uri: 'Redirect URI' 37 | errors: 38 | models: 39 | doorkeeper/application: 40 | attributes: 41 | redirect_uri: 42 | fragment_present: 'cannot contain a fragment.' 43 | invalid_uri: 'must be a valid URI.' 44 | relative_uri: 'must be an absolute URI.' 45 | secured_uri: 'must be an HTTPS/SSL URI.' 46 | 47 | doorkeeper: 48 | applications: 49 | confirmations: 50 | destroy: 'Are you sure?' 51 | buttons: 52 | edit: 'Edit' 53 | destroy: 'Destroy' 54 | submit: 'Submit' 55 | cancel: 'Cancel' 56 | authorize: 'Authorize' 57 | form: 58 | error: 'Whoops! Check your form for possible errors' 59 | help: 60 | redirect_uri: 'Use one line per URI' 61 | native_redirect_uri: 'Use %{native_redirect_uri} for local tests' 62 | edit: 63 | title: 'Edit application' 64 | index: 65 | title: 'Your applications' 66 | new: 'New Application' 67 | name: 'Name' 68 | callback_url: 'Callback URL' 69 | new: 70 | title: 'New Application' 71 | show: 72 | title: 'Application: %{name}' 73 | application_id: 'Application Id' 74 | secret: 'Secret' 75 | callback_urls: 'Callback urls' 76 | actions: 'Actions' 77 | 78 | authorizations: 79 | buttons: 80 | authorize: 'Authorize' 81 | deny: 'Deny' 82 | error: 83 | title: 'An error has occurred' 84 | new: 85 | title: 'Authorize required' 86 | prompt: 'Authorize %{client_name} to use your account?' 87 | able_to: 'This application will be able to' 88 | show: 89 | title: 'Authorization code' 90 | 91 | authorized_applications: 92 | confirmations: 93 | revoke: 'Are you sure?' 94 | buttons: 95 | revoke: 'Revoke' 96 | index: 97 | title: 'Your authorized applications' 98 | application: 'Application' 99 | created_at: 'Created At' 100 | date_format: '%Y-%m-%d %H:%M:%S' 101 | 102 | errors: 103 | messages: 104 | # Common error messages 105 | invalid_request: 'The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed.' 106 | invalid_redirect_uri: 'The redirect uri included is not valid.' 107 | unauthorized_client: 'The client is not authorized to perform this request using this method.' 108 | access_denied: 'The resource owner or authorization server denied the request.' 109 | invalid_scope: 'The requested scope is invalid, unknown, or malformed.' 110 | server_error: 'The authorization server encountered an unexpected condition which prevented it from fulfilling the request.' 111 | temporarily_unavailable: 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.' 112 | 113 | #configuration error messages 114 | credential_flow_not_configured: 'Resource Owner Password Credentials flow failed due to Doorkeeper.configure.resource_owner_from_credentials being unconfigured.' 115 | resource_owner_authenticator_not_configured: 'Resource Owner find failed due to Doorkeeper.configure.resource_owner_authenticator being unconfiged.' 116 | 117 | # Access grant errors 118 | unsupported_response_type: 'The authorization server does not support this response type.' 119 | 120 | # Access token errors 121 | invalid_client: 'Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.' 122 | invalid_grant: 'The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.' 123 | unsupported_grant_type: 'The authorization grant type is not supported by the authorization server.' 124 | 125 | # Password Access token errors 126 | invalid_resource_owner: 'The provided resource owner credentials are not valid, or resource owner cannot be found' 127 | 128 | invalid_token: 129 | revoked: "The access token was revoked" 130 | expired: "The access token expired" 131 | unknown: "The access token is invalid" 132 | 133 | flash: 134 | applications: 135 | create: 136 | notice: 'Application created.' 137 | destroy: 138 | notice: 'Application deleted.' 139 | update: 140 | notice: 'Application updated.' 141 | authorized_applications: 142 | destroy: 143 | notice: 'Application revoked.' 144 | 145 | layouts: 146 | admin: 147 | nav: 148 | oauth2_provider: 'OAuth2 Provider' 149 | applications: 'Applications' 150 | application: 151 | title: 'OAuth authorize required' -------------------------------------------------------------------------------- /spec/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more, please read the Rails Internationalization guide 20 | # available at http://guides.rubyonrails.org/i18n.html. 21 | 22 | en: 23 | hello: "Hello world" 24 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | 3 | use_doorkeeper do 4 | skip_controllers :applications, :authorized_applications 5 | end 6 | 7 | resources :sessions, only: [:new, :create] 8 | get '/logout(/:passport_id)', to: 'sessions#logout', as: :logout 9 | 10 | root to: 'home#index' 11 | 12 | end 13 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20150302113121_add_users.rb: -------------------------------------------------------------------------------- 1 | # This is not part of SSO, this is simply an example implementation of a user model. 2 | 3 | class AddUsers < ActiveRecord::Migration 4 | def change 5 | create_table :users do |t| 6 | t.string :name, null: false 7 | t.string :email, null: false 8 | t.string :password, null: false # <- Of course you would have this encrypted in a real-life setup 9 | t.string :tags, array: true, default: [] 10 | t.boolean :vip 11 | t.timestamps null: false 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20150303054803_create_doorkeeper_tables.rb: -------------------------------------------------------------------------------- 1 | # POI 2 | 3 | # This migration file is created when you run `rails generate doorkeeper:install` in your OAuth Server Rails app. 4 | # These tables are needed for Doorkeeper to work, see also https://github.com/doorkeeper-gem/doorkeeper#installation 5 | # This migration here has *not* been modified. You simply see the original file below, created by the Doorkeeper generator. 6 | 7 | # Note, however, that it was generated with Doorkeeper >= 2.0.0, since it has the `scopes` column on the `oauth_applications` table. 8 | # We make use of that column and it was introduced here: https://github.com/doorkeeper-gem/doorkeeper/blob/master/CHANGELOG.md#200 9 | 10 | class CreateDoorkeeperTables < ActiveRecord::Migration 11 | def change 12 | create_table :oauth_applications do |t| 13 | t.string :name, null: false 14 | t.string :uid, null: false 15 | t.string :secret, null: false 16 | t.text :redirect_uri, null: false 17 | t.string :scopes, null: false, default: '' # <- Exists only with Doorkeeper 2.0.0 or higher. 18 | t.timestamps null: false 19 | end 20 | 21 | add_index :oauth_applications, :uid, unique: true 22 | 23 | create_table :oauth_access_grants do |t| 24 | t.integer :resource_owner_id, null: false 25 | t.integer :application_id, null: false 26 | t.string :token, null: false 27 | t.integer :expires_in, null: false 28 | t.text :redirect_uri, null: false 29 | t.datetime :created_at, null: false 30 | t.datetime :revoked_at 31 | t.string :scopes 32 | end 33 | 34 | add_index :oauth_access_grants, :token, unique: true 35 | 36 | create_table :oauth_access_tokens do |t| 37 | t.integer :resource_owner_id 38 | t.integer :application_id 39 | t.string :token, null: false 40 | t.string :refresh_token 41 | t.integer :expires_in 42 | t.datetime :revoked_at 43 | t.datetime :created_at, null: false 44 | t.string :scopes 45 | end 46 | 47 | add_index :oauth_access_tokens, :token, unique: true 48 | add_index :oauth_access_tokens, :resource_owner_id 49 | add_index :oauth_access_tokens, :refresh_token, unique: true 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20150303132931_create_passports_table.rb: -------------------------------------------------------------------------------- 1 | # POI 2 | 3 | # This is what the Passport table on the SSO Server looks like. You need to have this migration. 4 | # As you can see it uses the `uuid` and `inet` column types. So you are kind of stuck with Postgres. 5 | # However, there should be no reason for you not to simply use `integer` and `string` for those two columns instead. 6 | 7 | class CreatePassportsTable < ActiveRecord::Migration 8 | def change 9 | enable_extension 'uuid-ossp' 10 | enable_extension 'hstore' 11 | 12 | create_table :passports, id: :uuid do |t| 13 | # Relationships with Doorkeeper-internal tables 14 | t.integer :oauth_access_grant_id # OAuth Grant Token 15 | t.integer :oauth_access_token_id # OAuth Access Token 16 | t.boolean :insider # Denormalized: Is the client app trusted? 17 | 18 | # Passport information 19 | t.integer :owner_id, null: false # User ID 20 | t.string :secret, null: false, unique: true # Random secret string 21 | 22 | # Passport activity 23 | t.datetime :activity_at, null: false # Timestamp of most recent usage 24 | t.inet :ip, null: false # Most recent IP which used this Passport 25 | t.string :agent # Post recent User Agent which used this Passport 26 | t.string :location # Human-readable city of the IP (geolocation) 27 | t.string :device # Mobile client hardware UUID (if applicable) 28 | t.hstore :stamps # Keeping track of *all* IPs which use(d) this Passport 29 | 30 | # Revocation 31 | t.datetime :revoked_at # If set, consider this record to be deleted 32 | t.string :revoke_reason # Slug describing why deleted (logout, timeout, etc) 33 | t.timestamps null: false # Internal Rails created_at and updated_at columns 34 | end 35 | 36 | # Doorkeeper is not guaranteed to create a new access token upon each login, it may just return an existing one 37 | # That's why we need to check for `revoked_at`, only valid passports bear the constraint 38 | add_index :passports, [:owner_id, :oauth_access_token_id], where: 'revoked_at IS NULL AND oauth_access_token_id IS NOT NULL', unique: true, name: :one_access_token_per_owner 39 | 40 | add_index :passports, :oauth_access_grant_id 41 | add_index :passports, :oauth_access_token_id 42 | add_index :passports, :insider 43 | add_index :passports, :owner_id 44 | add_index :passports, :secret 45 | add_index :passports, :activity_at 46 | add_index :passports, :ip 47 | add_index :passports, :location 48 | add_index :passports, :device 49 | add_index :passports, :revoked_at 50 | add_index :passports, :revoke_reason 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | # This file is auto-generated from the current state of the database. Instead 3 | # of editing this file, please use the migrations feature of Active Record to 4 | # incrementally modify your database, and then regenerate this schema definition. 5 | # 6 | # Note that this schema.rb definition is the authoritative source for your 7 | # database schema. If you need to create the application database on another 8 | # system, you should be using db:schema:load, not running all the migrations 9 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 10 | # you'll amass, the slower it'll run and the greater likelihood for issues). 11 | # 12 | # It's strongly recommended that you check this file into your version control system. 13 | 14 | ActiveRecord::Schema.define(version: 20150303132931) do 15 | 16 | # These are extensions that must be enabled in order to support this database 17 | enable_extension "plpgsql" 18 | enable_extension "uuid-ossp" 19 | enable_extension "hstore" 20 | 21 | create_table "oauth_access_grants", force: :cascade do |t| 22 | t.integer "resource_owner_id", null: false 23 | t.integer "application_id", null: false 24 | t.string "token", null: false 25 | t.integer "expires_in", null: false 26 | t.text "redirect_uri", null: false 27 | t.datetime "created_at", null: false 28 | t.datetime "revoked_at" 29 | t.string "scopes" 30 | end 31 | 32 | add_index "oauth_access_grants", ["token"], name: "index_oauth_access_grants_on_token", unique: true, using: :btree 33 | 34 | create_table "oauth_access_tokens", force: :cascade do |t| 35 | t.integer "resource_owner_id" 36 | t.integer "application_id" 37 | t.string "token", null: false 38 | t.string "refresh_token" 39 | t.integer "expires_in" 40 | t.datetime "revoked_at" 41 | t.datetime "created_at", null: false 42 | t.string "scopes" 43 | end 44 | 45 | add_index "oauth_access_tokens", ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true, using: :btree 46 | add_index "oauth_access_tokens", ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id", using: :btree 47 | add_index "oauth_access_tokens", ["token"], name: "index_oauth_access_tokens_on_token", unique: true, using: :btree 48 | 49 | create_table "oauth_applications", force: :cascade do |t| 50 | t.string "name", null: false 51 | t.string "uid", null: false 52 | t.string "secret", null: false 53 | t.text "redirect_uri", null: false 54 | t.string "scopes", default: "", null: false 55 | t.datetime "created_at", null: false 56 | t.datetime "updated_at", null: false 57 | end 58 | 59 | add_index "oauth_applications", ["uid"], name: "index_oauth_applications_on_uid", unique: true, using: :btree 60 | 61 | create_table "passports", id: :uuid, default: "uuid_generate_v4()", force: :cascade do |t| 62 | t.integer "oauth_access_grant_id" 63 | t.integer "oauth_access_token_id" 64 | t.boolean "insider" 65 | t.integer "owner_id", null: false 66 | t.string "secret", null: false 67 | t.datetime "activity_at", null: false 68 | t.inet "ip", null: false 69 | t.string "agent" 70 | t.string "location" 71 | t.string "device" 72 | t.hstore "stamps" 73 | t.datetime "revoked_at" 74 | t.string "revoke_reason" 75 | t.datetime "created_at", null: false 76 | t.datetime "updated_at", null: false 77 | end 78 | 79 | add_index "passports", ["activity_at"], name: "index_passports_on_activity_at", using: :btree 80 | add_index "passports", ["device"], name: "index_passports_on_device", using: :btree 81 | add_index "passports", ["insider"], name: "index_passports_on_insider", using: :btree 82 | add_index "passports", ["ip"], name: "index_passports_on_ip", using: :btree 83 | add_index "passports", ["location"], name: "index_passports_on_location", using: :btree 84 | add_index "passports", ["oauth_access_grant_id"], name: "index_passports_on_oauth_access_grant_id", using: :btree 85 | add_index "passports", ["oauth_access_token_id"], name: "index_passports_on_oauth_access_token_id", using: :btree 86 | add_index "passports", ["owner_id", "oauth_access_token_id"], name: "one_access_token_per_owner", unique: true, where: "((revoked_at IS NULL) AND (oauth_access_token_id IS NOT NULL))", using: :btree 87 | add_index "passports", ["owner_id"], name: "index_passports_on_owner_id", using: :btree 88 | add_index "passports", ["revoke_reason"], name: "index_passports_on_revoke_reason", using: :btree 89 | add_index "passports", ["revoked_at"], name: "index_passports_on_revoked_at", using: :btree 90 | add_index "passports", ["secret"], name: "index_passports_on_secret", using: :btree 91 | 92 | create_table "users", force: :cascade do |t| 93 | t.string "name", null: false 94 | t.string "email", null: false 95 | t.string "password", null: false 96 | t.string "tags", default: [], array: true 97 | t.boolean "vip" 98 | t.datetime "created_at", null: false 99 | t.datetime "updated_at", null: false 100 | end 101 | 102 | end 103 | -------------------------------------------------------------------------------- /spec/dummy/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halo/sso/9a2c7f5d3cb2c787765a1ef084ae596fdb948bf7/spec/dummy/log/.keep -------------------------------------------------------------------------------- /spec/integration/oauth/authorization_code_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe 'OAuth 2.0 Authorization Grant Flow', type: :request, db: true do 4 | 5 | let!(:user) { create :user } 6 | let!(:client) { create :outsider_doorkeeper_application } 7 | let(:redirect_uri) { client.redirect_uri } 8 | 9 | let(:scope) { :outsider } 10 | let(:grant_params) { { client_id: client.uid, redirect_uri: redirect_uri, response_type: :code, scope: scope, state: 'some_random_string' } } 11 | let(:result) { JSON.parse(response.body) } 12 | 13 | let(:latest_grant) { ::Doorkeeper::AccessGrant.last } 14 | let(:latest_access_token) { ::Doorkeeper::AccessToken.last } 15 | let(:access_token_count) { ::Doorkeeper::AccessToken.count } 16 | let(:grant_count) { ::Doorkeeper::AccessGrant.count } 17 | 18 | let(:latest_passport) { ::SSO::Server::Passport.last } 19 | let(:passport_count) { ::SSO::Server::Passport.count } 20 | 21 | before do 22 | get_via_redirect '/oauth/authorize', grant_params 23 | end 24 | 25 | it 'remembers the return path' do 26 | expect(session[:return_path]).to eq "/oauth/authorize?#{grant_params.to_query}" 27 | end 28 | 29 | it 'shows to the login page' do 30 | expect(response).to render_template 'sessions/new' 31 | end 32 | 33 | context 'Logging in' do 34 | before do 35 | post '/sessions', username: user.email, password: user.password 36 | follow_redirect! 37 | end 38 | 39 | it 'redirects to the application callback including the Grant Token' do 40 | expect(latest_grant).to be_present 41 | expect(response).to redirect_to "#{client.redirect_uri}?code=#{latest_grant.token}&state=some_random_string" 42 | end 43 | 44 | it 'generates a passport with the grant token attached to it' do 45 | expect(latest_passport.oauth_access_grant_id).to eq latest_grant.id 46 | end 47 | 48 | it 'does not generate multiple authorization grants' do 49 | expect(grant_count).to eq 1 50 | end 51 | 52 | context 'Exchanging the Authorization Grant for an Access Token' do 53 | let(:grant) { ::Rack::Utils.parse_query(URI.parse(response.location).query).fetch('code') } 54 | let(:grant_type) { :authorization_code } 55 | let(:params) { { client_id: client.uid, client_secret: client.secret, code: grant, grant_type: grant_type, redirect_uri: redirect_uri } } 56 | let(:token) { JSON.parse(response.body).fetch 'access_token' } 57 | 58 | before do 59 | post '/oauth/token', params 60 | end 61 | 62 | it 'succeeds' do 63 | expect(response.status).to eq 200 64 | end 65 | 66 | it 'responds with JSON serialized params' do 67 | expect(result).to be_instance_of Hash 68 | end 69 | 70 | it 'includes the access_token' do 71 | expect(result['access_token']).to eq latest_access_token.token 72 | end 73 | 74 | it 'generates a passport with the grant token attached to it' do 75 | expect(latest_passport.oauth_access_token_id).to eq latest_access_token.id 76 | end 77 | 78 | it 'does not generate multiple passports' do 79 | expect(passport_count).to eq 1 80 | end 81 | 82 | it 'does not generate multiple access tokens' do 83 | expect(access_token_count).to eq 1 84 | end 85 | 86 | it 'succeeds' do 87 | expect(response.status).to eq 200 88 | end 89 | 90 | context 'Exchanging the Access Token for a Passport' do 91 | before do 92 | SSO.config.passport_chip_key = SecureRandom.hex 93 | post '/oauth/sso/v1/passports', access_token: token 94 | end 95 | 96 | it 'succeeds' do 97 | expect(response.status).to eq 200 98 | end 99 | 100 | it 'gets the passport' do 101 | expect(result['passport']).to be_present 102 | end 103 | 104 | it 'is the passport for that access token' do 105 | expect(result['passport']['id']).to eq latest_passport.id 106 | expect(latest_passport.oauth_access_token_id).to eq latest_access_token.id 107 | end 108 | 109 | it 'is an outsider passport' do 110 | expect(latest_passport).to_not be_insider 111 | end 112 | 113 | context 'insider application' do 114 | let!(:client) { create :insider_doorkeeper_application } 115 | let(:scope) { :insider } 116 | 117 | it 'is an insider passport' do 118 | expect(latest_passport).to be_insider 119 | end 120 | end 121 | end 122 | 123 | end 124 | end 125 | 126 | end 127 | -------------------------------------------------------------------------------- /spec/integration/oauth/password_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe 'OAuth 2.0 Resource Owner Password Credentials Grant', type: :request, db: true do 4 | 5 | let!(:user) { create :user } 6 | let!(:client) { create :outsider_doorkeeper_application } 7 | 8 | let(:scope) { :outsider } 9 | let(:password) { user.password } 10 | let(:params) { { grant_type: :password, client_id: client.uid, client_secret: client.secret, username: user.email, password: password, scope: scope } } 11 | let(:headers) { { 'HTTP_ACCEPT' => 'application/json' } } 12 | 13 | let(:latest_access_token) { ::Doorkeeper::AccessToken.last } 14 | let(:latest_passport) { ::SSO::Server::Passport.last } 15 | let(:passport_count) { ::SSO::Server::Passport.count } 16 | let(:result) { JSON.parse(response.body) } 17 | 18 | before do 19 | SSO.config.passport_chip_key = SecureRandom.hex 20 | post '/oauth/token', params, headers 21 | end 22 | 23 | context 'correct password' do 24 | it 'succeeds' do 25 | expect(response.status).to eq 200 26 | end 27 | 28 | it 'responds with JSON serialized params' do 29 | expect(result).to be_instance_of Hash 30 | end 31 | 32 | it 'includes the access_token' do 33 | expect(result['access_token']).to eq latest_access_token.token 34 | end 35 | 36 | it 'generates a passport with the grant token attached to it' do 37 | expect(latest_passport.oauth_access_token_id).to eq latest_access_token.id 38 | end 39 | 40 | it 'does not generate multiple passports' do 41 | expect(passport_count).to eq 1 42 | end 43 | 44 | context 'Exchanging the Access Token for a Passport' do 45 | let(:token) { JSON.parse(response.body).fetch 'access_token' } 46 | 47 | before do 48 | post '/oauth/sso/v1/passports', access_token: token 49 | end 50 | 51 | it 'succeeds' do 52 | expect(response.status).to eq 200 53 | end 54 | 55 | it 'gets the passport' do 56 | expect(result['passport']).to be_present 57 | end 58 | 59 | it 'is the passport for that access token' do 60 | expect(result['passport']['id']).to eq latest_passport.id 61 | expect(latest_passport.oauth_access_token_id).to eq latest_access_token.id 62 | end 63 | 64 | it 'is an outsider passport' do 65 | expect(latest_passport).to_not be_insider 66 | end 67 | 68 | context 'insider application' do 69 | let!(:client) { create :insider_doorkeeper_application } 70 | let(:scope) { :insider } 71 | 72 | it 'is an insider passport' do 73 | expect(latest_passport).to be_insider 74 | end 75 | end 76 | end 77 | end 78 | 79 | context 'wrong password' do 80 | let(:password) { 'wrong-password-sent-by-hackerz' } 81 | 82 | it 'fails' do 83 | expect(response.status).to eq 401 84 | end 85 | 86 | it 'responds with JSON serialized params' do 87 | expect(result).to be_instance_of Hash 88 | end 89 | 90 | it 'provides a errornous status' do 91 | expect(result['status']).to eq 'error' 92 | end 93 | 94 | it 'provides a useful code' do 95 | expect(result['code']).to eq 'authentication_failed' 96 | end 97 | 98 | it 'does not generate anny passports' do 99 | expect(passport_count).to eq 0 100 | end 101 | end 102 | 103 | end 104 | -------------------------------------------------------------------------------- /spec/lib/sso/benchmarking_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe ::SSO::Benchmarking do 4 | 5 | let(:instance) { MyTestNamespace::MyClass.new } 6 | 7 | before do 8 | stub_const 'MyTestNamespace', Module.new 9 | stub_const 'MyTestNamespace::MyClass', Class.new { include SSO::Benchmarking } 10 | end 11 | 12 | describe '#benchmark' do 13 | context 'without block' do 14 | it 'is nil' do 15 | expect(instance.benchmark).to be_nil 16 | end 17 | end 18 | 19 | context 'block given' do 20 | context 'without arguments' do 21 | it 'returns what was passed in' do 22 | duration = instance.benchmark { :something } 23 | expect(duration).to eq :something 24 | end 25 | 26 | it 'logs' do 27 | expect(instance).to receive(:debug) do |_, &block| 28 | expect(block.call).to eq 'Benchmark took 0ms' 29 | end 30 | instance.benchmark {} 31 | end 32 | 33 | it 'does not meter' do 34 | expect(::SSO.config).to_not receive(:metric) 35 | instance.benchmark {} 36 | end 37 | end 38 | 39 | context 'only with name' do 40 | it 'logs with the name' do 41 | expect(instance).to receive(:debug) do |_, &block| 42 | expect(block.call).to eq 'Long calculation took 0ms' 43 | end 44 | instance.benchmark(name: 'Long calculation') {} 45 | end 46 | 47 | it 'does not meter' do 48 | expect(instance).to_not receive(:timing) 49 | instance.benchmark(name: 'Long calculation') {} 50 | end 51 | end 52 | 53 | context 'only with metric' do 54 | it 'logs with the metric' do 55 | expect(instance).to receive(:debug).twice do |_, &block| 56 | next if block.call.include?('Measuring') 57 | expect(block.call).to eq 'blob.serialization took 0ms' 58 | end 59 | instance.benchmark(metric: 'blob.serialization') {} 60 | end 61 | 62 | it 'meters as timing with the metric as name' do 63 | expect(instance).to receive(:timing).with key: 'blob.serialization', value: 0 64 | instance.benchmark(metric: 'blob.serialization') {} 65 | end 66 | end 67 | 68 | context 'with name and metric' do 69 | it 'logs with the name' do 70 | expect(instance).to receive(:debug).twice do |_, &block| 71 | next if block.call.include?('Measuring') 72 | expect(block.call).to eq 'Synchronous encryption took 0ms' 73 | end 74 | instance.benchmark(name: 'Synchronous encryption', metric: 'encryption.aes') {} 75 | end 76 | 77 | it 'meters as timing with the metric as name' do 78 | expect(instance).to receive(:timing).with key: 'encryption.aes', value: 0 79 | instance.benchmark(name: 'Synchronous encryption', metric: 'encryption.aes') {} 80 | end 81 | end 82 | end 83 | end 84 | 85 | end 86 | -------------------------------------------------------------------------------- /spec/lib/sso/client/authentications/passport_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe SSO::Client::Authentications::Passport, type: :request, db: true, reveal_exceptions: true do 4 | 5 | # Untrusted Client 6 | let(:request_method) { 'GET' } 7 | let(:request_path) { '/some/resource' } 8 | let(:request_params) { { passport_chip: passport_chip } } 9 | let(:signature_token) { Signature::Token.new passport_id, passport_secret } 10 | let(:signature_request) { Signature::Request.new(request_method, request_path, request_params) } 11 | let(:auth_hash) { signature_request.sign signature_token } 12 | let(:query_params) { request_params.merge auth_hash } 13 | let(:ip) { '198.51.100.74' } 14 | let(:agent) { 'IE7' } 15 | 16 | # Trusted Client 17 | let(:rack_request) { double :rack_request, request_method: request_method, ip: ip, user_agent: agent, path: request_path, query_parameters: query_params.stringify_keys, params: query_params.stringify_keys } 18 | let(:warden_env) { {} } 19 | let(:warden_request) { double :warden_request, ip: ip, user_agent: agent, env: warden_env } 20 | let(:warden) { double :warden, request: warden_request } 21 | let(:client_user) { double :client_user } 22 | let(:client_passport) { ::SSO::Client::Passport.new id: passport_id, secret: passport_secret, state: passport_state, user: client_user } 23 | let(:authentication) { described_class.new rack_request } 24 | let(:operation) { authentication.authenticate } 25 | let(:passport) { operation.object } 26 | 27 | # Shared 28 | let(:passport_id) { server_passport.id } 29 | let(:passport_state) { server_passport.state } 30 | let(:passport_secret) { server_passport.secret } 31 | let(:passport_chip) { server_passport.chip! } 32 | 33 | # Server 34 | let(:oauth_client) { create :outsider_doorkeeper_application } 35 | let(:insider) { false } 36 | let(:server_user) { create :user, name: 'Emily', tags: %i(cool nice) } 37 | let!(:server_passport) { create :passport, user: server_user, owner_id: server_user.id, ip: ip, agent: agent, insider: insider } 38 | 39 | before do 40 | SSO.config.passport_chip_key = SecureRandom.hex 41 | SSO.config.oauth_client_id = oauth_client.id 42 | SSO.config.oauth_client_secret = oauth_client.secret 43 | end 44 | 45 | context 'no changes' do 46 | before do 47 | operation 48 | end 49 | 50 | context 'outsider passport' do 51 | it 'succeeds' do 52 | expect(operation).to be_success 53 | end 54 | 55 | it 'verifies the passport' do 56 | expect(passport).to be_verified 57 | end 58 | 59 | it 'modifies the passport' do 60 | expect(passport).to be_modified 61 | end 62 | 63 | it 'attaches the user attributes to the passport' do 64 | expect(passport.user).to be_instance_of Hash 65 | expect(passport.user['name']).to eq 'Emily' 66 | expect(passport.user['email']).to eq 'emily@example.com' 67 | expect(passport.user['tags'].sort).to eq %w(cool is_working_from_home nice).sort 68 | end 69 | 70 | context 'outsider oauth client' do 71 | it 'tracks the immediate request IP' do 72 | expect(server_passport.reload.ip).to eq '127.0.0.1' 73 | end 74 | end 75 | 76 | context 'insider oauth client' do 77 | let(:oauth_client) { create :insider_doorkeeper_application } 78 | 79 | it 'tracks the untrusted client IP' do 80 | expect(server_passport.reload.ip).to eq ip 81 | end 82 | end 83 | end 84 | 85 | context 'insider passport' do 86 | let(:insider) { true } 87 | 88 | it 'succeeds' do 89 | expect(operation).to be_success 90 | end 91 | 92 | it 'verifies the passport' do 93 | expect(passport).to be_verified 94 | end 95 | 96 | it 'modifies the passport' do 97 | expect(passport).to be_modified 98 | end 99 | 100 | it 'tracks the untrusted client IP' do 101 | expect(server_passport.reload.ip).to eq ip 102 | end 103 | 104 | it 'attaches the user attributes to the passport' do 105 | expect(passport.user).to be_instance_of Hash 106 | expect(passport.user['name']).to eq 'Emily' 107 | expect(passport.user['email']).to eq 'emily@example.com' 108 | expect(passport.user['tags'].sort).to eq %w(cool is_at_the_office nice).sort 109 | end 110 | end 111 | end 112 | 113 | end 114 | -------------------------------------------------------------------------------- /spec/lib/sso/client/warden/hooks/after_fetch_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe SSO::Client::Warden::Hooks::AfterFetch, type: :request, db: true, stub_benchmarks: true do 4 | 5 | # Client side 6 | let(:warden_env) { {} } 7 | let(:client_params) { { device_id: 'unique device identifier' } } 8 | let(:warden_request) { double :warden_request, ip: ip, user_agent: agent, params: client_params, env: warden_env } 9 | let(:warden) { double :warden, request: warden_request } 10 | let(:hook) { described_class.new passport: client_passport, warden: warden, options: { scope: warden_scope } } 11 | let(:warden_scope) {} 12 | let(:client_user) { double :client_user, name: 'Good old client user' } 13 | let(:client_passport) { ::SSO::Client::Passport.new id: passport_id, secret: passport_secret, state: passport_state, user: client_user } 14 | let(:operation) { hook.call } 15 | 16 | # Shared 17 | let!(:oauth_app) { create :outsider_doorkeeper_application } 18 | let(:passport_id) { server_passport.id } 19 | let(:passport_state) { server_passport.state } 20 | let(:passport_secret) { server_passport.secret } 21 | let(:ip) { '198.51.100.74' } 22 | let(:agent) { 'IE7' } 23 | 24 | # Server side 25 | let!(:server_user) { create :user, tags: %w(wears_glasses never_gives_up) } 26 | let!(:server_passport) { create :passport, user: server_user, owner_id: server_user.id, ip: ip, agent: agent } 27 | 28 | before do 29 | # The server dynamically injects some tags. In order to calculate the user state correctly in our test setup, 30 | # We need to "simulate" what the tags will look like once the server modified them. No big problem. 31 | allow(server_user).to receive(:tags).and_return %w(wears_glasses is_working_from_home never_gives_up) 32 | SSO.config.oauth_client_id = SecureRandom.hex 33 | SSO.config.oauth_client_secret = SecureRandom.hex 34 | end 35 | 36 | context 'invalid passport' do 37 | let(:passport_secret) { SecureRandom.uuid } 38 | 39 | before do 40 | expect(warden).to receive :logout 41 | end 42 | 43 | it 'does not verify the passport' do 44 | expect(client_passport).to_not be_verified 45 | hook.call 46 | expect(client_passport).to_not be_verified 47 | end 48 | 49 | it 'does not modify the passport' do 50 | expect(client_passport).to_not be_modified 51 | hook.call 52 | expect(client_passport).to_not be_modified 53 | end 54 | 55 | it 'fails' do 56 | expect(operation).to be_failure 57 | end 58 | 59 | it 'has a useful error code' do 60 | expect(operation.code).to eq :invalid 61 | end 62 | 63 | it 'meters the invalid passport' do 64 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.server.warden.strategies.passport.passport_authentication_failed', value: 1, tags: nil, data: { caller: 'SSO::Server::Warden::Strategies::Passport' } 65 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.server.warden.strategies.passport.authentication', value: 1, tags: nil, data: { caller: 'SSO::Server::Warden::Strategies::Passport' } 66 | expect(::SSO.config.metric).to receive(:call).with type: :timing, key: 'sso.client.passport.verification.duration', value: 42_000, tags: nil, data: { caller: 'SSO::Client::PassportVerifier' } 67 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.client.warden.hooks.after_fetch.invalid', value: 1, tags: { scope: nil }, data: { passport_id: client_passport.id, caller: 'SSO::Client::Warden::Hooks::AfterFetch' } 68 | hook.call 69 | end 70 | 71 | context 'with warden scope' do 72 | let(:warden_scope) { :vip } 73 | 74 | it 'meters the invalid passport with the scope' do 75 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.server.warden.strategies.passport.passport_authentication_failed', value: 1, tags: nil, data: { caller: 'SSO::Server::Warden::Strategies::Passport' } 76 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.server.warden.strategies.passport.authentication', value: 1, tags: nil, data: { caller: 'SSO::Server::Warden::Strategies::Passport' } 77 | expect(::SSO.config.metric).to receive(:call).with type: :timing, key: 'sso.client.passport.verification.duration', value: 42_000, tags: nil, data: { caller: 'SSO::Client::PassportVerifier' } 78 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.client.warden.hooks.after_fetch.invalid', value: 1, tags: { scope: :vip }, data: { passport_id: client_passport.id, caller: 'SSO::Client::Warden::Hooks::AfterFetch' } 79 | hook.call 80 | end 81 | end 82 | end 83 | 84 | context 'user does not change' do 85 | it 'verifies the passport' do 86 | expect(client_passport).to_not be_verified 87 | hook.call 88 | expect(client_passport).to be_verified 89 | end 90 | 91 | it 'does not modify the passport' do 92 | expect(client_passport).to_not be_modified 93 | hook.call 94 | expect(client_passport).to_not be_modified 95 | end 96 | 97 | it 'does not modify the encapsulated user' do 98 | hook.call 99 | expect(client_passport.user.name).to eq 'Good old client user' 100 | end 101 | 102 | it 'succeeds' do 103 | expect(operation).to be_success 104 | end 105 | 106 | it 'has a useful error code' do 107 | expect(operation.code).to eq :valid 108 | end 109 | 110 | it 'meters the invalid passport' do 111 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.server.warden.strategies.passport.signature_approved_no_changes', value: 1, tags: nil, data: { caller: 'SSO::Server::Warden::Strategies::Passport' } 112 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.server.warden.strategies.passport.authentication', value: 1, tags: nil, data: { caller: 'SSO::Server::Warden::Strategies::Passport' } 113 | expect(::SSO.config.metric).to receive(:call).with type: :timing, key: 'sso.client.passport.verification.duration', value: 42_000, tags: nil, data: { caller: 'SSO::Client::PassportVerifier' } 114 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.client.warden.hooks.after_fetch.valid', value: 1, tags: { scope: nil }, data: { passport_id: client_passport.id, caller: 'SSO::Client::Warden::Hooks::AfterFetch' } 115 | hook.call 116 | end 117 | end 118 | 119 | context 'user attribute changed which is not included in the state digest' do 120 | before do 121 | hook 122 | server_user.update_attribute :name, 'Something new' 123 | end 124 | 125 | it 'verifies the passport' do 126 | expect(client_passport).to_not be_verified 127 | hook.call 128 | expect(client_passport).to be_verified 129 | end 130 | 131 | it 'does not modify the passport' do 132 | expect(client_passport).to_not be_modified 133 | hook.call 134 | expect(client_passport).to_not be_modified 135 | end 136 | 137 | it 'does not modify the encapsulated user' do 138 | hook.call 139 | expect(client_passport.user.name).to eq 'Good old client user' 140 | end 141 | 142 | it 'succeeds' do 143 | expect(operation).to be_success 144 | end 145 | 146 | it 'has a useful error code' do 147 | expect(operation.code).to eq :valid 148 | end 149 | 150 | it 'meters the invalid passport' do 151 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.server.warden.strategies.passport.signature_approved_no_changes', value: 1, tags: nil, data: { caller: 'SSO::Server::Warden::Strategies::Passport' } 152 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.server.warden.strategies.passport.authentication', value: 1, tags: nil, data: { caller: 'SSO::Server::Warden::Strategies::Passport' } 153 | expect(::SSO.config.metric).to receive(:call).with type: :timing, key: 'sso.client.passport.verification.duration', value: 42_000, tags: nil, data: { caller: 'SSO::Client::PassportVerifier' } 154 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.client.warden.hooks.after_fetch.valid', value: 1, tags: { scope: nil }, data: { passport_id: client_passport.id, caller: 'SSO::Client::Warden::Hooks::AfterFetch' } 155 | hook.call 156 | end 157 | end 158 | 159 | context 'user attribute changed which results in a new state digest' do 160 | before do 161 | hook 162 | server_user.update_attribute :email, 'brand-new@example.com' 163 | end 164 | 165 | it 'verifies the passport' do 166 | expect(client_passport).to_not be_verified 167 | hook.call 168 | expect(client_passport).to be_verified 169 | end 170 | 171 | it 'modifies the passport' do 172 | expect(client_passport).to_not be_modified 173 | hook.call 174 | expect(client_passport).to be_modified 175 | end 176 | 177 | it 'updates the client user to reflect the server user' do 178 | hook.call 179 | expect(client_passport.user['name']).to eq server_user.name 180 | end 181 | 182 | it 'succeeds' do 183 | expect(operation).to be_success 184 | end 185 | 186 | it 'has a useful error code' do 187 | expect(operation.code).to eq :valid_and_modified 188 | end 189 | 190 | it 'meters the invalid passport' do 191 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.server.warden.strategies.passport.signature_approved_state_changed', value: 1, tags: nil, data: { caller: 'SSO::Server::Warden::Strategies::Passport' } 192 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.server.warden.strategies.passport.authentication', value: 1, tags: nil, data: { caller: 'SSO::Server::Warden::Strategies::Passport' } 193 | expect(::SSO.config.metric).to receive(:call).with type: :timing, key: 'sso.client.passport.verification.duration', value: 42_000, tags: nil, data: { caller: 'SSO::Client::PassportVerifier' } 194 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.client.warden.hooks.after_fetch.valid_and_modified', value: 1, tags: { scope: nil }, data: { passport_id: client_passport.id, caller: 'SSO::Client::Warden::Hooks::AfterFetch' } 195 | hook.call 196 | end 197 | end 198 | 199 | context 'server request times out' do 200 | before do 201 | expect(::HTTParty).to receive(:get).and_raise ::Net::ReadTimeout 202 | end 203 | 204 | it 'fails' do 205 | expect(operation).to be_failure 206 | end 207 | 208 | it 'has a useful error code' do 209 | expect(operation.code).to eq :server_request_timed_out 210 | end 211 | 212 | it 'meters the timeout' do 213 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.client.warden.hooks.after_fetch.timeout', value: 1, tags: { scope: nil }, data: { timeout_ms: '100ms', passport_id: client_passport.id, caller: 'SSO::Client::Warden::Hooks::AfterFetch' } 214 | hook.call 215 | end 216 | end 217 | 218 | context 'server unreachable' do 219 | before do 220 | expect(::HTTParty).to receive(:get).and_return double(:response, code: 302) 221 | end 222 | 223 | it 'fails' do 224 | expect(operation).to be_failure 225 | end 226 | 227 | it 'has a useful error code' do 228 | expect(operation.code).to eq :server_unreachable 229 | end 230 | 231 | it 'meters the timeout' do 232 | expect(::SSO.config.metric).to receive(:call).with type: :timing, key: 'sso.client.passport.verification.duration', value: 42_000, tags: nil, data: { caller: 'SSO::Client::PassportVerifier' } 233 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.client.warden.hooks.after_fetch.server_unreachable', value: 1, tags: { scope: nil }, data: { passport_id: client_passport.id, caller: 'SSO::Client::Warden::Hooks::AfterFetch' } 234 | hook.call 235 | end 236 | end 237 | 238 | context 'server response not parseable' do 239 | let(:response) { double :response, code: 200 } 240 | 241 | before do 242 | expect(::HTTParty).to receive(:get).and_return response 243 | allow(response).to receive(:parsed_response).and_raise ::JSON::ParserError 244 | end 245 | 246 | it 'fails' do 247 | expect(operation).to be_failure 248 | end 249 | 250 | it 'has a useful error code' do 251 | expect(operation.code).to eq :server_response_not_parseable 252 | end 253 | 254 | it 'meters the timeout' do 255 | expect(::SSO.config.metric).to receive(:call).with type: :timing, key: 'sso.client.passport.verification.duration', value: 42_000, tags: nil, data: { caller: 'SSO::Client::PassportVerifier' } 256 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.client.warden.hooks.after_fetch.server_response_not_parseable', value: 1, tags: { scope: nil }, data: { passport_id: client_passport.id, caller: 'SSO::Client::Warden::Hooks::AfterFetch' } 257 | hook.call 258 | end 259 | end 260 | 261 | context 'server response has no success flag at all' do 262 | let(:response) { double :response, code: 200, parsed_response: { some: :thing } } 263 | 264 | before do 265 | expect(::HTTParty).to receive(:get).and_return response 266 | end 267 | 268 | it 'fails' do 269 | expect(operation).to be_failure 270 | end 271 | 272 | it 'has a useful error code' do 273 | expect(operation.code).to eq :server_response_missing_success_flag 274 | end 275 | 276 | it 'meters the timeout' do 277 | expect(::SSO.config.metric).to receive(:call).with type: :timing, key: 'sso.client.passport.verification.duration', value: 42_000, tags: nil, data: { caller: 'SSO::Client::PassportVerifier' } 278 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.client.warden.hooks.after_fetch.server_response_missing_success_flag', value: 1, tags: { scope: nil }, data: { passport_id: client_passport.id, caller: 'SSO::Client::Warden::Hooks::AfterFetch' } 279 | hook.call 280 | end 281 | end 282 | 283 | context 'server behaves weirdly' do 284 | let(:response) { double :response, code: 200, parsed_response: { success: true } } 285 | 286 | before do 287 | expect(::HTTParty).to receive(:get).and_return response 288 | end 289 | 290 | it 'fails' do 291 | expect(operation).to be_failure 292 | end 293 | 294 | it 'has a useful error code' do 295 | expect(operation.code).to eq :unexpected_server_response_status 296 | end 297 | 298 | it 'meters the timeout' do 299 | expect(::SSO.config.metric).to receive(:call).with type: :timing, key: 'sso.client.passport.verification.duration', value: 42_000, tags: nil, data: { caller: 'SSO::Client::PassportVerifier' } 300 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.client.warden.hooks.after_fetch.unexpected_server_response_status', value: 1, tags: { scope: nil }, data: { passport_id: client_passport.id, caller: 'SSO::Client::Warden::Hooks::AfterFetch' } 301 | hook.call 302 | end 303 | end 304 | 305 | context 'client-side exception' do 306 | before do 307 | expect(::HTTParty).to receive(:get).and_raise ArgumentError 308 | end 309 | 310 | it 'fails' do 311 | expect(operation).to be_failure 312 | end 313 | 314 | it 'has a useful error code' do 315 | expect(operation.code).to eq :client_exception_caught 316 | end 317 | end 318 | 319 | describe '.activate' do 320 | 321 | it 'proxies the options to warden' do 322 | expect(::Warden::Manager).to receive(:after_fetch).with(scope: :insider).and_yield :passport, :warden, :options 323 | described_class.activate scope: :insider 324 | end 325 | end 326 | 327 | end 328 | -------------------------------------------------------------------------------- /spec/lib/sso/client/warden/strategies/passport_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe SSO::Client::Warden::Strategies::Passport, stub_benchmarks: true do 4 | 5 | let(:env) { env_with_params } 6 | let(:strategy) { described_class.new env, scope } 7 | let(:scope) {} 8 | 9 | describe '#valid?' do 10 | context 'with :auth_version and :state' do 11 | let(:env) { env_with_params '/', auth_version: '4.2', state: 'abc' } 12 | 13 | it 'is true' do 14 | expect(strategy).to be_valid 15 | end 16 | end 17 | 18 | context 'blank :auth_version' do 19 | let(:env) { env_with_params '/', auth_version: '', state: 'abc' } 20 | 21 | it 'is false' do 22 | expect(strategy).not_to be_valid 23 | end 24 | end 25 | 26 | context 'blank :state' do 27 | let(:env) { env_with_params '/', auth_version: '5.5', state: '' } 28 | 29 | it 'is false' do 30 | expect(strategy).not_to be_valid 31 | end 32 | end 33 | 34 | context 'nil :auth_version' do 35 | let(:env) { env_with_params '/', state: 'xzy' } 36 | 37 | it 'is false' do 38 | expect(strategy).not_to be_valid 39 | end 40 | end 41 | end 42 | 43 | describe '#authenticate!' do 44 | 45 | context 'invalid passport' do 46 | it 'is a custom response' do 47 | expect(strategy.authenticate!).to eq :custom 48 | end 49 | 50 | it 'meters' do 51 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.client.warden.strategies.passport.authentication', value: 1, tags: { scope: nil }, data: { caller: 'SSO::Client::Warden::Strategies::Passport' } 52 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.client.warden.strategies.passport.passport_authentication_failed', value: 1, tags: { scope: nil }, data: { caller: 'SSO::Client::Warden::Strategies::Passport' } 53 | expect(::SSO.config.metric).to receive(:call).with type: :timing, key: 'sso.client.passport.proxy_verification.duration', value: 42_000, tags: nil, data: { caller: 'SSO::Client::Warden::Strategies::Passport' } 54 | strategy.authenticate! 55 | end 56 | 57 | context 'with scope' do 58 | let(:scope) { :cool } 59 | 60 | it 'meters with the scope' do 61 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.client.warden.strategies.passport.authentication', value: 1, tags: { scope: :cool }, data: { caller: 'SSO::Client::Warden::Strategies::Passport' } 62 | expect(::SSO.config.metric).to receive(:call).with type: :increment, key: 'sso.client.warden.strategies.passport.passport_authentication_failed', value: 1, tags: { scope: :cool }, data: { caller: 'SSO::Client::Warden::Strategies::Passport' } 63 | expect(::SSO.config.metric).to receive(:call).with type: :timing, key: 'sso.client.passport.proxy_verification.duration', value: 42_000, tags: nil, data: { caller: 'SSO::Client::Warden::Strategies::Passport' } 64 | strategy.authenticate! 65 | end 66 | end 67 | end 68 | 69 | it 'fails' do 70 | expect(strategy).to receive(:custom!) do |rack_array| 71 | expect(rack_array.size).to eq 3 72 | expect(rack_array[0]).to eq 200 73 | expect(rack_array[1]).to eq 'Content-Type' => 'application/json' 74 | expect(rack_array[2]).to eq ['{"success":false,"code":"passport_verification_failed"}'] 75 | end 76 | strategy.authenticate! 77 | end 78 | 79 | context 'valid passport' do 80 | let(:operation) { Operations.success :some_code, object: :authentication_object } 81 | let(:authentication) { double :authentication, authenticate: operation } 82 | 83 | before do 84 | allow(::SSO::Client::Authentications::Passport).to receive(:new).and_return authentication 85 | allow(authentication).to receive(:success?).and_return true 86 | end 87 | 88 | it 'is a success response' do 89 | expect(strategy.authenticate!).to eq :success 90 | end 91 | 92 | it 'succeeds' do 93 | expect(strategy).to receive(:success!).with :authentication_object 94 | strategy.authenticate! 95 | end 96 | end 97 | 98 | end 99 | 100 | end 101 | -------------------------------------------------------------------------------- /spec/lib/sso/logging_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe SSO::Logging do 4 | 5 | let(:instance) { MyTestNamespace::MyClass.new } 6 | let(:logger) { ::Logger.new '/dev/null' } 7 | 8 | before do 9 | ::SSO.config.logger = logger 10 | stub_const 'MyTestNamespace', Module.new 11 | stub_const 'MyTestNamespace::MyClass', Class.new { include SSO::Logging } 12 | end 13 | 14 | describe '#logger' do 15 | it 'is a logger' do 16 | expect(instance.logger).to be_instance_of ::Logger 17 | end 18 | end 19 | 20 | describe '#debug' do 21 | it 'delegates to the logger' do 22 | expect(logger).to receive(:debug).with('MyTestNamespace::MyClass') do |_, &block| 23 | expect(block.call).to eq 'Say what?' 24 | end 25 | instance.debug { 'Say what?' } 26 | end 27 | end 28 | 29 | context 'logger missing' do 30 | let(:logger) {} 31 | 32 | describe '#debug' do 33 | it 'does not break' do 34 | instance.debug { 'Should I freak out now?' } 35 | end 36 | end 37 | end 38 | 39 | end 40 | -------------------------------------------------------------------------------- /spec/lib/sso/server/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe SSO::Configuration do 4 | 5 | let(:config) { described_class.new } 6 | 7 | describe '#human_readable_location_for_ip' do 8 | let(:lookup) { SSO.config.human_readable_location_for_ip } 9 | 10 | context 'default' do 11 | it 'is a proc' do 12 | expect(lookup).to be_instance_of Proc 13 | end 14 | 15 | it 'is a String' do 16 | expect(lookup.call('198.51.100.88')).to eq 'Unknown' 17 | end 18 | end 19 | 20 | context 'customized' do 21 | before do 22 | SSO.config.human_readable_location_for_ip = proc { |ip| "Location of #{ip}" } 23 | end 24 | 25 | it 'is a custom String' do 26 | expect(lookup.call('198.51.100.89')).to eq 'Location of 198.51.100.89' 27 | end 28 | end 29 | end 30 | 31 | describe '#environment' do 32 | context 'with Rails' do 33 | it 'is the Rails environment' do 34 | expect(config.environment).to be ::Rails.env 35 | end 36 | end 37 | 38 | context 'without Rails' do 39 | before do 40 | hide_const 'Rails' 41 | stub_const 'ENV', 'RACK_ENV' => 'rackish' 42 | end 43 | 44 | it 'is the RACK_ENV' do 45 | expect(config.environment).to eq 'rackish' 46 | end 47 | 48 | context 'without RACK_ENV' do 49 | before do 50 | stub_const 'ENV', {} 51 | end 52 | 53 | it 'is unknown' do 54 | expect(config.environment).to eq 'unknown' 55 | end 56 | end 57 | end 58 | end 59 | 60 | context 'test environment' do 61 | 62 | describe '#logger' do 63 | context 'with Rails' do 64 | it 'is the Rails logger' do 65 | expect(config.logger).to be ::Rails.logger 66 | end 67 | 68 | it 'is on the Rails logger level' do 69 | expect(config.logger.level).to be ::Rails.logger.level 70 | end 71 | end 72 | 73 | context 'without Rails' do 74 | before do 75 | hide_const 'Rails' 76 | end 77 | 78 | it 'is a Logger' do 79 | expect(config.logger).to be_instance_of ::Logger 80 | end 81 | 82 | it 'is on UNKNOWN level' do 83 | expect(config.logger.level).to eq ::Logger::UNKNOWN 84 | end 85 | end 86 | end 87 | 88 | end 89 | 90 | context 'development environment' do 91 | before do 92 | config.environment = :development 93 | end 94 | 95 | describe '#logger' do 96 | context 'with Rails' do 97 | it 'is the Rails logger' do 98 | expect(config.logger).to be ::Rails.logger 99 | end 100 | 101 | it 'is on the Rails logger level' do 102 | expect(config.logger.level).to be ::Rails.logger.level 103 | end 104 | end 105 | 106 | context 'without Rails' do 107 | before do 108 | hide_const 'Rails' 109 | end 110 | 111 | it 'is a Logger' do 112 | expect(config.logger).to be_instance_of ::Logger 113 | end 114 | 115 | it 'is on DEBUG level' do 116 | expect(config.logger.level).to eq ::Logger::DEBUG 117 | end 118 | end 119 | end 120 | 121 | end 122 | 123 | context 'production environment' do 124 | before do 125 | config.environment = :production 126 | end 127 | 128 | describe '#logger' do 129 | context 'with Rails' do 130 | it 'is the Rails logger' do 131 | expect(config.logger).to be ::Rails.logger 132 | end 133 | 134 | it 'is on the Rails logger level' do 135 | expect(config.logger.level).to be ::Rails.logger.level 136 | end 137 | end 138 | 139 | context 'without Rails' do 140 | before do 141 | hide_const 'Rails' 142 | end 143 | 144 | it 'is a Logger' do 145 | expect(config.logger).to be_instance_of ::Logger 146 | end 147 | 148 | it 'is on WARN level' do 149 | expect(config.logger.level).to eq ::Logger::WARN 150 | end 151 | end 152 | end 153 | 154 | end 155 | 156 | end 157 | -------------------------------------------------------------------------------- /spec/lib/sso/server/middleware/passport_destruction_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe SSO::Server::Middleware::PassportDestruction, type: :request, db: true do 4 | 5 | let(:updated_passport) { ::SSO::Server::Passports.find(passport.id).object } 6 | 7 | before do 8 | Timecop.freeze 9 | end 10 | 11 | context 'passport exists' do 12 | let!(:passport) { create :passport } 13 | 14 | it 'succeeds' do 15 | delete "/oauth/sso/v1/passports/#{passport.id}" 16 | expect(response.status).to eq 200 17 | end 18 | 19 | it 'revokes the passport' do 20 | delete "/oauth/sso/v1/passports/#{passport.id}" 21 | expect(updated_passport.revoked_at.to_i).to eq Time.now.to_i 22 | end 23 | end 24 | 25 | end 26 | -------------------------------------------------------------------------------- /spec/lib/sso/server/passports_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe SSO::Server::Passports do 4 | let(:passports) { described_class } 5 | 6 | before do 7 | Timecop.freeze 8 | end 9 | 10 | describe '.update_activity' do 11 | let(:env) { { 'REMOTE_ADDR' => ip, 'rack.input' => '', 'HTTP_USER_AGENT' => 'Safari', 'QUERY_STRING' => 'agent=Chrome&ip=198.51.100.1&device_id=my_device_id' } } 12 | let(:ip) { '198.51.100.99' } 13 | let(:request) { Rack::Request.new env } 14 | 15 | let(:another_env) { { 'REMOTE_ADDR' => another_ip, 'rack.input' => '', 'HTTP_USER_AGENT' => 'Opera', 'QUERY_STRING' => 'agent=Firefox&ip=198.51.100.2&device_id=another_my_device_id' } } 16 | let(:another_ip) { '198.51.100.100' } 17 | let(:another_request) { Rack::Request.new another_env } 18 | 19 | let(:insider) { false } 20 | let(:passport) { create :passport, activity_at: 1.week.ago, insider: insider } 21 | 22 | before do 23 | passports.update_activity passport_id: passport.id, request: request 24 | end 25 | 26 | context 'outsider' do 27 | it 'creates a brand new stamp' do 28 | expect(passport.reload.stamps).to eq '198.51.100.99' => Time.now.to_i.to_s 29 | end 30 | 31 | it 'tracks the imediate IP' do 32 | expect(passport.reload.ip).to eq '198.51.100.99' 33 | expect(passport.reload.agent).to eq 'Safari' 34 | expect(passport.reload.device).to eq 'my_device_id' 35 | end 36 | 37 | it 'updates activity_at' do 38 | expect(passport.reload.activity_at.to_i).to eq Time.now.to_i 39 | end 40 | 41 | context 'another request' do 42 | before do 43 | Timecop.freeze 5.minutes.from_now 44 | passports.update_activity passport_id: passport.id, request: another_request 45 | end 46 | 47 | it 'adds another stamp' do 48 | expect(passport.reload.stamps).to eq '198.51.100.99' => 5.minutes.ago.to_i.to_s, '198.51.100.100' => Time.now.to_i.to_s 49 | end 50 | 51 | it 'updates activity_at' do 52 | expect(passport.reload.activity_at.to_i).to eq Time.now.to_i 53 | end 54 | 55 | it 'updates the imediate IP' do 56 | expect(passport.reload.ip).to eq '198.51.100.100' 57 | expect(passport.reload.agent).to eq 'Opera' 58 | expect(passport.reload.device).to eq 'another_my_device_id' 59 | end 60 | end 61 | end 62 | 63 | context 'insider' do 64 | let(:insider) { true } 65 | 66 | it 'creates a brand new stamp' do 67 | expect(passport.reload.stamps).to eq '198.51.100.1' => Time.now.to_i.to_s 68 | end 69 | 70 | it 'tracks the proxied IP' do 71 | expect(passport.reload.ip).to eq '198.51.100.1' 72 | expect(passport.reload.agent).to eq 'Chrome' 73 | expect(passport.reload.device).to eq 'my_device_id' 74 | end 75 | 76 | it 'updates activity_at' do 77 | expect(passport.reload.activity_at.to_i).to eq Time.now.to_i 78 | end 79 | 80 | context 'another request' do 81 | before do 82 | Timecop.freeze 5.minutes.from_now 83 | passports.update_activity passport_id: passport.id, request: another_request 84 | end 85 | 86 | it 'adds another stamp' do 87 | expect(passport.reload.stamps).to eq '198.51.100.1' => 5.minutes.ago.to_i.to_s, '198.51.100.2' => Time.now.to_i.to_s 88 | end 89 | 90 | it 'updates activity_at' do 91 | expect(passport.reload.activity_at.to_i).to eq Time.now.to_i 92 | end 93 | 94 | it 'updates the proxied IP' do 95 | expect(passport.reload.ip).to eq '198.51.100.2' 96 | expect(passport.reload.agent).to eq 'Firefox' 97 | expect(passport.reload.device).to eq 'another_my_device_id' 98 | end 99 | end 100 | end 101 | 102 | end 103 | 104 | end 105 | -------------------------------------------------------------------------------- /spec/lib/sso/server/warden/hooks/before_logout_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe SSO::Server::Warden::Hooks::BeforeLogout do 4 | 5 | let(:proc) { described_class.to_proc } 6 | let(:calling) { proc.call(user, warden, options) } 7 | let(:user) { double :user } 8 | let(:request) { double :request, params: params.stringify_keys } 9 | let(:params) { { passport_id: passport.id } } 10 | let(:warden) { double :warden, request: request } 11 | let(:options) { double :options } 12 | let(:passport) { create :passport } 13 | 14 | before do 15 | Timecop.freeze 16 | end 17 | 18 | describe '.to_proc' do 19 | it 'is a proc' do 20 | expect(proc).to be_instance_of Proc 21 | end 22 | end 23 | 24 | describe '#call' do 25 | it 'accepts the three warden arguments and returns nothing' do 26 | expect(calling).to be_nil 27 | end 28 | 29 | it 'revokes the passport' do 30 | calling 31 | passport.reload 32 | expect(passport.revoked_at.to_i).to eq Time.now.to_i 33 | expect(passport.revoke_reason).to eq 'logout' 34 | end 35 | 36 | it 'survives an exception' do 37 | allow(described_class).to receive(:new).and_raise NoMethodError, 'I am a problem' 38 | expect(::SSO.config.logger).to receive(:error) 39 | expect(calling).to be_nil 40 | end 41 | end 42 | 43 | end 44 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RACK_ENV'] = 'test' 2 | 3 | unless ENV['TRAVIS'] 4 | require 'simplecov' 5 | SimpleCov.add_filter '/spec/' 6 | SimpleCov.start 7 | end 8 | 9 | require 'sso' 10 | require 'sso/server' # <- The dummy app is an SSO Server 11 | require 'sso/client' # <- For integration tests from client to server 12 | 13 | require File.expand_path('../dummy/config/environment', __FILE__) 14 | 15 | require 'rspec/rails' 16 | require 'factory_girl_rails' 17 | require 'database_cleaner' 18 | require 'timecop' 19 | require 'webmock' 20 | 21 | Dir[Pathname.pwd.join('spec/support/**/*.rb')].each { |f| require f } 22 | 23 | RSpec.configure do |config| 24 | 25 | config.include FactoryGirl::Syntax::Methods 26 | config.include SSO::Test::Helpers 27 | 28 | config.color = true 29 | config.disable_monkey_patching! 30 | config.fail_fast = true 31 | config.raise_errors_for_deprecations! 32 | config.use_transactional_fixtures = false 33 | 34 | config.before :suite do 35 | DatabaseCleaner.strategy = :transaction 36 | DatabaseCleaner.clean_with :truncation 37 | SSO.config.exception_handler = nil 38 | SSO.config.passport_chip_key = nil 39 | SSO.config.oauth_client_id = nil 40 | SSO.config.oauth_client_secret = nil 41 | SSO.config.metric = ::SSO::Test::Helpers.meter 42 | end 43 | 44 | config.before :each do 45 | redirect_httparty_to_rails_stack 46 | end 47 | 48 | config.before :each, db: true do 49 | DatabaseCleaner.start 50 | end 51 | 52 | config.before :each, reveal_exceptions: true do 53 | SSO.config.exception_handler = proc { |exception| fail exception } 54 | end 55 | 56 | config.before :each, stub_benchmarks: true do 57 | stub_benchmarks 58 | end 59 | 60 | config.after :each do 61 | Timecop.return 62 | SSO.config.exception_handler = nil 63 | SSO.config.passport_chip_key = nil 64 | SSO.config.oauth_client_id = nil 65 | SSO.config.oauth_client_secret = nil 66 | end 67 | 68 | config.after :each, db: true do 69 | DatabaseCleaner.clean 70 | end 71 | 72 | end 73 | -------------------------------------------------------------------------------- /spec/support/factories/doorkeeper/application.rb: -------------------------------------------------------------------------------- 1 | FactoryGirl.define do 2 | factory :parent_of_all_doorkeeper_applications, class: Doorkeeper::Application do 3 | 4 | factory :insider_doorkeeper_application do 5 | scopes { :insider } 6 | end 7 | 8 | factory :outsider_doorkeeper_application do 9 | scopes { :outsider } 10 | end 11 | 12 | uid { SecureRandom.hex } 13 | secret { SecureRandom.hex } 14 | name { %w(Alpha Beta Gamma Delta Epsilon).sample } 15 | redirect_uri { "https://#{name.downcase}.example.com#{['/subpath', nil].sample}/auth/sso/callback" } 16 | 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/support/factories/server/passport.rb: -------------------------------------------------------------------------------- 1 | FactoryGirl.define do 2 | sequence(:owner_id) { |n| (n * 2) + 424242 } 3 | sequence(:ip) { |n| IPAddr.new("198.51.100.#{n}").to_s } 4 | 5 | factory :parent_of_all_passports, class: SSO::Server::Passport do 6 | 7 | factory :passport do 8 | end 9 | 10 | owner_id { generate(:owner_id) } 11 | ip { generate(:ip) } 12 | 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/support/factories/server/user.rb: -------------------------------------------------------------------------------- 1 | FactoryGirl.define do 2 | factory :parent_of_all_users, class: User do 3 | 4 | factory :user do 5 | end 6 | 7 | name { %w(Alice Bob Carol Eve Frank).sample } 8 | email { "#{name.downcase}@example.com" } 9 | password { %w(p4ssword s3same l3tmein).sample } 10 | tags { [[%w(password_expired superuser).sample, %w(admin confirmed).sample], []].sample } 11 | vip { [true, false].sample } 12 | 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/support/sso/test.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Test 3 | 4 | def self.strip_cookies 5 | false 6 | end 7 | 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/support/sso/test/cookie_stripper.rb: -------------------------------------------------------------------------------- 1 | module SSO 2 | module Test 3 | # There is no good way to simulate disabled cookies in Rails, 4 | # so we inject this Middleware which actually removes them from our incoming requests. 5 | # 6 | class CookieStripper 7 | 8 | def initialize(app) 9 | fail 'What are you doing?' unless Rails.env.test? 10 | @app = app 11 | end 12 | 13 | def call(env) 14 | Rack::Request.new(env).cookies.clear if SSO::Test.strip_cookies 15 | @app.call(env) 16 | end 17 | 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/support/sso/test/helpers.rb: -------------------------------------------------------------------------------- 1 | require 'httparty' 2 | 3 | module SSO 4 | module Test 5 | module Helpers 6 | 7 | def self.meter 8 | proc {} 9 | end 10 | 11 | def stub_benchmarks 12 | allow(Benchmark).to receive(:realtime) do |&block| 13 | block.call 14 | 42 15 | end 16 | end 17 | 18 | # Inspired by Warden::Spec::Helpers 19 | def env_with_params(path = '/', params = {}, env = {}) 20 | method = params.delete(:method) || 'GET' 21 | env = { 'HTTP_VERSION' => '1.1', 'REQUEST_METHOD' => "#{method}" }.merge(env) 22 | Rack::MockRequest.env_for "#{path}?#{Rack::Utils.build_query(params)}", env 23 | end 24 | 25 | def redirect_httparty_to_rails_stack 26 | redirect_httparty :get 27 | redirect_httparty :post 28 | end 29 | 30 | private 31 | 32 | def redirect_httparty(method) 33 | allow(HTTParty).to receive(method) do |url, options| 34 | ::SSO.config.logger.warn('SSO::Test::Helpers') do 35 | "RSpec caught an outgoing HTTParty request to #{url.inspect} and re-routes it back into the Rails integration test framework..." 36 | end 37 | 38 | url = URI.parse url 39 | expect(url.host).to include '.example.com' 40 | expect(url.scheme).to eq 'https' 41 | 42 | if options[:basic_auth].present? 43 | basic_auth_header = 'Basic ' + Base64.encode64("#{options[:basic_auth][:username]}:#{options[:basic_auth][:password]}") 44 | options[:headers]['HTTP_AUTHORIZATION'] = basic_auth_header 45 | end 46 | 47 | case method 48 | when :post 49 | query_string = options[:query].to_query.present? ? "?#{options[:query].to_query}" : nil 50 | send method, "#{url.path}#{query_string}", options[:body], options[:headers] 51 | when :get 52 | send method, url.path, options[:query], options[:headers] 53 | else 54 | fail NotImplementedError 55 | end 56 | 57 | convert_rails_response_to_httparty_response response 58 | end 59 | end 60 | 61 | def convert_rails_response_to_httparty_response(response) 62 | parsed_response = JSON.parse response.body 63 | OpenStruct.new code: response.code.to_i, parsed_response: parsed_response 64 | 65 | rescue JSON::ParserError 66 | ::SSO.config.logger.warn('SSO::Test::Helpers') do 67 | 'It looks like I could not parse that JSON response. I will behave just like HTTParty and not raise an Exception for this.' 68 | end 69 | OpenStruct.new code: response.code.to_i 70 | end 71 | 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /sso.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'sso' 3 | s.version = '0.1.4' 4 | s.date = '2015-03-26' 5 | s.summary = 'Leveraging Doorkeeper as single-sign-on OAuth server.' 6 | s.description = "#{s.summary} To provide true single-sign-OUT, every request on an OAuth client app is verified with the SSO server." 7 | s.author = 'halo' 8 | s.license = 'MIT' 9 | s.homepage = 'https://github.com/halo/sso' 10 | 11 | s.required_ruby_version = '>= 2.0.0' 12 | 13 | s.files = Dir['lib/**/*'] & `git ls-files -z`.split("\0") 14 | s.test_files = Dir['spec/**/*'] & `git ls-files -z`.split("\0") 15 | 16 | # Server 17 | s.add_runtime_dependency 'doorkeeper', '>= 2.0.0' 18 | 19 | # Client 20 | s.add_runtime_dependency 'httparty' 21 | 22 | # Both 23 | s.add_runtime_dependency 'omniauth-oauth2' 24 | s.add_runtime_dependency 'signature', '>= 0.1.8' 25 | s.add_runtime_dependency 'warden', '>= 1.2.3' 26 | s.add_runtime_dependency 'operation', '~> 0.0.3' 27 | 28 | # Development 29 | s.add_development_dependency 'database_cleaner' 30 | s.add_development_dependency 'factory_girl_rails' 31 | s.add_development_dependency 'guard-rspec', '>= 4.2.3' 32 | s.add_development_dependency 'guard-rubocop' 33 | s.add_development_dependency 'pg' 34 | s.add_development_dependency 'rails' 35 | s.add_development_dependency 'rspec-rails' 36 | s.add_development_dependency 'rubocop' 37 | s.add_development_dependency 'simplecov', '>= 0.9.0' 38 | s.add_development_dependency 'timecop' 39 | s.add_development_dependency 'webmock' 40 | end 41 | --------------------------------------------------------------------------------