cognito_rails 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
+ 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