jm81auth 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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