cognito_rails 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
+ SHA256:
3
+ metadata.gz: 48fe70f5578d90db88e1a7546f12a16a3b24941bf20a2e8bb4a37b24b3678adc
4
+ data.tar.gz: 5463df9f063b8b986ca1ef49d0f9a8efce22862854e7e74cb4aee77a8f101fea
5
+ SHA512:
6
+ metadata.gz: '058bb0a9820ee032ca2d56f7c3954c5acb434543d599ddf3aedb029e2c8b6e06b9d93445a9989248c0a9223cc5038a91cd680313d7d1735837c18826a3008999'
7
+ data.tar.gz: 5fa75f4e6109b4144a33759340672eee19253d33ba8e9c167b0c4187b85f7b736d8129cfd22221188119c793f8250d550b36ba49b55742d47133ec9fd7d4ec14
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module CognitoRails
6
+ class Config
7
+ class << self
8
+ # @raise [RuntimeError] if not set
9
+ # @return [String] AWS access key id
10
+ def aws_access_key_id
11
+ # @type [String,nil]
12
+ @aws_access_key_id || (raise 'Missing config aws_access_key_id')
13
+ end
14
+
15
+ # @!attribute aws_access_key_id [w]
16
+ # @return [String]
17
+ # @!attribute aws_region [w]
18
+ # @return [String]
19
+ # @!attribute aws_secret_access_key [w]
20
+ # @return [String]
21
+ # @!attribute aws_user_pool_id [w]
22
+ # @return [String]
23
+ # @!attribute default_user_class [w]
24
+ # @return [String,nil]
25
+ attr_writer :aws_access_key_id, :skip_model_hooks, :aws_region,
26
+ :aws_secret_access_key, :aws_user_pool_id,
27
+ :default_user_class
28
+
29
+ # @return [Boolean] skip model hooks
30
+ def skip_model_hooks
31
+ !!@skip_model_hooks
32
+ end
33
+
34
+ # @!attribute logger [rw]
35
+ # @return [Logger]
36
+ # @!attribute cache_adapter [rw]
37
+ # @return [#fetch,nil]
38
+ attr_accessor :logger, :cache_adapter
39
+
40
+ # @return [String] AWS region
41
+ # @raise [RuntimeError] if not set
42
+ def aws_region
43
+ @aws_region || (raise 'Missing config aws_region')
44
+ end
45
+
46
+ # @return [String] AWS secret access key
47
+ # @raise [RuntimeError] if not set
48
+ def aws_secret_access_key
49
+ @aws_secret_access_key || (raise 'Missing config aws_secret_access_key')
50
+ end
51
+
52
+ # @return [String] AWS user pool id
53
+ # @raise [RuntimeError] if not set
54
+ def aws_user_pool_id
55
+ @aws_user_pool_id || (raise 'Missing config aws_user_pool_id')
56
+ end
57
+
58
+ # @return [String] default user class
59
+ # @raise [RuntimeError] if not set
60
+ def default_user_class
61
+ @default_user_class || (raise 'Missing config default_user_class')
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CognitoRails
4
+ module Controller
5
+ extend ActiveSupport::Concern
6
+
7
+ # @scope class
8
+ # @!attribute _cognito_user_class [rw]
9
+ # @return [String,nil] class name of user model
10
+
11
+ included do
12
+ class_attribute :_cognito_user_class
13
+ end
14
+
15
+ # @return [ActiveRecord::Base,nil]
16
+ def current_user
17
+ @current_user ||= cognito_user_klass.find_by_cognito(external_cognito_id) if external_cognito_id
18
+ end
19
+
20
+ private
21
+
22
+ # @return [#find_by_cognito]
23
+ def cognito_user_klass
24
+ @cognito_user_klass ||= (self.class._cognito_user_class || CognitoRails::Config.default_user_class)&.constantize
25
+ end
26
+
27
+ # @return [String,nil] cognito user id
28
+ def external_cognito_id
29
+ # @type [String,nil]
30
+ token = request.headers['Authorization']&.split(' ')&.last
31
+
32
+ return unless token
33
+
34
+ CognitoRails::JWT.decode(token)&.dig(0, 'sub')
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+ require 'open-uri'
5
+ require 'json'
6
+
7
+ module CognitoRails
8
+ class JWT
9
+ class << self
10
+ # @param token [String] JWT token
11
+ # @return [Array<Hash>,nil]
12
+ def decode(token)
13
+ aws_idp = with_cache { URI.open(jwks_url).read }
14
+ jwt_config = JSON.parse(aws_idp, symbolize_names: true)
15
+
16
+ ::JWT.decode(token, nil, true, { jwks: jwt_config, algorithms: ['RS256'] })
17
+ rescue ::JWT::ExpiredSignature, ::JWT::VerificationError, ::JWT::DecodeError => e
18
+ Config.logger&.error e.message
19
+ nil
20
+ end
21
+
22
+ private
23
+
24
+ def jwks_url
25
+ "https://cognito-idp.#{Config.aws_region}.amazonaws.com/#{Config.aws_user_pool_id}/.well-known/jwks.json"
26
+ end
27
+
28
+ # @param block [Proc]
29
+ # @yield [String] to be cached
30
+ # @return [String] cached block
31
+ def with_cache(&block)
32
+ return yield unless Config.cache_adapter.respond_to?(:fetch)
33
+
34
+ Config.cache_adapter.fetch('aws_idp', expires_in: 4.hours, &block)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+
5
+ module CognitoRails
6
+ # ActiveRecord model extension
7
+ module Model
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ class_attribute :_cognito_verify_email
12
+ class_attribute :_cognito_verify_phone
13
+ class_attribute :_cognito_custom_attributes
14
+ class_attribute :_cognito_attribute_name
15
+ self._cognito_custom_attributes = []
16
+
17
+ before_create do
18
+ init_cognito_user unless CognitoRails::Config.skip_model_hooks
19
+ end
20
+
21
+ after_destroy do
22
+ destroy_cognito_user unless CognitoRails::Config.skip_model_hooks
23
+ end
24
+ end
25
+
26
+ # @return [String]
27
+ def cognito_external_id
28
+ self[self.class._cognito_attribute_name]
29
+ end
30
+
31
+ # @param value [String]
32
+ # @return [String]
33
+ def cognito_external_id=(value)
34
+ self[self.class._cognito_attribute_name] = value
35
+ end
36
+
37
+ def cognito_user
38
+ @cognito_user ||= User.find(cognito_external_id, user_class: self.class)
39
+ end
40
+
41
+ protected
42
+
43
+ def init_cognito_user
44
+ return if cognito_external_id.present?
45
+
46
+ attrs = { email: email, user_class: self.class }
47
+ attrs[:phone] = phone if respond_to?(:phone)
48
+ attrs[:custom_attributes] = instance_custom_attributes
49
+ cognito_user = User.new(attrs)
50
+ cognito_user.save!
51
+ self.cognito_external_id = cognito_user.id
52
+ end
53
+
54
+ # @return [Array<Hash>]
55
+ def instance_custom_attributes
56
+ _cognito_custom_attributes.map { |e| { name: e[:name], value: parse_custom_attribute_value(e[:value]) } }
57
+ end
58
+
59
+ def parse_custom_attribute_value(value)
60
+ if value.is_a? Symbol
61
+ self[value]
62
+ else
63
+ value
64
+ end
65
+ end
66
+
67
+ def destroy_cognito_user
68
+ cognito_user&.destroy!
69
+ end
70
+
71
+ class_methods do
72
+ # @param name [String] attribute name
73
+ # @return [ActiveRecord::Base] model class
74
+ def find_by_cognito(external_id)
75
+ find_by({ _cognito_attribute_name => external_id })
76
+ end
77
+
78
+ def cognito_verify_email
79
+ self._cognito_verify_email = true
80
+ end
81
+
82
+ def cognito_verify_phone
83
+ self._cognito_verify_phone = true
84
+ end
85
+
86
+ # @param name [String] attribute name
87
+ # @param value [String] attribute name
88
+ def define_cognito_attribute(name, value)
89
+ _cognito_custom_attributes << { name: "custom:#{name}", value: value }
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+ require 'active_model/validations'
5
+ require 'securerandom'
6
+ require 'aws-sdk-cognitoidentityprovider'
7
+
8
+ module CognitoRails
9
+ # A class to map the cognito user to a model-like object
10
+ #
11
+ # @!attribute id [rw]
12
+ # @return [String]
13
+ # @!attribute email [rw]
14
+ # @return [String]
15
+ # @!attribute password [rw]
16
+ # @return [String,nil]
17
+ # @!attribute phone [rw]
18
+ # @return [String,nil]
19
+ # @!attribute custom_attributes [rw]
20
+ # @return [Array<Hash>,nil]
21
+ # @!attribute user_class [rw]
22
+ # @return [Class,nil]
23
+ # rubocop:disable Metrics/ClassLength
24
+ class User
25
+ # rubocop:enable Metrics/ClassLength
26
+
27
+ include ActiveModel::Validations
28
+
29
+ attr_accessor :id, :email, :password, :phone, :custom_attributes, :user_class
30
+
31
+ validates :email, presence: true
32
+
33
+ # @param attributes [Hash]
34
+ # @option attributes [String] :email
35
+ # @option attributes [String, nil] :password
36
+ # @option attributes [String, nil] :phone
37
+ # @option attributes [Array<Hash>, nil] :custom_attributes
38
+ # @option attributes [Class, nil] :user_class
39
+ def initialize(attributes = {})
40
+ attributes = attributes.with_indifferent_access
41
+ self.email = attributes[:email]
42
+ self.password = SecureRandom.urlsafe_base64 || attributes[:password]
43
+ self.phone = attributes[:phone]
44
+ self.user_class = attributes[:user_class] || Config.default_user_class.constantize
45
+ self.custom_attributes = attributes[:custom_attributes]
46
+ end
47
+
48
+ # @param id [String]
49
+ # @param user_class [nil,Object]
50
+ # @return [CognitoRails::User]
51
+ def self.find(id, user_class = nil)
52
+ result = cognito_client.admin_get_user(
53
+ {
54
+ user_pool_id: CognitoRails::Config.aws_user_pool_id, # required
55
+ username: id # required
56
+ }
57
+ )
58
+ user = new(user_class: user_class)
59
+ user.id = result.username
60
+ user.email = result.user_attributes.find { |attribute| attribute[:name] == 'email' }[:value]
61
+ user.phone = result.user_attributes.find { |attribute| attribute[:name] == 'phone_number' }&.dig(:value)
62
+ user
63
+ end
64
+
65
+ # @param attributes [Hash]
66
+ # @option attributes [String] :email
67
+ # @option attributes [String] :password
68
+ # @option attributes [String, nil] :phone
69
+ # @option attributes [Array<Hash>, nil] :custom_attributes
70
+ # @option attributes [Class, nil] :user_class
71
+ # @return [CognitoRails::User]
72
+ def self.create!(attributes = {})
73
+ user = new(attributes)
74
+ user.save!
75
+ user
76
+ end
77
+
78
+ # @param attributes [Hash]
79
+ # @option attributes [String] :email
80
+ # @option attributes [String] :password
81
+ # @option attributes [String, nil] :phone
82
+ # @option attributes [Array<Hash>, nil] :custom_attributes
83
+ # @option attributes [Class, nil] :user_class
84
+ # @return [CognitoRails::User]
85
+ def self.create(attributes = {})
86
+ user = new(attributes)
87
+ user.save
88
+ user
89
+ end
90
+
91
+ # @return [Boolean]
92
+ def new_record?
93
+ !persisted?
94
+ end
95
+
96
+ # @return [Boolean]
97
+ def persisted?
98
+ id.present?
99
+ end
100
+
101
+ # @return [Boolean]
102
+ # @raise [ActiveRecord::RecordInvalid]
103
+ def save!
104
+ save || (raise ActiveRecord::RecordInvalid, self)
105
+ end
106
+
107
+ # @return [Boolean]
108
+ def save
109
+ return false unless validate
110
+
111
+ if persisted?
112
+ save_for_update
113
+ else
114
+ save_for_create
115
+ end
116
+
117
+ true
118
+ end
119
+
120
+ # @return [Boolean]
121
+ def destroy
122
+ return false if new_record?
123
+
124
+ cognito_client.admin_delete_user(
125
+ {
126
+ user_pool_id: CognitoRails::Config.aws_user_pool_id,
127
+ username: id
128
+ }
129
+ )
130
+ self.id = nil
131
+
132
+ true
133
+ end
134
+
135
+ # @return [Boolean]
136
+ # @raise [ActiveRecord::RecordInvalid]
137
+ def destroy!
138
+ destroy || (raise ActiveRecord::RecordInvalid, self)
139
+ end
140
+
141
+ private
142
+
143
+ # @return [Aws::CognitoIdentityProvider::Client]
144
+ def cognito_client
145
+ self.class.cognito_client
146
+ end
147
+
148
+ # @return [Boolean]
149
+ def verify_email?
150
+ user_class._cognito_verify_email
151
+ end
152
+
153
+ # @return [Boolean]
154
+ def verify_phone?
155
+ user_class._cognito_verify_phone
156
+ end
157
+
158
+ # @return [Aws::CognitoIdentityProvider::Client]
159
+ # @raise [RuntimeError]
160
+ def self.cognito_client
161
+ raise 'Can\'t create user in test mode' if Rails.env.test?
162
+
163
+ @cognito_client ||= Aws::CognitoIdentityProvider::Client.new(
164
+ access_key_id: CognitoRails::Config.aws_access_key_id,
165
+ secret_access_key: CognitoRails::Config.aws_secret_access_key,
166
+ region: CognitoRails::Config.aws_region
167
+ )
168
+ end
169
+
170
+ # @return [Array<Hash>]
171
+ def general_user_attributes
172
+ [
173
+ *([{ name: 'email', value: email }] if email),
174
+ *([{ name: 'phone_number', value: phone }] if phone),
175
+ *custom_attributes
176
+ ]
177
+ end
178
+
179
+ # @return [Array<Hash>]
180
+ def verify_user_attributes
181
+ [
182
+ *([{ name: 'email_verified', value: 'True' }] if verify_email?),
183
+ *([{ name: 'phone_number_verified', value: 'True' }] if verify_phone?)
184
+ ]
185
+ end
186
+
187
+ def save_for_create
188
+ resp = cognito_client.admin_create_user(
189
+ {
190
+ user_pool_id: CognitoRails::Config.aws_user_pool_id,
191
+ username: email,
192
+ temporary_password: password,
193
+ user_attributes: [
194
+ *general_user_attributes,
195
+ *verify_user_attributes
196
+ ]
197
+ }
198
+ )
199
+ self.id = resp.user.attributes.find { |a| a[:name] == 'sub' }[:value]
200
+ end
201
+
202
+ def save_for_update
203
+ cognito_client.admin_update_user_attributes(
204
+ {
205
+ user_pool_id: CognitoRails::Config.aws_user_pool_id,
206
+ username: id,
207
+ user_attributes: [
208
+ *general_user_attributes
209
+ ]
210
+ }
211
+ )
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CognitoRails
4
+ # @return [String] gem version
5
+ VERSION = '0.1.0'
6
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+ require 'active_support/dependencies/autoload'
5
+ require 'active_record'
6
+ require 'action_controller/metal'
7
+
8
+ # Provides a set of tools to integrate AWS Cognito in your Rails app
9
+ module CognitoRails
10
+ extend ActiveSupport::Concern
11
+ extend ActiveSupport::Autoload
12
+
13
+ autoload :Config
14
+ autoload :Controller
15
+ autoload :Model
16
+ autoload :User
17
+ autoload :JWT
18
+
19
+ # @private
20
+ module ModelInitializer
21
+ # @param attribute_name [String]
22
+ # @return [void]
23
+ def as_cognito_user(attribute_name: 'external_id')
24
+ send :include, CognitoRails::Model
25
+ self._cognito_attribute_name = attribute_name
26
+ end
27
+ end
28
+
29
+ # @private
30
+ module ControllerInitializer
31
+ # @param user_class [Class,nil]
32
+ # @return [void]
33
+ def cognito_authentication(user_class: nil)
34
+ send :include, CognitoRails::Controller
35
+ self._cognito_user_class = user_class
36
+ end
37
+ end
38
+ end
39
+
40
+ # rubocop:disable Lint/SendWithMixinArgument
41
+ ActiveRecord::Base.send(:extend, CognitoRails::ModelInitializer)
42
+ ActionController::Metal.send(:extend, CognitoRails::ControllerInitializer)
43
+ # rubocop:enable Lint/SendWithMixinArgument
@@ -0,0 +1,53 @@
1
+ require 'spec_helper'
2
+
3
+ # rubocop:disable Metrics/BlockLength
4
+ RSpec.describe CognitoRails::Controller, type: :model do
5
+ # rubocop:enable Metrics/BlockLength
6
+ include CognitoRails::Helpers
7
+
8
+ context 'with an API controller' do
9
+ class MyApiController < ActionController::API
10
+ cognito_authentication
11
+
12
+ def request
13
+ @request ||= OpenStruct.new({ headers: { 'Authorization' => 'Bearer aaaaa' } })
14
+ end
15
+ end
16
+ let(:controller) { MyApiController.new }
17
+
18
+ it 'returns no user if the bearer is invalid' do
19
+ expect(CognitoRails::JWT).to receive(:decode).at_least(:once).and_return([{ 'sub' => '123123123' }])
20
+ expect(controller.current_user).to eq(nil)
21
+ end
22
+
23
+ it 'returns a user if the token is correct' do
24
+ user = User.create!(email: sample_cognito_email, external_id: '123123123')
25
+
26
+ expect(CognitoRails::JWT).to receive(:decode).at_least(:once).and_return([{ 'sub' => '123123123' }])
27
+ expect(controller.current_user).to eq(user)
28
+ end
29
+ end
30
+
31
+ context 'with a standard controller' do
32
+ class MyController < ActionController::Base
33
+ cognito_authentication user_class: 'Admin'
34
+
35
+ def request
36
+ @request ||= OpenStruct.new({ headers: { 'Authorization' => 'Bearer aaaaa' } })
37
+ end
38
+ end
39
+ let(:controller) { MyController.new }
40
+
41
+ it 'returns no user if the bearer is invalid' do
42
+ expect(CognitoRails::JWT).to receive(:decode).at_least(:once).and_return([{ 'sub' => '123123123' }])
43
+ expect(controller.current_user).to eq(nil)
44
+ end
45
+
46
+ it 'returns a user if the token is correct' do
47
+ user = Admin.create!(email: sample_cognito_email, phone: sample_cognito_phone, cognito_id: '123123123')
48
+
49
+ expect(CognitoRails::JWT).to receive(:decode).at_least(:once).and_return([{ 'sub' => '123123123' }])
50
+ expect(controller.current_user).to eq(user)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ # rubocop:disable Metrics/BlockLength
6
+ RSpec.describe CognitoRails::JWT, type: :model do
7
+ # rubocop:enable Metrics/BlockLength
8
+ before do
9
+ allow(URI).to receive(:open).and_return(double(read: jwks))
10
+ end
11
+
12
+ context 'with an invalid jwtk' do
13
+ let(:jwks) { '{}' }
14
+
15
+ it 'decode returns nil' do
16
+ expect(described_class.decode('aaaa')).to be_nil
17
+ end
18
+ end
19
+
20
+ context 'with a valid jwtk' do
21
+ let(:jwk) { JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), 'optional-kid') }
22
+ let(:jwks) { { keys: [jwk.export] }.to_json }
23
+ let(:payload) { { 'data' => 'data' } }
24
+ let(:token) do
25
+ headers = { kid: jwk.kid }
26
+
27
+ JWT.encode(payload, jwk.keypair, 'RS256', headers)
28
+ end
29
+
30
+ it 'decodes a token correctly' do
31
+ expect(described_class.decode(token)[0]).to eq({
32
+ 'data' => 'data'
33
+ })
34
+ end
35
+
36
+ it 'fails to decode if the token is invalid' do
37
+ expect(described_class.decode('aaaa')).to be_nil
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ # rubocop:disable Metrics/BlockLength
6
+ RSpec.describe CognitoRails::User, type: :model do
7
+ include CognitoRails::Helpers
8
+
9
+ let(:sample_cognito_email) { 'some@mail.com' }
10
+ let(:sample_cognito_phone) { '123456789' }
11
+
12
+ it 'validates email presence' do
13
+ expect(subject).to have(1).error_on(:email)
14
+ subject.email = sample_cognito_email
15
+ expect(subject).to have(0).error_on(:email)
16
+ end
17
+
18
+ it 'finds an user by id' do
19
+ expect(described_class).to receive(:cognito_client).and_return(fake_cognito_client)
20
+
21
+ record = described_class.find(sample_cognito_id)
22
+ expect(record).to be_a(described_class)
23
+ expect(record.id).to eq(sample_cognito_id)
24
+ expect(record.email).to eq(sample_cognito_email)
25
+ expect(record.user_class).to eq(User)
26
+ end
27
+
28
+ it 'finds a user with admin class' do
29
+ expect(described_class).to receive(:cognito_client).and_return(fake_cognito_client)
30
+
31
+ record = described_class.find(sample_cognito_id, Admin)
32
+ expect(record.user_class).to eq(Admin)
33
+ end
34
+
35
+ it 'finds a user with default class' do
36
+ expect(described_class).to receive(:cognito_client).and_return(fake_cognito_client)
37
+
38
+ record = described_class.find(sample_cognito_id)
39
+ expect(record.user_class).to eq(CognitoRails::Config.default_user_class.constantize)
40
+ end
41
+
42
+ context 'persistence' do
43
+ it 'saves a new user' do
44
+ expect_any_instance_of(described_class).to receive(:cognito_client).and_return(fake_cognito_client)
45
+ subject.email = sample_cognito_email
46
+ subject.save!
47
+ expect(subject.id).to eq(sample_cognito_id)
48
+ end
49
+
50
+ it 'fails save on invalid record' do
51
+ expect { subject.save! }.to raise_error ActiveRecord::RecordInvalid
52
+ end
53
+ end
54
+
55
+ context 'deletion' do
56
+ it 'deletes a new user' do
57
+ allow_any_instance_of(described_class).to receive(:cognito_client).and_return(fake_cognito_client)
58
+ subject.email = sample_cognito_email
59
+ subject.save!
60
+
61
+ subject.destroy!
62
+ end
63
+
64
+ it 'fails save on invalid record' do
65
+ expect { subject.destroy! }.to raise_error ActiveRecord::RecordInvalid
66
+ end
67
+ end
68
+
69
+ context 'user' do
70
+ it 'creates a cognito user once created a new user' do
71
+ expect_any_instance_of(CognitoRails::User).to receive(:cognito_client).and_return(fake_cognito_client)
72
+
73
+ user = User.create!(email: sample_cognito_email)
74
+
75
+ expect(user.external_id).to eq(sample_cognito_id)
76
+ end
77
+
78
+ it 'destroys the cognito user once destroyed the user' do
79
+ expect(CognitoRails::User).to receive(:cognito_client).at_least(:once).and_return(fake_cognito_client)
80
+
81
+ user = User.create!(email: sample_cognito_email)
82
+
83
+ user.destroy!
84
+ end
85
+
86
+ it 'saves custom attributes in cognito' do
87
+ expect(CognitoRails::User).to receive(:cognito_client).at_least(:once).and_return(fake_cognito_client)
88
+
89
+ expect(fake_cognito_client).to receive(:admin_create_user).with(
90
+ hash_including(
91
+ user_attributes: array_including(
92
+ [
93
+ {
94
+ name: 'email_verified', value: 'True'
95
+ },
96
+ {
97
+ name: 'email', value: sample_cognito_email
98
+ },
99
+ {
100
+ name: 'custom:role', value: 'user'
101
+ },
102
+ {
103
+ name: 'custom:name', value: 'TestName'
104
+ }
105
+ ]
106
+ )
107
+ )
108
+ )
109
+
110
+ User.create!(email: sample_cognito_email, name: 'TestName')
111
+ end
112
+ end
113
+
114
+ context 'admin' do
115
+ before do
116
+ expect(CognitoRails::User).to receive(:cognito_client).at_least(:once).and_return(fake_cognito_client)
117
+ end
118
+
119
+ it '#find_by_cognito' do
120
+ admin = Admin.create!(email: sample_cognito_email, phone: '12345678')
121
+
122
+ expect(Admin.find_by_cognito(sample_cognito_id)).to eq(admin)
123
+ end
124
+
125
+ it 'creates a cognito user once created a new admin' do
126
+ admin = Admin.create!(email: sample_cognito_email, phone: '12345678')
127
+
128
+ expect(admin.cognito_external_id).to eq(sample_cognito_id)
129
+ end
130
+
131
+ it 'destroys the cognito user once destroyed the admin' do
132
+ admin = Admin.create!(email: sample_cognito_email, phone: '12345678')
133
+
134
+ admin.destroy!
135
+ end
136
+
137
+ it 'saves custom attributes in cognito' do
138
+ expect(fake_cognito_client).to receive(:admin_create_user).with(
139
+ hash_including(
140
+ user_attributes: array_including(
141
+ [
142
+ {
143
+ name: 'phone_number_verified', value: 'True'
144
+ },
145
+ {
146
+ name: 'email_verified', value: 'True'
147
+ },
148
+ {
149
+ name: 'phone_number', value: '12345678'
150
+ },
151
+ {
152
+ name: 'email', value: sample_cognito_email
153
+ },
154
+ {
155
+ name: 'custom:role', value: 'admin'
156
+ }
157
+ ]
158
+ )
159
+ )
160
+ )
161
+
162
+ Admin.create!(email: sample_cognito_email, phone: '12345678')
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,7 @@
1
+ FactoryBot.define do
2
+ factory :user do
3
+ sequence(:email) { |i| "email#{i}@cognito.com" }
4
+ sequence(:name) { |i| "TestName" }
5
+ sequence(:external_id) { |k| "extenralid-#{k}" }
6
+ end
7
+ end
@@ -0,0 +1,36 @@
1
+ require 'active_support'
2
+ require 'rails'
3
+ require 'rspec'
4
+ require 'active_record'
5
+ require 'action_controller'
6
+ require 'cognito_rails'
7
+ require 'factory_bot_rails'
8
+ require 'rspec/collection_matchers'
9
+ require 'factories/user'
10
+
11
+ I18n.enforce_available_locales = false
12
+ RSpec::Expectations.configuration.warn_about_potential_false_positives = false
13
+
14
+ Dir[File.expand_path('../support/*.rb', __FILE__)].each { |f| require f }
15
+
16
+ CognitoRails::Config.aws_access_key_id = 'access_key_id'
17
+ CognitoRails::Config.aws_region = 'region'
18
+ CognitoRails::Config.aws_secret_access_key = 'secret_access_key'
19
+ CognitoRails::Config.aws_user_pool_id = 'user_pool_id'
20
+ CognitoRails::Config.default_user_class = 'User'
21
+
22
+ RSpec.configure do |config|
23
+
24
+ config.include FactoryBot::Syntax::Methods
25
+
26
+ config.before(:suite) do
27
+ Schema.create
28
+ end
29
+
30
+ config.around(:each) do |example|
31
+ ActiveRecord::Base.transaction do
32
+ example.run
33
+ raise ActiveRecord::Rollback
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,43 @@
1
+ module CognitoRails::Helpers
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ let(:sample_cognito_id) { SecureRandom.uuid }
6
+ let(:sample_cognito_email) { 'some@mail.com' }
7
+ let(:sample_cognito_phone) { '123456789' }
8
+ let(:fake_cognito_client) do
9
+ client = double
10
+ allow(client).to receive(:admin_create_user) do |params|
11
+ expect(params).to match_structure(
12
+ user_pool_id: one_of(String, nil),
13
+ username: String,
14
+ temporary_password: String,
15
+ user_attributes: a_list_of(name: String, value: one_of(String, nil))
16
+ )
17
+ OpenStruct.new(user: OpenStruct.new(attributes: [{ name: 'sub', value: sample_cognito_id }]))
18
+ end
19
+
20
+ allow(client).to receive(:admin_delete_user) do |params|
21
+ expect(params).to match_structure(
22
+ user_pool_id: one_of(String, nil),
23
+ username: String
24
+ )
25
+ OpenStruct.new
26
+ end
27
+ allow(client).to receive(:admin_get_user).and_return(
28
+ OpenStruct.new(
29
+ {
30
+ username: sample_cognito_id,
31
+ user_attributes: [
32
+ { name: 'sub', value: sample_cognito_id },
33
+ { name: 'email', value: sample_cognito_email },
34
+ { name: 'phone', value: sample_cognito_phone },
35
+ { name: 'custom:name', value: "TestName" }
36
+ ]
37
+ }
38
+ )
39
+ )
40
+ client
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pp'
4
+
5
+ module Structure
6
+ module Type
7
+ class Error < ::StandardError; end
8
+ class SizeError < Error; end
9
+ class MatchError < Error; end
10
+
11
+ class Single
12
+ attr_reader :classes
13
+ def initialize(classes)
14
+ @classes = classes
15
+ end
16
+
17
+ def class?
18
+ @classes.all? { |c| c.is_a?(Class) || c.is_a?(Module) }
19
+ end
20
+
21
+ def matches?(json)
22
+ result = classes.any? do |s|
23
+ yield s, json
24
+ rescue Structure::Type::Error
25
+ false
26
+ end
27
+ raise MatchError, "#{json}\n is not one of #{inspect}" unless result
28
+
29
+ true
30
+ end
31
+
32
+ def inspect
33
+ "one_of(#{@classes})"
34
+ end
35
+ end
36
+
37
+ class Array < Single
38
+ def initialize(classes)
39
+ super(classes)
40
+ @max = 999_999
41
+ @min = 0
42
+ end
43
+
44
+ def between(min, max)
45
+ @min = min
46
+ @max = max
47
+ self
48
+ end
49
+
50
+ def at_least(number)
51
+ between(number, Float::INFINITY)
52
+ end
53
+
54
+ def with(number)
55
+ @number = number
56
+ self
57
+ end
58
+
59
+ def elements
60
+ @min = @number
61
+ @max = @number
62
+ self
63
+ end
64
+
65
+ def items
66
+ elements
67
+ end
68
+
69
+ def elements_at_most
70
+ raise 'Wrong use of at_most' unless @number
71
+
72
+ @max = @number
73
+ @number = nil
74
+ self
75
+ end
76
+
77
+ def elements_at_least
78
+ raise 'Wrong use of at_least' unless @number
79
+
80
+ @min = @number
81
+ @number = nil
82
+ self
83
+ end
84
+
85
+ def matches?(json)
86
+ raise SizeError, "Size Error: #{inspect} size (#{json.size}) is not between #{@min} and #{@max}." unless json.size.between?(@min, @max)
87
+
88
+ json.all? { |j| classes.any? { |s| yield s, j } }
89
+ end
90
+
91
+ def inspect
92
+ if class?
93
+ "a_list_of(#{@classes.inspect})"
94
+ else
95
+ "a_list_of(\n#{@classes.pretty_inspect})"
96
+ end
97
+ end
98
+ end
99
+
100
+ module Methods
101
+ def a_list_of(*class_list)
102
+ Structure::Type::Array.new(class_list)
103
+ end
104
+
105
+ def one_of(*class_list)
106
+ Structure::Type::Single.new(class_list)
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ include Structure::Type::Methods
113
+
114
+ RSpec::Matchers.define :match_structure do |structure|
115
+ match do |json|
116
+ @key = 'root'
117
+ begin
118
+ explore_structure(structure, json)
119
+ rescue Structure::Type::SizeError => e
120
+ @message = e.message
121
+ false
122
+ rescue Structure::Type::MatchError => e
123
+ @message = e.message
124
+ false
125
+ end
126
+ end
127
+
128
+ def size_fail(structure, json)
129
+ raise Structure::Type::SizeError,
130
+ "Wrong size at #{@key}: #{json.size} != #{structure.size}"
131
+ end
132
+
133
+ def structure_fail(structure, json)
134
+ raise Structure::Type::MatchError,
135
+ "Structure:\n#{structure.pretty_inspect}\nGiven:\n#{json.pretty_inspect}"
136
+ end
137
+
138
+ def explore_structure(struc, json)
139
+ # example: 1 ~= Integer
140
+ if struc.is_a? Class
141
+ structure_fail(struc, json) unless json.is_a?(struc)
142
+ # example: "foobar" ~= /f[o]+bar/
143
+ elsif struc.is_a?(Regexp) && json.is_a?(String)
144
+ structure_fail(struc, json) unless json.match?(struc)
145
+ # example: [1, 2, 3] ~= a_list_of(Integer)
146
+ elsif struc.is_a? Structure::Type::Array
147
+ struc.matches?(json) { |j, s| explore_structure(j, s) }
148
+ # example: 1 ~= one_of(Integer, String)
149
+ elsif struc.is_a? Structure::Type::Single
150
+ struc.matches?(json) { |j, s| explore_structure(j, s) }
151
+ # example: {a: b, c: d} ~= {a: Integer, b: String}
152
+ elsif json.is_a?(Hash)
153
+ structure_fail(struc, json) unless struc.is_a? Hash
154
+ struc = struc.with_indifferent_access
155
+ json = json.with_indifferent_access
156
+ struc.all? do |k, v|
157
+ @key = k
158
+ structure_fail(struc, json) unless json.key?(k)
159
+ explore_structure(v, json[k])
160
+ end
161
+ # example: [1, 2, 3] ~= [1, 2, 3]
162
+ elsif json.is_a?(Array)
163
+ structure_fail(struc, json) unless struc.is_a? Array
164
+ size_fail(struc, json) if struc.size != json.size
165
+ return struc.zip(json).all? do |s, j|
166
+ @key = s
167
+ explore_structure(s, j)
168
+ end
169
+ # example: 3 ~= 3
170
+ else
171
+ structure_fail(struc, json) unless json == struc
172
+ end
173
+ true
174
+ end
175
+
176
+ failure_message do |json|
177
+ "#{json.pretty_inspect}\ndoes not match structure\n#{structure.pretty_inspect}\n\n#{@message}"
178
+ end
179
+
180
+ failure_message_when_negated do |json|
181
+ "#{json.pretty_inspect}\nis matching structure\n#{structure.pretty_inspect}"
182
+ end
183
+ end
@@ -0,0 +1,51 @@
1
+ require 'active_record'
2
+
3
+ ActiveRecord::Base.establish_connection(
4
+ adapter: 'sqlite3',
5
+ database: ':memory:'
6
+ )
7
+
8
+ class User < ActiveRecord::Base
9
+ validates :email, presence: true
10
+ validates :email, uniqueness: true
11
+
12
+ as_cognito_user
13
+ cognito_verify_email
14
+ define_cognito_attribute 'role', 'user'
15
+ define_cognito_attribute 'name', :name
16
+ end
17
+
18
+ class Admin < ActiveRecord::Base
19
+ validates :email, presence: true
20
+ validates :email, uniqueness: true
21
+ validates :phone, presence: true
22
+ validates :phone, uniqueness: true
23
+
24
+ as_cognito_user attribute_name: 'cognito_id'
25
+ cognito_verify_email
26
+ cognito_verify_phone
27
+ define_cognito_attribute 'role', 'admin'
28
+ end
29
+
30
+ module Schema
31
+ def self.create
32
+ ActiveRecord::Migration.verbose = false
33
+
34
+ ActiveRecord::Schema.define do
35
+ create_table :users, force: true do |t|
36
+ t.string "email", null: false
37
+ t.string "name"
38
+ t.string "external_id", null: false
39
+ t.timestamps null: false
40
+ end
41
+
42
+ create_table :admins, force: true do |t|
43
+ t.string "email", null: false
44
+ t.string "phone", null: false
45
+ t.string "cognito_id", null: false
46
+ t.timestamps null: false
47
+ end
48
+ end
49
+
50
+ end
51
+ end
metadata ADDED
@@ -0,0 +1,141 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cognito_rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mònade
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-03-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '8'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '5'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '8'
33
+ - !ruby/object:Gem::Dependency
34
+ name: aws-sdk-cognitoidentityprovider
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: jwt
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: rspec
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3'
75
+ - !ruby/object:Gem::Dependency
76
+ name: rubocop
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ description: Add Cognito authentication to your Rails API
90
+ email: team@monade.io
91
+ executables: []
92
+ extensions: []
93
+ extra_rdoc_files: []
94
+ files:
95
+ - lib/cognito_rails.rb
96
+ - lib/cognito_rails/config.rb
97
+ - lib/cognito_rails/controller.rb
98
+ - lib/cognito_rails/jwt.rb
99
+ - lib/cognito_rails/model.rb
100
+ - lib/cognito_rails/user.rb
101
+ - lib/cognito_rails/version.rb
102
+ - spec/cognito_rails/controller_spec.rb
103
+ - spec/cognito_rails/jwt_spec.rb
104
+ - spec/cognito_rails/user_spec.rb
105
+ - spec/factories/user.rb
106
+ - spec/spec_helper.rb
107
+ - spec/support/cognito_helpers.rb
108
+ - spec/support/match_structure.rb
109
+ - spec/support/schema.rb
110
+ homepage: https://rubygems.org/gems/cognito_rails
111
+ licenses:
112
+ - MIT
113
+ metadata: {}
114
+ post_install_message:
115
+ rdoc_options: []
116
+ require_paths:
117
+ - lib
118
+ required_ruby_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: 2.7.0
123
+ required_rubygems_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: '0'
128
+ requirements: []
129
+ rubygems_version: 3.4.6
130
+ signing_key:
131
+ specification_version: 4
132
+ summary: Add Cognito authentication to your Rails API
133
+ test_files:
134
+ - spec/cognito_rails/controller_spec.rb
135
+ - spec/cognito_rails/jwt_spec.rb
136
+ - spec/cognito_rails/user_spec.rb
137
+ - spec/factories/user.rb
138
+ - spec/spec_helper.rb
139
+ - spec/support/cognito_helpers.rb
140
+ - spec/support/match_structure.rb
141
+ - spec/support/schema.rb