web47sso 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "web47sso"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,5 @@
1
+ en:
2
+ ui_form:
3
+ sso_servers:
4
+ index:
5
+ title: SSO Servers
data/coverage_merge.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'simplecov'
4
+ require 'simplecov_lcov_formatter'
5
+
6
+ # Load up the files in the workspace and merge or collate them together.
7
+ # This should kick out a single coverage file out
8
+ SimpleCov::Formatter::LcovFormatter.config do |c|
9
+ c.single_report_path = 'coverage/final.lcov'
10
+ c.report_with_single_file = true
11
+ end
12
+ SimpleCov.collate Dir['/tmp/workspace/lcov/*.json'], 'rails' do
13
+ add_filter '/spec/'
14
+ add_filter '/config/'
15
+ add_filter '/lib/'
16
+ add_filter '/test/'
17
+ add_filter '/vendor/'
18
+ add_filter '/config/'
19
+ add_filter '/db/'
20
+ add_filter '/public/'
21
+ add_filter '/coverage_merge.rb'
22
+ add_filter '/Gemfile'
23
+ add_filter '/Rakefile'
24
+ add_filter '/Gemfile.lock'
25
+ add_filter '/rubocop.yml'
26
+ enable_coverage :branch
27
+ formatter SimpleCov::Formatter::LcovFormatter
28
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Manage SSO severs
5
+ #
6
+ module CoreSsoServersController
7
+ include CoreController
8
+ include App47Logger
9
+
10
+ def index
11
+ @sso_servers = SsoServer.asc(:name)
12
+ end
13
+
14
+ def update
15
+ sso_server.update_attributes_and_log!(current_user, sso_server_params)
16
+ flash[:notice] = 'SSO Server updated'
17
+ redirect_to_referrer sso_servers_path
18
+ rescue StandardError => error
19
+ log_controller_error error
20
+ render :edit
21
+ end
22
+
23
+ def create
24
+ sso_server.save_and_log! current_user, sso_server_params
25
+ flash[:notice] = 'SSO Server created'
26
+ redirect_to_referrer sso_servers_path
27
+ rescue StandardError => error
28
+ log_controller_error error
29
+ render :new
30
+ end
31
+
32
+ def destroy
33
+ sso_server.destroy_and_log current_user
34
+ flash[:notice] = 'SSO Server removed'
35
+ redirect_to_referrer sso_servers_path
36
+ rescue StandardError => error
37
+ log_controller_error error, true
38
+ redirect_to_referrer sso_servers_path
39
+ end
40
+
41
+ private
42
+
43
+ def sso_server
44
+ @sso_server
45
+ end
46
+
47
+ #
48
+ # Parameters
49
+ #
50
+ def sso_server_params
51
+ params[:sso_server][:active] ||= 'off'
52
+ params[:sso_server].permit(allowed_param_names)
53
+ end
54
+
55
+ def allowed_param_names
56
+ SsoServer.allowed_param_names
57
+ end
58
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Capture a secure request transaction
5
+ #
6
+ class SecureRequest
7
+ include StandardModel
8
+ #
9
+ # Fields
10
+ #
11
+ field :code, { type: String }
12
+ field :used, { type: Boolean, default: false }
13
+ field :ip_address, { type: String }
14
+ field :user_agent, { type: String }
15
+ field :valid_duration_hrs, { type: Integer, default: 1 }
16
+ # Make consumed and alias synonyms
17
+ alias consumed? used
18
+ # Make state and ID the same
19
+ alias state id
20
+
21
+ # @abstract Consume the requests, but also verify it's valid before doing so
22
+ # @param [Hash] options - options to update based on request
23
+ def consume!(options = {})
24
+ raise 'Expired request' if expired?
25
+ raise 'Consumed request' if consumed?
26
+
27
+ consume(options)
28
+ end
29
+
30
+ # @@abstract Mark this request as consumed recording the user and request information if given
31
+ # @param [Hash] options - options to update based on request
32
+ def consume(options = {})
33
+ self.used = true
34
+ if options[:request].present?
35
+ self.ip_address = options[:request].remote_ip
36
+ self.user_agent = options[:request].user_agent
37
+ end
38
+ save!
39
+ end
40
+
41
+ #
42
+ # What is the current status?
43
+ #
44
+ def status
45
+ if expired?
46
+ 'Expired'
47
+ elsif consumed?
48
+ 'Consumed'
49
+ else
50
+ 'New'
51
+ end
52
+ end
53
+
54
+ # @abstract Check if expired or used
55
+ # @return Boolean - True if we are good to go, false otherwise
56
+ def good?
57
+ !consumed? && not_expired?
58
+ end
59
+
60
+ # @abstract Check if expired
61
+ # @return Boolean - True if we are good to go, false otherwise
62
+ def expired?
63
+ created_at < valid_duration_hrs.hours.ago.utc
64
+ rescue StandardError
65
+ true
66
+ end
67
+
68
+ # @abstract Check if not expired
69
+ # @return Boolean
70
+ def not_expired?
71
+ !expired?
72
+ end
73
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Google OAuth 2.0
5
+ #
6
+ class SsoGoogleServer < SsoOauthServer
7
+ #
8
+ # Google Server SSO name
9
+ #
10
+ def display_name
11
+ 'Google OAuth 2.0'
12
+ end
13
+
14
+ private
15
+
16
+ #
17
+ # Google SSO settings
18
+ #
19
+ def customize_defaults
20
+ self.server_url ||= 'https://accounts.google.com'
21
+ self.auth_url ||= 'https://accounts.google.com/o/oauth2/auth'
22
+ self.token_url ||= 'https://accounts.google.com/o/oauth2/token'
23
+ self.profile_url ||= 'https://www.googleapis.com/userinfo/v2/me'
24
+ self.redirect_path ||= 'auth/google'
25
+ self.scopes ||= 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email'
26
+ super
27
+ end
28
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # OAuth 2.0 Server
5
+ #
6
+ class SsoOauthServer < SsoServer
7
+ # Fields
8
+ field :server_url, type: String
9
+ field :auth_url, type: String
10
+ field :token_url, type: String
11
+ field :profile_url, type: String
12
+ field :redirect_path, type: String
13
+ field :client_id, type: String
14
+ field :client_secret, type: String
15
+ field :scopes, type: String
16
+ # Validations
17
+ validates :client_id, presence: true
18
+ validates :client_secret, presence: true
19
+
20
+ # @abstract Using the appropriate SSO server configuration obtain the user profile address
21
+ # @return Hash - User profile as a hash
22
+ def user_profile(code)
23
+ # Exchange the code to validate the token
24
+ token = exchange(SystemConfiguration.base_url, code)
25
+ log_debug "user_profile token: #{token.inspect}"
26
+ raise 'Invalid token' unless token.present? && token.valid?
27
+
28
+ # Get scoped information using the access token
29
+ user_profile = get_profile(token)
30
+ log_debug "user_profile user_profile: #{user_profile.inspect}"
31
+ raise 'Failed to retrieve user profile from provider' if user_profile.blank?
32
+
33
+ # user_profile.symbolize_keys
34
+ clean_user_profile(user_profile)
35
+ end
36
+
37
+ # @abstract Default mapping of user profile keys to what the caller is expecting
38
+ # @return Hash - Mapping of key names for the user profile
39
+ def profile_key_mapping
40
+ @profile_key_mapping ||= { name: 'name', email: 'email' }
41
+ end
42
+
43
+ # @abstract Clean up the user profile to only return what is listed in the key mapping. Not everything
44
+ # @return Hash - The user profile using the mapping from this class
45
+ def clean_user_profile(user_profile)
46
+ profile = {}
47
+ profile_key_mapping.each do |key, value|
48
+ profile[key] = user_profile[value]
49
+ end
50
+ profile
51
+ end
52
+
53
+ # @abstract OAuth 2.0 provider's authorization endpoint URL
54
+ # @return URL - The URL for the authorization end point
55
+ def auth_endpoint
56
+ return nil if server_url.blank? || auth_url.blank?
57
+
58
+ prefix_server auth_url
59
+ end
60
+
61
+ # @abstract OAuth 2.0 provider's token endpoint URL
62
+ # @return URL - the Token endpoint
63
+ def token_endpoint
64
+ return nil if server_url.blank? || token_url.blank?
65
+
66
+ prefix_server token_url
67
+ end
68
+
69
+ # @abstract OAuth 2.0 provider's profile endpoint URL
70
+ # @return Profile end point
71
+ def profile_endpoint
72
+ return nil if server_url.blank? || profile_url.blank?
73
+
74
+ prefix_server profile_url
75
+ end
76
+
77
+ # @abstract URL to redirect users to going through the flow
78
+ # @return URL - Redirect URL for this server
79
+ def redirect_uri(root_url = SystemConfiguration.base_url)
80
+ return root_url if redirect_path.blank? || root_url.blank?
81
+
82
+ [root_url, (root_url.end_with?('/') ? '' : '/'), redirect_path.gsub(%r{^/+}, '')].join
83
+ end
84
+
85
+ # @abstract Scope specifies optional requested permissions
86
+ # @return Array[String] - Scopes for this server
87
+ def requested_scopes
88
+ scopes.present? ? scopes.split(' ') : []
89
+ end
90
+
91
+ #
92
+ # Returns a URL to the OAuth 2.0 provider's consent page
93
+ # asking for permissions for the given scopes explicitly.
94
+ #
95
+ # State is a token to protect the user from CSRF attacks. You
96
+ # must always provide a non-zero string and validate that it
97
+ # matches the state query parameter on your redirect callback.
98
+ #
99
+ def auth_code_url(request, parameters = {})
100
+ super
101
+ uri = URI(auth_endpoint)
102
+ uri.query = parameters.merge(response_type: 'code',
103
+ client_id: client_id,
104
+ redirect_uri: redirect_uri,
105
+ scope: requested_scopes.join(' '),
106
+ state: request.state).to_query
107
+ uri.to_s
108
+ end
109
+
110
+ #
111
+ # Exchange will convert an authorization code into a token.
112
+ #
113
+ def exchange(root_url, code)
114
+ response = send_request_for_exchange(root_url, code)
115
+ log_debug response.body.inspect
116
+ raise 'Cannot fetch token' unless response.code.between? 200, 299
117
+
118
+ extract_token(response_values(response))
119
+ rescue StandardError => error
120
+ log_error 'Unable to retrieve profile', error
121
+ nil
122
+ end
123
+
124
+ # @abstract Get the profile given an access token, can be overriden by concrete implementations.
125
+ # @param [SsoOauthToken] access_token
126
+ # @return [Hash]
127
+ def get_profile(access_token)
128
+ log_debug "get_profile profile_endpoint: #{profile_endpoint} with token: #{access_token.inspect}"
129
+ response = RestClient.get(profile_endpoint, Authorization: access_token.authorization)
130
+ log_debug "get_profile response: #{response.body}"
131
+ response_values(response)
132
+ end
133
+
134
+ #
135
+ # Based on the response content type get a hash of values.
136
+ #
137
+ def response_values(response)
138
+ log_debug "response_values response: #{response.inspect}"
139
+ case response.headers[:content_type]
140
+ when 'application/x-www-form-urlencoded', 'text/plain'
141
+ log_debug "form or plain text response: #{response.body}"
142
+ log_debug Rack::Utils.parse_nested_query(response.body).inspect
143
+ Rack::Utils.parse_nested_query(response.body)
144
+ else
145
+ log_debug "json: #{response.body}"
146
+ JSON.parse(response.body)
147
+ end
148
+ end
149
+
150
+ #
151
+ # Token will come in the following schema (hopefully)
152
+ #
153
+ # - AccessToken: access_token
154
+ # - TokenType: token_type
155
+ # - Refresh Token: refresh_token
156
+ # - Expiry: expires_in, expires (thank you FB)
157
+ #
158
+ def extract_token(vals)
159
+ log_debug "extract_token: #{vals.inspect}"
160
+ token = SsoOauthToken.new
161
+ token.access_token = vals['access_token']
162
+ token.token_type = vals['token_type']
163
+ token.refresh_token = vals['refresh_token']
164
+
165
+ # Convert `e` to integer and then to a time
166
+ e = vals['expires_in']
167
+ e = vals['expires'] if e.blank?
168
+ token.expiry = Time.now.utc + e.to_i.seconds unless e.blank?
169
+
170
+ token
171
+ end
172
+
173
+ private
174
+
175
+ #
176
+ # If a relative path was given prepend the server as the base
177
+ #
178
+ def prefix_server(path)
179
+ if path.start_with?(server_url) || path.starts_with?('http')
180
+ path
181
+ else
182
+ "#{server_url.chomp('/')}/#{path.gsub(%r{^/+}, '')}"
183
+ end
184
+ end
185
+
186
+ #
187
+ # Send HTTP POST to exchange the authorization code for an access token.
188
+ #
189
+ def send_request_for_exchange(root_url, code)
190
+ RestClient.post(token_endpoint,
191
+ {
192
+ grant_type: 'authorization_code',
193
+ client_id: client_id,
194
+ client_secret: client_secret,
195
+ code: code,
196
+ redirect_uri: redirect_uri(root_url),
197
+ scope: requested_scopes.join(' ')
198
+ },
199
+ content_type: 'application/x-www-form-urlencoded',
200
+ accept: :json)
201
+ rescue RestClient::ExceptionWithResponse => error
202
+ log_error "Unable to fetch OAuth token #{inspect}, #{response.inspect}", error
203
+ log_error "Redirect_uri #{redirect_uri(root_url)}"
204
+ log_error "response body #{response.body}"
205
+ raise error
206
+ end
207
+
208
+ def customize_defaults
209
+ self.redirect_path ||= 'auth/oauth'
210
+ super
211
+ end
212
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # OAuth 2.0 Token
5
+ #
6
+ class SsoOauthToken
7
+ attr_accessor :access_token, :token_type, :refresh_token, :expiry
8
+
9
+ # @abstract The authorization string for this token type
10
+ # @return String
11
+ def authorization
12
+ [type, access_token].compact.join(' ')
13
+ end
14
+
15
+ def type
16
+ @token_type.presence || 'Bearer'
17
+ end
18
+
19
+ def valid?
20
+ @access_token.present? && (@expiry.nil? || Time.now.utc < @expiry)
21
+ end
22
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Base server container for an account which can have 0..N sso servers configured.
5
+ #
6
+ class SsoServer
7
+ include StandardModel
8
+ include SearchAble
9
+ #
10
+ # fields
11
+ #
12
+ field :active, type: Boolean, default: false
13
+ field :name, type: String
14
+ field :logout_url, type: String
15
+ #
16
+ # relationships
17
+ #
18
+ has_many :users, inverse_of: :sso_server, dependent: :nullify, class_name: 'User'
19
+ has_many :requests, inverse_of: :sso_server, class_name: 'SsoUserLoginRequest', dependent: :destroy
20
+ #
21
+ # Validations
22
+ #
23
+ validates :name, presence: true, uniqueness: true
24
+ validates :logout_url, url: true, allow_blank: true
25
+ #
26
+ # callbacks
27
+ #
28
+ after_initialize :customize_defaults
29
+
30
+ # @abstract Default is to do nothing, but subclasses may override this
31
+ def customize_defaults; end
32
+
33
+ # @abstract Display name for this server, should be overridden by concrete implementations.
34
+ # @return String
35
+ def display_name
36
+ 'SSO Server'
37
+ end
38
+
39
+ # @abstract Return the welcome message
40
+ # @return String
41
+ def display_instructions
42
+ "Click to get started with #{display_name}"
43
+ end
44
+
45
+ # @abstract Using the appropriate SSO server configuration obtain the
46
+ # user profile address, this should be implemented by the child class.
47
+ # @return Hash
48
+ # @raise NotImplementedError if the method is not implemented in the concrete class
49
+ def user_profile(_code)
50
+ raise NotImplementedError, 'Failed to retrieve user profile from provider'
51
+ end
52
+
53
+ # @abstract Get the authentication URL for the SSO Server, must be implemented by the concrete class
54
+ # @return URL
55
+ def auth_code_url(request, _parameters = {})
56
+ return if request.is_a?(SsoUserLoginRequest)
57
+
58
+ request = request.becomes(SsoUserLoginRequest)
59
+ request.update! sso_server: self
60
+ end
61
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Capture the request of an SSO authentication
5
+ #
6
+ class SsoUserLoginRequest < UserLoginRequest
7
+ #
8
+ # Relationships
9
+ #
10
+ belongs_to :sso_server, { inverse_of: :requests }
11
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Capture the request of a User authentication
5
+ #
6
+ class UserLoginRequest < UserRequest
7
+ #
8
+ # Fields
9
+ #
10
+ field :browser_uid, { type: String }
11
+ #
12
+ # Validations
13
+ #
14
+ validates :browser_uid, presence: true
15
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Capture the request for a user
5
+ #
6
+ class UserRequest < SecureRequest
7
+ #
8
+ # Relationships
9
+ #
10
+ belongs_to :user, { inverse_of: :requests, optional: true, class_name: 'User' }
11
+
12
+ # @abstract Mark this request as consumed recording the user and request information if given
13
+ def consume(options = {})
14
+ self.user = options[:user] if options[:user].present?
15
+ super
16
+ end
17
+
18
+ # @abstract Return the end point for the given request
19
+ def default_uri(endpoint_path = 'home')
20
+ [SystemConfiguration.base_url, endpoint_path].join('/')
21
+ end
22
+ end
@@ -0,0 +1,3 @@
1
+ module Web47sso
2
+ VERSION = '0.1.0'
3
+ end
data/lib/web47sso.rb ADDED
@@ -0,0 +1,16 @@
1
+ require "web47sso/version"
2
+ require 'app/models/secure_request'
3
+ require 'app/models/sso_server'
4
+ require 'app/models/sso_oauth_server'
5
+ require 'app/models/sso_google_server'
6
+ require 'app/models/sso_oauth_token'
7
+ require 'app/models/user_request'
8
+ require 'app/models/user_login_request'
9
+ require 'app/models/sso_user_login_request'
10
+
11
+ require 'app/controllers/concerns/core_sso_servers_controller'
12
+
13
+ module Web47sso
14
+ class Error < StandardError; end
15
+ # Your code goes here...
16
+ end
data/web47sso.gemspec ADDED
@@ -0,0 +1,61 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "web47sso/version"
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "web47sso"
7
+ spec.version = Web47sso::VERSION
8
+ spec.authors = ["Chris Schroeder"]
9
+ spec.email = ["chris@app47.com"]
10
+
11
+ spec.summary = %q{App47 Web SSO}
12
+ spec.description = %q{Single Sign On (SSO) components used in several App47 Apps}
13
+ spec.homepage = "https://github.com/App47/web47sso.git"
14
+ spec.license = "MIT"
15
+
16
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
17
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
18
+ if spec.respond_to?(:metadata)
19
+ spec.metadata["allowed_push_host"] = 'https://rubygems.org'
20
+
21
+ spec.metadata["homepage_uri"] = spec.homepage
22
+ spec.metadata["source_code_uri"] = "https://github.com/App47/web47sso.git"
23
+ else
24
+ raise "RubyGems 2.0 or newer is required to protect against " \
25
+ "public gem pushes."
26
+ end
27
+
28
+ # Specify which files should be added to the gem when it is released.
29
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
30
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
31
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
32
+ end
33
+ spec.bindir = "bin"
34
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
35
+ spec.require_paths = ["lib"]
36
+
37
+ spec.add_runtime_dependency 'mongoid', '~> 9.0'
38
+
39
+ spec.add_runtime_dependency 'web47core', '~> 3.2.28'
40
+ spec.add_development_dependency "rake"
41
+ spec.add_development_dependency "minitest", "~> 5.0"
42
+ spec.add_development_dependency 'brakeman'
43
+ spec.add_development_dependency 'codacy-coverage'
44
+ spec.add_development_dependency 'database_cleaner-mongoid'
45
+ spec.add_development_dependency 'factory_bot'
46
+ spec.add_development_dependency 'factory_bot_rails'
47
+ spec.add_development_dependency 'listen'
48
+ spec.add_development_dependency 'minitest-rails'
49
+ spec.add_development_dependency 'minitest-reporters'
50
+ spec.add_development_dependency 'mocha'
51
+ spec.add_development_dependency 'rails', '~> 7.2.2'
52
+ spec.add_development_dependency 'railties'
53
+ spec.add_development_dependency 'sass-rails'
54
+ spec.add_development_dependency 'shoulda', '~> 4.0.0'
55
+ spec.add_development_dependency 'shoulda-context'
56
+ spec.add_development_dependency 'shoulda-matchers'
57
+ spec.add_development_dependency 'simplecov'
58
+ spec.add_development_dependency 'simplecov_lcov_formatter'
59
+ spec.add_development_dependency 'test-unit'
60
+ spec.add_development_dependency 'webmock'
61
+ end