jm81auth 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 5054fd53d58c6211c8000f49769e161bcba9482e
4
+ data.tar.gz: 5ae3e3f048318ff1bd5859695067ab805e255386
5
+ SHA512:
6
+ metadata.gz: dfe794d0b2d0bf1d226351ab9c8fb424e939805a1e414617abb591f3d78cd14eb6483c8a634fd9b8a074892d64ef27707ac1515e20ce0806a86064679ae55a52
7
+ data.tar.gz: 0053aa9455f3a95bdeeaa919909255d02ecc58d660352f3c2796323db5894a4ee78059e4553af1a06e4e36f1079a924dc3b314cddc464f8e3c41d9f23bb5ac0e
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2017 YOURNAME
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.
data/README.rdoc ADDED
@@ -0,0 +1,3 @@
1
+ = Jm81auth
2
+
3
+ This project rocks and uses MIT-LICENSE.
data/Rakefile ADDED
@@ -0,0 +1,18 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rspec/core/rake_task'
8
+ RSpec::Core::RakeTask.new(:spec)
9
+
10
+ require 'rdoc/task'
11
+
12
+ RDoc::Task.new(:rdoc) do |rdoc|
13
+ rdoc.rdoc_dir = 'rdoc'
14
+ rdoc.title = 'Jm81auth'
15
+ rdoc.options << '--line-numbers'
16
+ rdoc.rdoc_files.include('README.rdoc')
17
+ rdoc.rdoc_files.include('lib/**/*.rb')
18
+ end
@@ -0,0 +1,9 @@
1
+ class Configuration
2
+ attr_accessor :client_secrets, :expires_seconds, :jwt_algorithm, :jwt_secret
3
+
4
+ def initialize
5
+ @client_secrets = {}
6
+ @expires_seconds = 30 * 86400
7
+ @jwt_algorithm = 'HS512'
8
+ end
9
+ end
@@ -0,0 +1,32 @@
1
+ module Jm81auth
2
+ module Models
3
+ module AuthMethod
4
+ def self.included(base)
5
+ base.extend ClassMethods
6
+
7
+ base.class_eval do
8
+ many_to_one :user
9
+ one_to_many :auth_tokens
10
+ end
11
+ end
12
+
13
+ # Create AuthToken, setting user and last_used_at
14
+ #
15
+ # @return [AuthToken]
16
+ def create_token
17
+ add_auth_token user: user, last_used_at: Time.now.utc
18
+ end
19
+
20
+ module ClassMethods
21
+ # Get an AuthMethod using provider data conditions (#provider_name and
22
+ # #provider_id).
23
+ #
24
+ # @param provider_data [Hash]
25
+ # @return [AuthMethod]
26
+ def by_provider_data provider_data
27
+ where(provider_data).first
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,101 @@
1
+ module Jm81auth
2
+ module Models
3
+ module AuthToken
4
+ def self.included(base)
5
+ base.extend ClassMethods
6
+
7
+ base.class_eval do
8
+ plugin :timestamps
9
+
10
+ many_to_one :auth_method
11
+ many_to_one :user
12
+ end
13
+ end
14
+
15
+ class DecodeError < StandardError
16
+ end
17
+
18
+ # Set #closed_at. Called, for example, when logging out.
19
+ def close!
20
+ self.update(closed_at: Time.now) unless self.closed_at
21
+ end
22
+
23
+ # @return [String]
24
+ # { auth_token_id: self.id } encoded via JWT for passing to client.
25
+ def encoded
26
+ self.class.encode auth_token_id: self.id
27
+ end
28
+
29
+ # @return [Boolean] Is this token expired?
30
+ def expired?
31
+ !open?
32
+ end
33
+
34
+ # @return [Boolean] True if token is not expired or closed.
35
+ def open?
36
+ !(last_used_at.nil?) && !closed_at && Time.now <= expires_at
37
+ end
38
+
39
+ # @return [DateTime] Time when this token expires.
40
+ def expires_at
41
+ last_used_at + self.class.config.expires_seconds
42
+ end
43
+
44
+ module ClassMethods
45
+
46
+ # Decode a JWT token and get AuthToken based on stored ID.
47
+ #
48
+ # @see #encoded
49
+ # @param token [String] JWT encoded hash with AuthToken#id
50
+ # @raise [DecodeError] auth_token_id is missing or no AuthToken found.
51
+ # @return [AuthToken]
52
+ def decode token
53
+ payload = JWT.decode(
54
+ token, config.jwt_secret, config.jwt_algorithm
55
+ ).first
56
+
57
+ auth_token = self[payload['auth_token_id']]
58
+
59
+ if payload['auth_token_id'].nil?
60
+ raise DecodeError, "auth_token_id missing: #{payload}"
61
+ elsif auth_token.nil?
62
+ raise DecodeError, "auth_token_id not found: #{payload}"
63
+ end
64
+
65
+ auth_token
66
+ end
67
+
68
+ # Encode a value using jwt_secret and jwt_algorithm.
69
+ #
70
+ # @param value [Hash, Array]
71
+ # @return [String] Encoded value
72
+ def encode value
73
+ JWT.encode value, config.jwt_secret, config.jwt_algorithm
74
+ end
75
+
76
+ # Decode a JWT token and get AuthToken based on stored ID. If an open
77
+ # AuthToken is found, update its last_used_at value.
78
+ #
79
+ # @see #decode
80
+ # @param token [String] JWT encoded hash with AuthToken#id
81
+ # @raise [DecodeError] auth_token_id is missing or no AuthToken found.
82
+ # @return [AuthToken]
83
+ def use token
84
+ auth_token = decode token
85
+
86
+ if auth_token && !auth_token.expired?
87
+ auth_token.update(last_used_at: Time.now)
88
+ auth_token
89
+ else
90
+ nil
91
+ end
92
+ end
93
+
94
+ # @return [Configuration]
95
+ def config
96
+ Jm81auth.config
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,56 @@
1
+ module Jm81auth
2
+ module Models
3
+ module User
4
+ EMAIL_REGEX = /.@./
5
+
6
+ def self.included(base)
7
+ base.extend ClassMethods
8
+
9
+ base.class_eval do
10
+ one_to_many :auth_methods
11
+ one_to_many :auth_tokens
12
+ end
13
+ end
14
+
15
+ module ClassMethods
16
+ # Find user by email address. Returns nil if the email address is not
17
+ # valid (for a minimal version of valid)
18
+ #
19
+ # @param email [~to_s] Email Address
20
+ # @return [User, nil]
21
+ def find_by_email email
22
+ if email.to_s =~ EMAIL_REGEX
23
+ where(email: email.to_s.downcase.strip).first
24
+ else
25
+ nil
26
+ end
27
+ end
28
+
29
+ # Login from OAuth.
30
+ #
31
+ # First try to find an AuthMethod matching the provider data. If none,
32
+ # find or create a User based on email, then create an AuthMethod.
33
+ # Finally, create and return an AuthToken.
34
+ #
35
+ # @param oauth [OAuth::Base]
36
+ # OAuth login object, include #provider_data (Hash with provider_name
37
+ # and provider_id), #email and #display_name.
38
+ # @return [AuthToken]
39
+ def oauth_login oauth
40
+ method = ::AuthMethod.by_provider_data oauth.provider_data
41
+
42
+ if !method
43
+ user = find_by_email(oauth.email) || create(
44
+ email: oauth.email.downcase,
45
+ display_name: oauth.display_name
46
+ )
47
+
48
+ method = user.add_auth_method oauth.provider_data
49
+ end
50
+
51
+ method.create_token
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,71 @@
1
+ # Based on satellizer example:
2
+ # https://github.com/sahat/satellizer/blob/master/examples/server/ruby
3
+
4
+ module Jm81auth
5
+ module OAuth
6
+ class Base
7
+ # Setup @params from params param (Har, har). Also, set @access_token,
8
+ # either from params Hash, or by calling #get_access_token. @params is the
9
+ # expected params needed by #get_access_token.
10
+ #
11
+ # @param params [Hash]
12
+ # Expected to contain :code, :redirectUri, :clientId, and, optionally,
13
+ # :access_token
14
+ def initialize params
15
+ @params = {
16
+ code: params[:code],
17
+ redirect_uri: params[:redirectUri],
18
+ client_id: params[:clientId],
19
+ client_secret: Jm81auth.config.client_secrets[provider_name]
20
+ }
21
+
22
+ @access_token = params[:access_token] || get_access_token
23
+ end
24
+
25
+ # @return [Hash] Data returned by accessing data URL.
26
+ def data
27
+ @data or get_data
28
+ end
29
+
30
+ # @return [String] Display name (e.g. "Jane Doe") from data.
31
+ def display_name
32
+ data['name']
33
+ end
34
+
35
+ # @return [String] Email address from data.
36
+ def email
37
+ data['email']
38
+ end
39
+
40
+ # Get data via get request to provider's data URL.
41
+ #
42
+ # @return [Hash]
43
+ def get_data
44
+ response = client.get(self.class::DATA_URL, access_token: @access_token)
45
+ @data = JSON.parse(response.body)
46
+ end
47
+
48
+ # @return [String] Provider name, based on class name.
49
+ def provider_name
50
+ self.class.name.split('::').last.downcase
51
+ end
52
+
53
+ # @return [String] Provider assigned ID, from data.
54
+ def provider_id
55
+ data['id'] || data['sub']
56
+ end
57
+
58
+ # @return [Hash] provider_name and provider_id
59
+ def provider_data
60
+ { provider_name: provider_name, provider_id: provider_id }
61
+ end
62
+
63
+ private
64
+
65
+ # @return [HTTPClient]
66
+ def client
67
+ @client ||= HTTPClient.new
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,13 @@
1
+ module Jm81auth
2
+ module OAuth
3
+ class Github < Base
4
+ ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token'
5
+ DATA_URL = 'https://api.github.com/user'
6
+
7
+ def get_access_token
8
+ response = client.post(ACCESS_TOKEN_URL, @params)
9
+ Rack::Utils.parse_nested_query(response.body)['access_token']
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,3 @@
1
+ module Jm81auth
2
+ VERSION = '0.1.0'
3
+ end
data/lib/jm81auth.rb ADDED
@@ -0,0 +1,23 @@
1
+ require 'httpclient'
2
+ require 'jwt'
3
+
4
+ module Jm81auth
5
+ class << self
6
+ def config &block
7
+ @config ||= Configuration.new
8
+ @config.instance_eval(&block) if block_given?
9
+ @config
10
+ end
11
+ end
12
+ end
13
+
14
+ require 'jm81auth/configuration'
15
+
16
+ require 'jm81auth/models/auth_method'
17
+ require 'jm81auth/models/auth_token'
18
+ require 'jm81auth/models/user'
19
+
20
+ require 'jm81auth/oauth/base'
21
+ require 'jm81auth/oauth/github'
22
+
23
+ require 'jm81auth/version'
@@ -0,0 +1,9 @@
1
+ FactoryGirl.define do
2
+ factory :auth_method do
3
+ to_create { |resource| resource.save }
4
+
5
+ user
6
+ provider_name 'test'
7
+ sequence(:provider_id) { |n| n }
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ FactoryGirl.define do
2
+ factory :auth_token do
3
+ to_create { |resource| resource.save }
4
+
5
+ user
6
+ auth_method
7
+ last_used_at { Time.now }
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ FactoryGirl.define do
2
+ factory :user do
3
+ to_create { |resource| resource.save }
4
+ end
5
+ end
@@ -0,0 +1,75 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe AuthMethod, type: :model do
4
+ subject(:auth_method) { FactoryGirl.build(:auth_method) }
5
+
6
+ it { is_expected.to be_valid }
7
+
8
+ describe '#user' do
9
+ it 'belongs to a User' do
10
+ expect(auth_method.user).to be_a(User)
11
+ end
12
+ end
13
+
14
+ describe '#auth_tokens' do
15
+ before(:each) { auth_method.save }
16
+
17
+ let!(:auth_token) do
18
+ auth_method.add_auth_token(user: auth_method.user, last_used_at: Time.now)
19
+ end
20
+
21
+ it 'has many' do
22
+ expect(auth_token).to be_a(AuthToken)
23
+ expect(auth_method.auth_tokens).to eq([auth_token])
24
+ expect(AuthToken[auth_token.id].auth_method).to eq(auth_method)
25
+ end
26
+
27
+ it 'cascades deletes' do
28
+ expect { auth_method.destroy }.
29
+ to raise_error(Sequel::ForeignKeyConstraintViolation)
30
+ end
31
+ end
32
+
33
+ describe '#create_token' do
34
+ before(:each) { auth_method.save }
35
+
36
+ it 'creates an AuthToken, setting user and last_used_at' do
37
+ token = nil
38
+ expect { token = auth_method.create_token }.
39
+ to change(AuthToken, :count).by(1)
40
+
41
+ token.reload
42
+ expect(token.user).to be_a(User)
43
+ expect(token.user).to eq(auth_method.user)
44
+ expect(token.last_used_at).to be_a(Time)
45
+ end
46
+ end
47
+
48
+ describe '.by_provider_data' do
49
+ let!(:auth_methods) do
50
+ [
51
+ FactoryGirl.create(
52
+ :auth_method, provider_name: 'github', provider_id: 2
53
+ ),
54
+ FactoryGirl.create(
55
+ :auth_method, provider_name: 'github', provider_id: 4
56
+ )
57
+ ]
58
+ end
59
+
60
+ it 'gets an AuthMethod from provider_name and provider_id' do
61
+ expect(
62
+ AuthMethod.by_provider_data(provider_name: 'github', provider_id: 2)
63
+ ).to eq(auth_methods[0])
64
+ expect(
65
+ AuthMethod.by_provider_data(provider_name: 'github', provider_id: '4')
66
+ ).to eq(auth_methods[1])
67
+ expect(
68
+ AuthMethod.by_provider_data(provider_name: 'other', provider_id: 2)
69
+ ).to be(nil)
70
+ expect(
71
+ AuthMethod.by_provider_data(provider_name: 'github', provider_id: 1)
72
+ ).to be(nil)
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,193 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe AuthToken, type: :model do
4
+ subject(:auth_token) { FactoryGirl.build(:auth_token) }
5
+
6
+ it { is_expected.to be_valid }
7
+
8
+ describe '#auth_method' do
9
+ it 'belongs to a AuthMethod' do
10
+ expect(auth_token.auth_method).to be_a(AuthMethod)
11
+ end
12
+ end
13
+
14
+ describe '#user' do
15
+ it 'belongs to a User' do
16
+ expect(auth_token.user).to be_a(User)
17
+ end
18
+ end
19
+
20
+ describe '#close!' do
21
+ context '#closed_at is not set' do
22
+ it 'sets #closed_at' do
23
+ auth_token.save
24
+ auth_token.close!
25
+ expect(auth_token.reload.closed_at).to be_a(Time)
26
+ end
27
+ end
28
+
29
+ context '#closed_at is set' do
30
+ it 'does nothing' do
31
+ auth_token.closed_at = Time.now - 1200
32
+ auth_token.save
33
+ auth_token.close!
34
+ expect(auth_token.reload.closed_at).to be <= Time.now - 1200
35
+ end
36
+ end
37
+ end
38
+
39
+ describe '#encoded' do
40
+ before(:each) { auth_token.save }
41
+
42
+ it 'returns a String encoding the id' do
43
+ encoded = auth_token.encoded
44
+ expect(auth_token.encoded).to be_a(String)
45
+ decoded = JWT.decode(
46
+ encoded, Jm81auth.config.jwt_secret, Jm81auth.config.jwt_algorithm
47
+ )
48
+ expect(decoded[0]).to eq({'auth_token_id' => auth_token.id})
49
+ end
50
+ end
51
+
52
+ describe '#expires_at' do
53
+ it 'is 30 days after last_used_at' do
54
+ auth_token.last_used_at = Time.parse('2015-08-01 15:00')
55
+ expect(auth_token.expires_at).to eq(Time.parse('2015-08-31 15:00'))
56
+ end
57
+ end
58
+
59
+ describe '#open? (#expired? is opposite)' do
60
+ context '#last_used_at not set' do
61
+ it 'is false' do
62
+ auth_token.last_used_at = nil
63
+ expect(auth_token.open?).to be(false)
64
+ expect(auth_token.expired?).to be(true)
65
+ end
66
+ end
67
+
68
+ context '#closed_at is set' do
69
+ it 'is false' do
70
+ auth_token.closed_at = Time.now
71
+ expect(auth_token.open?).to be(false)
72
+ expect(auth_token.expired?).to be(true)
73
+ end
74
+ end
75
+
76
+ context '#last_used_at is more than 30 days ago' do
77
+ it 'is false' do
78
+ auth_token.last_used_at = (Date.today - 30).to_time
79
+ expect(auth_token.open?).to be(false)
80
+ expect(auth_token.expired?).to be(true)
81
+ auth_token.last_used_at = (Date.today - 31).to_time
82
+ expect(auth_token.open?).to be(false)
83
+ expect(auth_token.expired?).to be(true)
84
+ end
85
+ end
86
+
87
+ context '#last_used_at is less than 30 days ago' do
88
+ it 'is true' do
89
+ auth_token.last_used_at = (Date.today - 29).to_time
90
+ expect(auth_token.open?).to be(true)
91
+ expect(auth_token.expired?).to be(false)
92
+ auth_token.last_used_at = Time.now
93
+ expect(auth_token.open?).to be(true)
94
+ expect(auth_token.expired?).to be(false)
95
+ end
96
+ end
97
+ end
98
+
99
+ describe '.decode' do
100
+ def encoded id
101
+ AuthToken.encode auth_token_id: id
102
+ end
103
+
104
+ before(:each) { auth_token.save }
105
+
106
+ it 'returns AuthToken based on encoded token' do
107
+ other = FactoryGirl.create(:auth_token)
108
+ expect(AuthToken.decode(encoded(auth_token.id))).to eq(auth_token)
109
+ expect(AuthToken.decode(encoded(auth_token.id))).to_not eq(other)
110
+ end
111
+
112
+ context 'auth_token_id missing from decoded hash' do
113
+ it 'raises DecodeError' do
114
+ expect { AuthToken.decode(AuthToken.encode(something: 'else')) }.
115
+ to raise_error(AuthToken::DecodeError)
116
+ end
117
+ end
118
+
119
+ context 'No AuthToken found for auth_token_id from decoded hash' do
120
+ it 'raises DecodeError' do
121
+ expect { AuthToken.decode(encoded(-10)) }.
122
+ to raise_error(AuthToken::DecodeError)
123
+ end
124
+ end
125
+ end
126
+
127
+ describe '.encode' do
128
+ it 'encodes a value using JWT' do
129
+ encoded = AuthToken.encode({a: 1, b: 2})
130
+ expect(auth_token.encoded).to be_a(String)
131
+ decoded = JWT.decode(
132
+ encoded, Jm81auth.config.jwt_secret, Jm81auth.config.jwt_algorithm
133
+ )
134
+ expect(decoded[0]).to eq({'a' => 1, 'b' => 2})
135
+ end
136
+ end
137
+
138
+ describe '.use' do
139
+ def encoded id
140
+ AuthToken.encode auth_token_id: id
141
+ end
142
+
143
+ before(:each) do
144
+ auth_token.save
145
+ end
146
+
147
+ context 'found open AuthToken' do
148
+ before(:each) do
149
+ auth_token.last_used_at = Time.now - 3600
150
+ auth_token.save
151
+ end
152
+
153
+ let!(:other) { FactoryGirl.create(:auth_token) }
154
+
155
+ it 'updates last_used_at' do
156
+ found = AuthToken.use(encoded(auth_token.id))
157
+ expect(found.last_used_at).to be > Time.now - 600
158
+ expect(found.last_used_at.to_i).
159
+ to eq(auth_token.reload.last_used_at.to_i)
160
+ end
161
+
162
+ it 'returns found AuthToken' do
163
+ expect(AuthToken.use(encoded(auth_token.id))).to be === auth_token
164
+ expect(AuthToken.use(encoded(auth_token.id))).to_not be === other
165
+ end
166
+ end
167
+
168
+ context 'found expired AuthToken' do
169
+ before(:each) do
170
+ auth_token.last_used_at = (Date.today - 40).to_time
171
+ auth_token.save
172
+ end
173
+
174
+ let!(:last_used_at) { auth_token.last_used_at }
175
+
176
+ it 'does not update last_used_at' do
177
+ AuthToken.use(encoded(auth_token.id))
178
+ expect(auth_token.reload.last_used_at.to_i).to eq(last_used_at.to_i)
179
+ end
180
+
181
+ it 'returns nil' do
182
+ expect(AuthToken.use(encoded(auth_token.id))).to be(nil)
183
+ end
184
+ end
185
+
186
+ context 'No AuthToken found for auth_token_id from decoded hash' do
187
+ it 'raises DecodeError' do
188
+ expect { AuthToken.use(encoded(-10)) }.
189
+ to raise_error(AuthToken::DecodeError)
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,263 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe User, type: :model do
4
+ subject(:user) { FactoryGirl.build(:user) }
5
+
6
+ it { is_expected.to be_valid }
7
+
8
+ describe '#auth_methods' do
9
+ before(:each) { user.save }
10
+
11
+ let!(:auth_method) do
12
+ user.add_auth_method provider_name: :test, provider_id: 1
13
+ end
14
+
15
+ it 'has many' do
16
+ expect(auth_method).to be_a(AuthMethod)
17
+ expect(user.auth_methods).to eq([auth_method])
18
+ expect(AuthMethod[auth_method.id].user).to eq(user)
19
+ end
20
+
21
+ it 'cascades deletes' do
22
+ user.destroy
23
+ expect(AuthMethod[auth_method.id]).to be(nil)
24
+ end
25
+ end
26
+
27
+ describe '#auth_tokens' do
28
+ before(:each) { user.save }
29
+
30
+ let!(:auth_token) do
31
+ user.add_auth_token(
32
+ auth_method: FactoryGirl.create(:auth_method), last_used_at: Time.now
33
+ )
34
+ end
35
+
36
+ it 'has many' do
37
+ expect(auth_token).to be_a(AuthToken)
38
+ expect(user.auth_tokens).to eq([auth_token])
39
+ expect(AuthToken[auth_token.id].user).to eq(user)
40
+ end
41
+
42
+ it 'restricts deletes' do
43
+ expect { user.destroy }.
44
+ to raise_error(Sequel::ForeignKeyConstraintViolation)
45
+ end
46
+ end
47
+
48
+ describe '.find_by_email' do
49
+ let!(:existing) { FactoryGirl.create(:user, email: 'test@example.com') }
50
+
51
+ context 'existing User with given email' do
52
+ it 'gets existing User' do
53
+ expect(User.find_by_email('test@example.com')).to eq(existing)
54
+ expect(User.find_by_email(' test@example.com ')).to eq(existing)
55
+ expect(User.find_by_email('TEST@example.com')).to eq(existing)
56
+ end
57
+ end
58
+
59
+ context 'no existing User with given email' do
60
+ it 'is nil' do
61
+ expect(User).to receive(:where).twice.and_call_original
62
+ expect(User.find_by_email('other@example.com')).to be(nil)
63
+ expect(User.find_by_email('test@example.org')).to be(nil)
64
+ end
65
+ end
66
+
67
+ context 'nil email' do
68
+ it 'is nil' do
69
+ expect(User).to_not receive(:where)
70
+ expect(User.find_by_email(nil)).to be(nil)
71
+ end
72
+ end
73
+
74
+ context 'invalid email' do
75
+ it 'is nil' do
76
+ expect(User).to_not receive(:where)
77
+ expect(User.find_by_email('')).to be(nil)
78
+ expect(User.find_by_email('@example.com')).to be(nil)
79
+ expect(User.find_by_email('test')).to be(nil)
80
+ end
81
+ end
82
+ end
83
+
84
+ describe '.oauth_login' do
85
+ let(:oauth) { double 'Jm81auth::OAuth' }
86
+
87
+ let!(:user) do
88
+ FactoryGirl.create :user, email: 'existing-user@example.com',
89
+ display_name: 'Existing User'
90
+ end
91
+
92
+ let!(:auth_method) do
93
+ FactoryGirl.create :auth_method,
94
+ user: user, provider_name: 'github', provider_id: 5
95
+ end
96
+
97
+ def login
98
+ @login_token = User.oauth_login oauth
99
+ @login_user = @login_token.user
100
+ @login_method = @login_token.auth_method
101
+ user.reload
102
+ auth_method.reload
103
+ end
104
+
105
+ context 'existing AuthMethod' do
106
+ before(:each) do
107
+ expect(oauth).to_not receive(:email)
108
+
109
+ expect(oauth).to receive(:provider_data) do
110
+ { provider_name: 'github', provider_id: 5 }
111
+ end
112
+ end
113
+
114
+ it 'uses existing (does not create) User' do
115
+ expect { login }.to_not change(User, :count)
116
+ expect(@login_user.id).to eq(user.id)
117
+ expect(@login_user.email).to eq('existing-user@example.com')
118
+ expect(@login_user.display_name).to eq('Existing User')
119
+ end
120
+
121
+ it 'uses existing (does not create) AuthMethod' do
122
+ expect { login }.to_not change(AuthMethod, :count)
123
+ expect(@login_method.id).to eq(auth_method.id)
124
+ end
125
+
126
+ it 'creates and returns AuthToken' do
127
+ expect { login }.to change(AuthToken, :count).by(1)
128
+ expect(@login_token.user.id).to eq(user.id)
129
+ expect(@login_token.auth_method.id).to eq(auth_method.id)
130
+ expect(@login_token.last_used_at).to be_a(Time)
131
+ end
132
+ end
133
+
134
+ context 'no existing AuthMethod' do
135
+ before(:each) do
136
+ expect(oauth).to receive(:provider_data).twice do
137
+ { provider_name: 'github', provider_id: 10 }
138
+ end
139
+ end
140
+
141
+ context 'User found with same email' do
142
+ before(:each) do
143
+ expect(oauth).to receive(:email) { 'existing-user@example.com' }
144
+ end
145
+
146
+ it 'uses existing (does not create) a new User' do
147
+ expect { login }.to_not change(User, :count)
148
+ expect(@login_user.id).to eq(user.id)
149
+ expect(@login_user.email).to eq('existing-user@example.com')
150
+ expect(@login_user.display_name).to eq('Existing User')
151
+ end
152
+
153
+ it 'creates a new AuthMethod' do
154
+ expect { login }.to change(AuthMethod, :count).by(1)
155
+ expect(auth_method.provider_id).to eq(5)
156
+ expect(@login_method.id).to_not eq(auth_method.id)
157
+ expect(@login_method.provider_name).to eq('github')
158
+ expect(@login_method.provider_id).to eq(10)
159
+ expect(@login_method.user.id).to eq(user.id)
160
+ end
161
+
162
+ it 'creates and returns AuthToken' do
163
+ expect { login }.to change(AuthToken, :count).by(1)
164
+ expect(@login_token.user.id).to eq(user.id)
165
+ expect(@login_token.auth_method.id).to eq(@login_method.id)
166
+ expect(@login_token.last_used_at).to be_a(Time)
167
+ end
168
+ end
169
+
170
+ context 'No User found with valid OAuth email' do
171
+ before(:each) do
172
+ expect(oauth).to receive(:email).twice { 'new-user@example.com' }
173
+ expect(oauth).to receive(:display_name) { 'New Name' }
174
+ end
175
+
176
+ it 'creates a new User' do
177
+ expect { login }.to change(User, :count).by(1)
178
+ expect(user.display_name).to eq('Existing User')
179
+ expect(@login_user.email).to eq('new-user@example.com')
180
+ expect(@login_user.display_name).to eq('New Name')
181
+ end
182
+
183
+ it 'creates a new AuthMethod' do
184
+ expect { login }.to change(AuthMethod, :count).by(1)
185
+ expect(auth_method.provider_id).to eq(5)
186
+ expect(@login_method.id).to_not eq(auth_method.id)
187
+ expect(@login_method.provider_name).to eq('github')
188
+ expect(@login_method.provider_id).to eq(10)
189
+ expect(@login_method.user.id).to eq(@login_user.id)
190
+ end
191
+
192
+ it 'creates and returns AuthToken' do
193
+ expect { login }.to change(AuthToken, :count).by(1)
194
+ expect(@login_token.user.id).to eq(@login_user.id)
195
+ expect(@login_token.auth_method.id).to eq(@login_method.id)
196
+ expect(@login_token.last_used_at).to be_a(Time)
197
+ end
198
+ end
199
+
200
+ context 'OAuth email is empty' do
201
+ before(:each) do
202
+ user.update email: ''
203
+ expect(oauth).to receive(:email).twice { '' }
204
+ expect(oauth).to receive(:display_name) { 'New Name' }
205
+ end
206
+
207
+ it 'creates a new User' do
208
+ expect { login }.to change(User, :count).by(1)
209
+ expect(user.display_name).to eq('Existing User')
210
+ expect(@login_user.email).to eq('')
211
+ expect(@login_user.display_name).to eq('New Name')
212
+ end
213
+
214
+ it 'creates a new AuthMethod' do
215
+ expect { login }.to change(AuthMethod, :count).by(1)
216
+ expect(auth_method.provider_id).to eq(5)
217
+ expect(@login_method.id).to_not eq(auth_method.id)
218
+ expect(@login_method.provider_name).to eq('github')
219
+ expect(@login_method.provider_id).to eq(10)
220
+ expect(@login_method.user.id).to eq(@login_user.id)
221
+ end
222
+
223
+ it 'creates and returns AuthToken' do
224
+ expect { login }.to change(AuthToken, :count).by(1)
225
+ expect(@login_token.user.id).to eq(@login_user.id)
226
+ expect(@login_token.auth_method.id).to eq(@login_method.id)
227
+ expect(@login_token.last_used_at).to be_a(Time)
228
+ end
229
+ end
230
+
231
+ context 'OAuth email is invalid' do
232
+ before(:each) do
233
+ user.update email: 'invalid'
234
+ expect(oauth).to receive(:email).twice { 'invalid' }
235
+ expect(oauth).to receive(:display_name) { 'New Name' }
236
+ end
237
+
238
+ it 'creates a new User' do
239
+ expect { login }.to change(User, :count).by(1)
240
+ expect(user.display_name).to eq('Existing User')
241
+ expect(@login_user.email).to eq('invalid')
242
+ expect(@login_user.display_name).to eq('New Name')
243
+ end
244
+
245
+ it 'creates a new AuthMethod' do
246
+ expect { login }.to change(AuthMethod, :count).by(1)
247
+ expect(auth_method.provider_id).to eq(5)
248
+ expect(@login_method.id).to_not eq(auth_method.id)
249
+ expect(@login_method.provider_name).to eq('github')
250
+ expect(@login_method.provider_id).to eq(10)
251
+ expect(@login_method.user.id).to eq(@login_user.id)
252
+ end
253
+
254
+ it 'creates and returns AuthToken' do
255
+ expect { login }.to change(AuthToken, :count).by(1)
256
+ expect(@login_token.user.id).to eq(@login_user.id)
257
+ expect(@login_token.auth_method.id).to eq(@login_method.id)
258
+ expect(@login_token.last_used_at).to be_a(Time)
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,171 @@
1
+ require 'spec_helper'
2
+
3
+ module SpecModels
4
+ class OAuthProvider < Jm81auth::OAuth::Base
5
+ DATA_URL = 'https://example.org/data'
6
+
7
+ def get_access_token
8
+ end
9
+ end
10
+ end
11
+
12
+ RSpec.describe Jm81auth::OAuth::Base do
13
+ let(:model) { SpecModels::OAuthProvider }
14
+
15
+ subject(:oauth) do
16
+ model.new access_token: 'TEST'
17
+ end
18
+
19
+ let(:data) do
20
+ {
21
+ 'id' => '123',
22
+ 'sub' => '456',
23
+ 'email' => 'first.last@example.com',
24
+ 'name' => 'First Last'
25
+ }
26
+ end
27
+
28
+ let(:json_data) do
29
+ <<-EOJSON
30
+ {
31
+ "id": "123",
32
+ "sub": "456",
33
+ "email": "first.last@example.com",
34
+ "name": "First Last"
35
+ }
36
+ EOJSON
37
+ end
38
+
39
+ let(:http_response) do
40
+ mock_response = double('http_response')
41
+ allow(mock_response).to receive(:body).and_return(json_data)
42
+ mock_response
43
+ end
44
+
45
+ before(:each) do
46
+ allow_any_instance_of(HTTPClient).to receive(:get) { http_response }
47
+ end
48
+
49
+ describe '#initialize' do
50
+ let(:params_hash) do
51
+ {
52
+ code: 'CODE',
53
+ redirectUri: 'https://example.org/redirect',
54
+ clientId: 'CLIENT_ID',
55
+ other: 'OTHER',
56
+ access_token: 'ACCESS_TOKEN'
57
+ }
58
+ end
59
+
60
+ it 'set @params' do
61
+ new_oauth = Jm81auth::OAuth::Base.new(params_hash)
62
+
63
+ expect(new_oauth.instance_variable_get(:@params)).to eq({
64
+ code: 'CODE',
65
+ redirect_uri: 'https://example.org/redirect',
66
+ client_id: 'CLIENT_ID',
67
+ client_secret: nil
68
+ })
69
+ end
70
+
71
+ context 'params includes access_token' do
72
+ it 'sets @access_token from params' do
73
+ expect_any_instance_of(model).to_not receive(:get_access_token)
74
+ new_oauth = model.new(params_hash)
75
+ expect(new_oauth.instance_variable_get(:@access_token)).
76
+ to eq('ACCESS_TOKEN')
77
+ end
78
+ end
79
+
80
+ context 'params does not include access_token' do
81
+ it 'set access_token using get_access_token' do
82
+ expect_any_instance_of(model).
83
+ to receive(:get_access_token).and_return('123abc')
84
+ new_oauth = model.new(params_hash.merge(access_token: nil))
85
+ expect(new_oauth.instance_variable_get(:@access_token)).
86
+ to eq('123abc')
87
+ end
88
+ end
89
+ end
90
+
91
+ describe 'data' do
92
+ context '@data set' do
93
+ let(:set_data) do
94
+ data.merge('email' => 'other@example.com')
95
+ end
96
+
97
+ before(:each) { oauth.instance_variable_set(:@data, set_data) }
98
+
99
+ it 'returns @data' do
100
+ expect(oauth).to_not receive(:get_data)
101
+ expect(oauth.data).to eq(set_data)
102
+ end
103
+ end
104
+
105
+ context '@data not set' do
106
+ it 'sets @data using get_data' do
107
+ expect(oauth).to receive(:get_data).and_call_original
108
+ oauth.data
109
+ end
110
+
111
+ it 'returns @data' do
112
+ expect(oauth.data).to eq(data)
113
+ end
114
+ end
115
+ end
116
+
117
+ describe '#display_name' do
118
+ it "gets data['name']" do
119
+ expect(oauth.display_name).to eq('First Last')
120
+ end
121
+ end
122
+
123
+ describe '#email' do
124
+ it "gets data['email']" do
125
+ expect(oauth.email).to eq('first.last@example.com')
126
+ end
127
+ end
128
+
129
+ describe '#get_data' do
130
+ it 'gets data from provider' do
131
+ expect_any_instance_of(HTTPClient).to receive(:get) { http_response }
132
+ expect(oauth.get_data).to eq(data)
133
+ end
134
+ end
135
+
136
+ describe '#provider_name' do
137
+ it 'gets provider name based on the class name' do
138
+ expect(oauth.provider_name).to eq('oauthprovider')
139
+ expect(Jm81auth::OAuth::Base.new(access_token: 'a').provider_name).
140
+ to eq('base')
141
+ end
142
+ end
143
+
144
+ describe 'provider_id' do
145
+ context "data['id'] is set" do
146
+ it "is data['id']" do
147
+ expect(oauth.provider_id).to eq('123')
148
+ end
149
+ end
150
+
151
+ context "data['id'] is not set" do
152
+ before(:each) do
153
+ expect(http_response).
154
+ to receive(:body).and_return(json_data.gsub(/id/, 'no_id'))
155
+ end
156
+
157
+ it "is data['sub']" do
158
+ expect(oauth.provider_id).to eq('456')
159
+ end
160
+ end
161
+ end
162
+
163
+ describe 'provider_data' do
164
+ it 'is a hash with provider_name and provider_id' do
165
+ expect(oauth.provider_data).to eq({
166
+ provider_name: 'oauthprovider',
167
+ provider_id: '123'
168
+ })
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,62 @@
1
+ require 'rubygems'
2
+ require 'bundler/setup'
3
+ Bundler.require
4
+ require 'jm81auth'
5
+ require 'sequel'
6
+ require 'factory_girl'
7
+
8
+ Dir['./spec/support/**/*.rb'].sort.each { |f| require f}
9
+
10
+ RSpec.configure do |config|
11
+ config.run_all_when_everything_filtered = true
12
+
13
+ config.include FactoryGirl::Syntax::Methods
14
+
15
+ config.before :suite do
16
+ FactoryGirl.find_definitions
17
+ end
18
+
19
+ config.around :each do |example|
20
+ DB.transaction(rollback: :always, auto_savepoint: true) { example.run }
21
+ end
22
+ end
23
+
24
+ DB = Sequel.sqlite
25
+
26
+ DB.create_table(:users) do
27
+ primary_key :id
28
+ column :email, "varchar(255)"
29
+ column :display_name, "varchar(255)"
30
+ end
31
+
32
+ DB.create_table(:auth_methods) do
33
+ primary_key :id
34
+ foreign_key :user_id, :users, :on_delete=>:cascade, :on_update=>:cascade
35
+ column :provider_name, "varchar(255)", :null=>false
36
+ column :provider_id, "integer unsigned", :null=>false
37
+ end
38
+
39
+ DB.create_table(:auth_tokens) do
40
+ primary_key :id
41
+ foreign_key :user_id, :users, :on_delete=>:restrict, :on_update=>:cascade
42
+ foreign_key :auth_method_id, :auth_methods, :on_delete=>:restrict, :on_update=>:cascade
43
+ column :created_at, "timestamp", :null=>false
44
+ column :last_used_at, "timestamp", :null=>false
45
+ column :closed_at, "timestamp"
46
+ end
47
+
48
+ Jm81auth.config do |config|
49
+ config.jwt_secret = 'testsecret'
50
+ end
51
+
52
+ class AuthMethod < Sequel::Model
53
+ include Jm81auth::Models::AuthMethod
54
+ end
55
+
56
+ class AuthToken < Sequel::Model
57
+ include Jm81auth::Models::AuthToken
58
+ end
59
+
60
+ class User < Sequel::Model
61
+ include Jm81auth::Models::User
62
+ end
metadata ADDED
@@ -0,0 +1,169 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jm81auth
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jared Morgan
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-03-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: httpclient
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.6'
27
+ - !ruby/object:Gem::Dependency
28
+ name: jwt
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.5'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.5'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: factory_girl
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '4.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '4.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: sequel
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '4.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '4.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: sqlite3
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.3'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.3'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.2'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '3.2'
111
+ description: I have no excuse for giving the world yet another auth lib.
112
+ email:
113
+ - jmorgan@mchost.net
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - MIT-LICENSE
119
+ - README.rdoc
120
+ - Rakefile
121
+ - lib/jm81auth.rb
122
+ - lib/jm81auth/configuration.rb
123
+ - lib/jm81auth/models/auth_method.rb
124
+ - lib/jm81auth/models/auth_token.rb
125
+ - lib/jm81auth/models/user.rb
126
+ - lib/jm81auth/oauth/base.rb
127
+ - lib/jm81auth/oauth/github.rb
128
+ - lib/jm81auth/version.rb
129
+ - spec/factories/auth_methods.rb
130
+ - spec/factories/auth_tokens.rb
131
+ - spec/factories/users.rb
132
+ - spec/jm81auth/models/auth_method_spec.rb
133
+ - spec/jm81auth/models/auth_token_spec.rb
134
+ - spec/jm81auth/models/user_spec.rb
135
+ - spec/jm81auth/oauth/base_spec.rb
136
+ - spec/spec_helper.rb
137
+ homepage: https://github.com/jm81/jm81auth
138
+ licenses:
139
+ - MIT
140
+ metadata: {}
141
+ post_install_message:
142
+ rdoc_options: []
143
+ require_paths:
144
+ - lib
145
+ required_ruby_version: !ruby/object:Gem::Requirement
146
+ requirements:
147
+ - - ">="
148
+ - !ruby/object:Gem::Version
149
+ version: '0'
150
+ required_rubygems_version: !ruby/object:Gem::Requirement
151
+ requirements:
152
+ - - ">="
153
+ - !ruby/object:Gem::Version
154
+ version: '0'
155
+ requirements: []
156
+ rubyforge_project:
157
+ rubygems_version: 2.2.2
158
+ signing_key:
159
+ specification_version: 4
160
+ summary: An authentication library for Rails API
161
+ test_files:
162
+ - spec/factories/users.rb
163
+ - spec/factories/auth_tokens.rb
164
+ - spec/factories/auth_methods.rb
165
+ - spec/jm81auth/models/auth_token_spec.rb
166
+ - spec/jm81auth/models/user_spec.rb
167
+ - spec/jm81auth/models/auth_method_spec.rb
168
+ - spec/jm81auth/oauth/base_spec.rb
169
+ - spec/spec_helper.rb