holistic_auth 0.9.8
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.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/.rspec +2 -0
- data/.rubocop.yml +62 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.travis.yml +22 -0
- data/Gemfile +21 -0
- data/LICENSE.txt +22 -0
- data/README.md +42 -0
- data/Rakefile +18 -0
- data/holistic_auth.gemspec +30 -0
- data/lib/holistic_auth.rb +17 -0
- data/lib/holistic_auth/client_token_issuer.rb +106 -0
- data/lib/holistic_auth/configuration.rb +67 -0
- data/lib/holistic_auth/end_point_listener.rb +35 -0
- data/lib/holistic_auth/errors.rb +4 -0
- data/lib/holistic_auth/orm_handlers/active_record.rb +56 -0
- data/lib/holistic_auth/providers/generic_provider.rb +100 -0
- data/lib/holistic_auth/providers/google.rb +82 -0
- data/lib/holistic_auth/providers/ms_graph.rb +93 -0
- data/lib/holistic_auth/providers/outlook.rb +38 -0
- data/lib/holistic_auth/providers/stub.rb +60 -0
- data/lib/holistic_auth/version.rb +3 -0
- data/spec/holistic_auth/client_token_issuer_spec.rb +46 -0
- data/spec/holistic_auth/end_point_listener_spec.rb +96 -0
- data/spec/holistic_auth/provider_spec.rb +122 -0
- data/spec/spec_helper.rb +31 -0
- metadata +187 -0
@@ -0,0 +1,35 @@
|
|
1
|
+
module HolisticAuth
|
2
|
+
class EndPointListener
|
3
|
+
attr_reader :provider, :auth_code, :options, :errors
|
4
|
+
def initialize(hash = {})
|
5
|
+
@provider = hash.delete :provider
|
6
|
+
@auth_code = hash.delete :auth_code
|
7
|
+
@options = hash
|
8
|
+
@errors = []
|
9
|
+
end
|
10
|
+
|
11
|
+
def valid?
|
12
|
+
validator_presence? &&
|
13
|
+
validator_valid_provider?
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def validator_presence?
|
19
|
+
provider_present = @provider.present?
|
20
|
+
auth_code_present = @auth_code.present?
|
21
|
+
|
22
|
+
return true if provider_present && auth_code_present
|
23
|
+
errors << 'A required param is missing'
|
24
|
+
errors << '"provider" field missing' unless provider_present
|
25
|
+
errors << '"auth_code" field missing' unless auth_code_present
|
26
|
+
false
|
27
|
+
end
|
28
|
+
|
29
|
+
def validator_valid_provider?
|
30
|
+
return true if @provider.is_a? HolisticAuth::Providers::GenericProvider
|
31
|
+
errors << "Provider '#{@provider}' is invalid"
|
32
|
+
false
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module HolisticAuth
|
2
|
+
module OrmHandlers
|
3
|
+
class ActiveRecord
|
4
|
+
# TODO: Railtie this properly rather than hacks
|
5
|
+
require 'active_record'
|
6
|
+
|
7
|
+
attr_reader :info, :account, :provider_name
|
8
|
+
|
9
|
+
def initialize(info, provider_name)
|
10
|
+
@info = info
|
11
|
+
@provider_name = provider_name
|
12
|
+
end
|
13
|
+
|
14
|
+
def discover_user!
|
15
|
+
discover_account!.user
|
16
|
+
end
|
17
|
+
|
18
|
+
def discover_account!
|
19
|
+
@account.present? ? @account : (@account = find_or_create_account)
|
20
|
+
end
|
21
|
+
|
22
|
+
def store_provider_credentials!(access_token)
|
23
|
+
raise 'Account not discovered yet!' unless @account.present?
|
24
|
+
|
25
|
+
@account.replace_credential!(
|
26
|
+
access_token: access_token.token,
|
27
|
+
refresh_token: access_token.refresh_token,
|
28
|
+
expires_at: (Time.now.utc + access_token.expires_in),
|
29
|
+
expires: access_token.expires_in.present? ? true : false,
|
30
|
+
)
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def find_or_create_account
|
36
|
+
Account.find_by(email: info[:email], provider: @provider_name) || create_account!
|
37
|
+
end
|
38
|
+
|
39
|
+
def create_account!
|
40
|
+
user = User.find_by(primary_email: info[:email]) || create_user!
|
41
|
+
user.create_account!(
|
42
|
+
email: info[:email],
|
43
|
+
provider: @provider_name,
|
44
|
+
provider_uid: info[:uid],
|
45
|
+
)
|
46
|
+
end
|
47
|
+
|
48
|
+
def create_user!
|
49
|
+
User.create!(
|
50
|
+
primary_email: info[:email], display_name: info[:display_name],
|
51
|
+
name: info[:name], picture_url: info[:picture_url]
|
52
|
+
)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
module HolisticAuth
|
2
|
+
module Providers
|
3
|
+
require 'oauth2'
|
4
|
+
|
5
|
+
class GenericProvider
|
6
|
+
attr_accessor :client_id,
|
7
|
+
:client_secret,
|
8
|
+
:site,
|
9
|
+
:tenant_id,
|
10
|
+
:token_url,
|
11
|
+
:api_key,
|
12
|
+
:user_info_url
|
13
|
+
|
14
|
+
attr_reader :oauth2_client
|
15
|
+
|
16
|
+
def initialize(options = {})
|
17
|
+
@client_id = options.delete :client_id
|
18
|
+
@client_secret = options.delete :client_secret
|
19
|
+
@site = options.delete(:site) || settings[:site]
|
20
|
+
@token_url = options.delete(:token_url) || settings[:token_url]
|
21
|
+
@api_key = options.delete :api_key
|
22
|
+
@user_info_url = options.delete(:user_info_url) || settings[:user_info_url]
|
23
|
+
@additional_parameters = options.delete(:additional_parameters) || settings[:additional_parameters] || {}
|
24
|
+
end
|
25
|
+
|
26
|
+
def add_secrets(options = {})
|
27
|
+
@client_id = options.delete :client_id if options[:client_id]
|
28
|
+
@client_secret = options.delete :client_secret if options[:client_secret]
|
29
|
+
@api_key = options.delete :api_key if options[:api_key]
|
30
|
+
@tenant_id = options.delete :tenant_id if options[:tenant_id]
|
31
|
+
@additional_parameters.merge!(options.delete(:additional_parameters)) if options[:additional_parameters]
|
32
|
+
end
|
33
|
+
|
34
|
+
def settings
|
35
|
+
{}
|
36
|
+
end
|
37
|
+
|
38
|
+
def secrets
|
39
|
+
sec = {}
|
40
|
+
sec[:client_id] = @client_id if @client_id
|
41
|
+
sec[:client_secret] = @client_secret if @client_secret
|
42
|
+
sec[:api_key] = @api_key if @api_key
|
43
|
+
|
44
|
+
sec
|
45
|
+
end
|
46
|
+
|
47
|
+
def exchange(auth_code, redirect_uri)
|
48
|
+
errors = []
|
49
|
+
errors << 'auth_code missing' if auth_code.blank?
|
50
|
+
errors << 'redirect_uri missing' if redirect_uri.blank?
|
51
|
+
errors << 'Client ID not set' if client_id.blank?
|
52
|
+
errors << 'Client Secret not set' if client_secret.blank?
|
53
|
+
|
54
|
+
raise "Cannot exchange auth code:\n#{errors}" if errors.present?
|
55
|
+
|
56
|
+
@oauth2_client = OAuth2::Client.new(client_id, client_secret, to_hash)
|
57
|
+
@oauth2_client.auth_code.get_token(auth_code, redirect_uri: redirect_uri)
|
58
|
+
end
|
59
|
+
|
60
|
+
def retrieve_user_info(*_params)
|
61
|
+
raise 'Not implemented'
|
62
|
+
end
|
63
|
+
|
64
|
+
def name(*_params)
|
65
|
+
raise 'Generic provider doesn\'t have a name'
|
66
|
+
end
|
67
|
+
|
68
|
+
def full_site_url
|
69
|
+
raise "site not specified for class #{self}" unless site.present?
|
70
|
+
site
|
71
|
+
end
|
72
|
+
|
73
|
+
def site_token_url
|
74
|
+
full_site_url + token_url
|
75
|
+
end
|
76
|
+
|
77
|
+
def to_hash
|
78
|
+
{
|
79
|
+
client_id: @client_id,
|
80
|
+
client_secret: @client_secret,
|
81
|
+
site: full_site_url,
|
82
|
+
token_url: @token_url,
|
83
|
+
api_key: @api_key,
|
84
|
+
user_info_url: @user_info_url,
|
85
|
+
additional_parameters: @additional_parameters,
|
86
|
+
}
|
87
|
+
end
|
88
|
+
|
89
|
+
def present?
|
90
|
+
!empty?
|
91
|
+
end
|
92
|
+
|
93
|
+
def empty?
|
94
|
+
site.nil? || token_url.nil?
|
95
|
+
end
|
96
|
+
|
97
|
+
alias inspect to_hash
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module HolisticAuth
|
2
|
+
module Providers
|
3
|
+
class Google < GenericProvider
|
4
|
+
SETTINGS = {
|
5
|
+
site: 'https://accounts.google.com',
|
6
|
+
token_url: '/o/oauth2/token',
|
7
|
+
user_info_url: 'https://www.googleapis.com/plus/v1/people/me/openIdConnect',
|
8
|
+
}.freeze
|
9
|
+
|
10
|
+
def settings
|
11
|
+
SETTINGS
|
12
|
+
end
|
13
|
+
|
14
|
+
def name
|
15
|
+
:google
|
16
|
+
end
|
17
|
+
|
18
|
+
def retrieve_user_info(access_token)
|
19
|
+
client = GoogleClient::Builder.new('plus', 'v1', 1)
|
20
|
+
result = client.execute access_token,
|
21
|
+
api_method: client.service.people.get,
|
22
|
+
parameters: {
|
23
|
+
userId: 'me',
|
24
|
+
}
|
25
|
+
|
26
|
+
process_user_info JSON.parse(result.body)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def process_user_info(hash)
|
32
|
+
{
|
33
|
+
email_verified: hash['emails'].first['type'].eql?('account'),
|
34
|
+
email: hash['emails'].first['value'],
|
35
|
+
display_name: hash['displayName'],
|
36
|
+
name: hash['name'],
|
37
|
+
picture_url: hash['image']['url'],
|
38
|
+
uid: hash['id'],
|
39
|
+
language: hash['language'],
|
40
|
+
}.with_indifferent_access
|
41
|
+
|
42
|
+
# {
|
43
|
+
# "kind" => "plus#person",
|
44
|
+
# "etag" => "\"xyz-something/abc\"",
|
45
|
+
# "gender" => "female",
|
46
|
+
# "emails" => [
|
47
|
+
# {
|
48
|
+
# "value" => "info@foogi.me",
|
49
|
+
# "type" => "account"
|
50
|
+
# }
|
51
|
+
# ],
|
52
|
+
# "objectType" => "person",
|
53
|
+
# "id" => "12345",
|
54
|
+
# "displayName" => "Leading Foogster",
|
55
|
+
# "name" => {
|
56
|
+
# "familyName" => "Foogster",
|
57
|
+
# "givenName" => "Leading"
|
58
|
+
# },
|
59
|
+
# "url" => "https://plus.google.com/+FoogiMe",
|
60
|
+
# "image" => {
|
61
|
+
# "url" => "https://someurl/photo.jpg?sz=50",
|
62
|
+
# "isDefault" => false
|
63
|
+
# },
|
64
|
+
# "placesLived" => [
|
65
|
+
# {
|
66
|
+
# "value" => "Sydney, Australia",
|
67
|
+
# "primary" => true
|
68
|
+
# },
|
69
|
+
# {
|
70
|
+
# "value" => "San Francisco, USA"
|
71
|
+
# }
|
72
|
+
# ],
|
73
|
+
# "isPlusUser" => true,
|
74
|
+
# "language" => "en_GB",
|
75
|
+
# "circledByCount" => 11225,
|
76
|
+
# "verified" => false,
|
77
|
+
# "domain" => "foogi.me"
|
78
|
+
# }
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
module HolisticAuth
|
2
|
+
module Providers
|
3
|
+
class MsGraph < GenericProvider
|
4
|
+
GRAPH_RESOURCE = 'https://graph.microsoft.com'.freeze
|
5
|
+
DEFAULT_CONTENT_TYPE = 'application/json;odata.metadata=minimal;odata.streaming=true'.freeze
|
6
|
+
API_VERSION = 'beta'.freeze
|
7
|
+
|
8
|
+
SETTINGS = {
|
9
|
+
site: 'https://login.microsoftonline.com',
|
10
|
+
token_url: 'oauth2/token',
|
11
|
+
user_info_url: URI("#{GRAPH_RESOURCE}/#{API_VERSION}/me"),
|
12
|
+
additional_parameters: {
|
13
|
+
resource: GRAPH_RESOURCE,
|
14
|
+
},
|
15
|
+
}.freeze
|
16
|
+
|
17
|
+
def settings
|
18
|
+
self.class::SETTINGS
|
19
|
+
end
|
20
|
+
|
21
|
+
def name
|
22
|
+
:ms_graph
|
23
|
+
end
|
24
|
+
|
25
|
+
def full_site_url
|
26
|
+
tenant_id.present? ? (site + '/' + tenant_id + '/') : (site + '/common/')
|
27
|
+
end
|
28
|
+
|
29
|
+
def retrieve_user_info(access_token)
|
30
|
+
result = query! :get, access_token.token, settings[:user_info_url]
|
31
|
+
process_info JSON.parse(result.body)
|
32
|
+
end
|
33
|
+
|
34
|
+
def process_info(hash)
|
35
|
+
sanity_check! hash
|
36
|
+
|
37
|
+
{
|
38
|
+
email_verified: hash['mail'].present?,
|
39
|
+
email: hash['mail'],
|
40
|
+
display_name: hash['displayName'],
|
41
|
+
name: {
|
42
|
+
givenName: hash['givenName'],
|
43
|
+
familyName: hash['familyName'],
|
44
|
+
},
|
45
|
+
picture_url: '',
|
46
|
+
uid: hash['id'],
|
47
|
+
language: hash['preferredLanguage'],
|
48
|
+
}.with_indifferent_access
|
49
|
+
end
|
50
|
+
|
51
|
+
# def events
|
52
|
+
# query_params = {
|
53
|
+
# startdatetime: DateTime.now.utc,
|
54
|
+
# enddatetime: DateTime.now.utc + 2.months,
|
55
|
+
# '$orderby' => 'start/dateTime',
|
56
|
+
# }
|
57
|
+
# end
|
58
|
+
|
59
|
+
# Need error handling for when the token has expired.
|
60
|
+
def query!(method, access_token, uri, body = nil)
|
61
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
62
|
+
http.use_ssl = true
|
63
|
+
|
64
|
+
headers = {
|
65
|
+
'Authorization' => "Bearer #{access_token}",
|
66
|
+
'Content-Type' => DEFAULT_CONTENT_TYPE,
|
67
|
+
}
|
68
|
+
|
69
|
+
full_endpoint = uri.query.present? ? "#{uri.path}?#{uri.query}" : uri.path
|
70
|
+
|
71
|
+
response =
|
72
|
+
case method
|
73
|
+
when :get
|
74
|
+
http.get(full_endpoint, headers)
|
75
|
+
when :post
|
76
|
+
http.post(full_endpoint, body, headers)
|
77
|
+
else
|
78
|
+
raise "method #{method} not implemented"
|
79
|
+
end
|
80
|
+
|
81
|
+
response
|
82
|
+
end
|
83
|
+
|
84
|
+
def sanity_check!(hash)
|
85
|
+
raise "Can't process empty user info" unless hash.is_a? Hash
|
86
|
+
|
87
|
+
if hash.key?('error')
|
88
|
+
raise "Could not process user info: \n #{hash['error']['code']}: #{hash['error']['message']}"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module HolisticAuth
|
2
|
+
module Providers
|
3
|
+
class Outlook < MsGraph
|
4
|
+
RESOURCE = 'https://outlook.office.com'.freeze
|
5
|
+
API_VERSION = 'v2.0'.freeze
|
6
|
+
|
7
|
+
SETTINGS = {
|
8
|
+
site: 'https://login.microsoftonline.com',
|
9
|
+
token_url: 'oauth2/v2.0/token',
|
10
|
+
user_info_url: URI("#{RESOURCE}/api/#{API_VERSION}/Me"),
|
11
|
+
additional_parameters: {
|
12
|
+
resource: RESOURCE,
|
13
|
+
},
|
14
|
+
}.freeze
|
15
|
+
|
16
|
+
def name
|
17
|
+
:outlook
|
18
|
+
end
|
19
|
+
|
20
|
+
def process_info(hash)
|
21
|
+
sanity_check!(hash)
|
22
|
+
|
23
|
+
{
|
24
|
+
email_verified: hash['EmailAddress'].present?,
|
25
|
+
email: hash['EmailAddress'],
|
26
|
+
display_name: hash['DisplayName'],
|
27
|
+
name: {
|
28
|
+
givenName: hash['givenName'],
|
29
|
+
familyName: hash['familyName'],
|
30
|
+
},
|
31
|
+
picture_url: '',
|
32
|
+
uid: hash['Id'],
|
33
|
+
language: hash['preferredLanguage'],
|
34
|
+
}.with_indifferent_access
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
module HolisticAuth
|
2
|
+
module Providers
|
3
|
+
class Stub < GenericProvider
|
4
|
+
SETTINGS = {
|
5
|
+
client_id: 'stub_cl_id',
|
6
|
+
client_secret: 'stub_cl_sec',
|
7
|
+
site: 'https://example.org',
|
8
|
+
token_url: '/extoken',
|
9
|
+
api_key: 'api_key',
|
10
|
+
user_info_url: 'http://example.org/info',
|
11
|
+
}.freeze
|
12
|
+
|
13
|
+
STUB_SAMPLE_TOKEN = {
|
14
|
+
token: 'ya29.token',
|
15
|
+
refresh_token: '1/refresh',
|
16
|
+
expires_in: 3600,
|
17
|
+
}.freeze
|
18
|
+
|
19
|
+
def initialize(options = {})
|
20
|
+
super(options)
|
21
|
+
|
22
|
+
@client_id ||= settings[:client_id]
|
23
|
+
@client_secret ||= settings[:client_secret]
|
24
|
+
@api_key ||= settings[:api_key]
|
25
|
+
end
|
26
|
+
|
27
|
+
def settings
|
28
|
+
SETTINGS
|
29
|
+
end
|
30
|
+
|
31
|
+
def name
|
32
|
+
:stub
|
33
|
+
end
|
34
|
+
|
35
|
+
def exchange(_, __)
|
36
|
+
@client = OAuth2::Client.new(
|
37
|
+
client_id,
|
38
|
+
client_secret,
|
39
|
+
)
|
40
|
+
|
41
|
+
OAuth2::AccessToken.new(
|
42
|
+
@client,
|
43
|
+
STUB_SAMPLE_TOKEN[:token],
|
44
|
+
refresh_token: STUB_SAMPLE_TOKEN[:refresh_token],
|
45
|
+
expires_in: STUB_SAMPLE_TOKEN[:expires_in],
|
46
|
+
)
|
47
|
+
end
|
48
|
+
|
49
|
+
def retrieve_user_info(_access_token)
|
50
|
+
{
|
51
|
+
email_verified: true,
|
52
|
+
email: 'a@b.c',
|
53
|
+
given_name: 'first',
|
54
|
+
family_name: 'last',
|
55
|
+
profile: 'xyz',
|
56
|
+
}.with_indifferent_access
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|