api_guard_grape 0.5.1 → 0.5.6

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8fa6b87a02bcff0944fd185a949d84c63776d678928ca896fd66ea8b1ad4992a
4
- data.tar.gz: 29f4c5f87ca08a83ed96ec88f1b186ff9e76e0759b6748e1a603534e2ab16bd4
3
+ metadata.gz: 5cbdbe6a99ddb95a194ca2d457a00c632dcd8e70731b6e8bcebf02388c32a7ed
4
+ data.tar.gz: ded2f1d9537467657b493269e3a06093b2208f871a356f056aaf1179cd8e6e85
5
5
  SHA512:
6
- metadata.gz: 9981e401bbd55f0514320a1bf94aeba015f98c38c2b5a2d14cc10a745a487a52ccb134a4ce9043817fdc84c4974340a6bea9e682740c1ece82172ac9654cfae5
7
- data.tar.gz: 03efd2036116ee5e4fd46b08e7ee08635a702b08e2d5ff9d2d7c36745ede88a89df8197b0db5a39a36b99a63a831d08c283b59e45cca68acc114587024fb8bf8
6
+ metadata.gz: 37e5bb2e16a4d3733b630577c070be248fc9d24aa70209434fd0709b2c6efa60b32e3a90b76d08d4d7f127e3a2e0ef48b7ff4832bfd66a1f89f1120a911020ab
7
+ data.tar.gz: 97c8f918a4de68e43c42b207aeb964a8cd5b7c2ff12658533a61db86921b7b2cdd0e6fcd8f4467faa9e21a9706c527a05afe1f3fe440b4b962c00b99892f3d4e
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # API Guard
2
2
 
3
- [![Version](https://img.shields.io/gem/v/api_guard.svg?color=green)](https://rubygems.org/gems/api_guard_grape)
4
- [![Build Status](https://github.com/prateeksinghbundela/api_guard_grape/workflows/build/badge.svg?branch=master)](https://github.com/prateeksinghbundela/api_guard_grape/actions?query=workflow%3Abuild)
5
- [![Maintainability](https://api.codeclimate.com/v1/badges/ced3e74a26a66ed915cb/maintainability)](https://codeclimate.com/github/prateeksinghbundela/api_guard_grape/maintainability)
3
+ [![Version](https://img.shields.io/gem/v/api_guard.svg?color=green)](https://rubygems.org/gems/api_guard)
4
+ [![Build Status](https://github.com/Gokul595/api_guard/workflows/build/badge.svg?branch=master)](https://github.com/Gokul595/api_guard/actions?query=workflow%3Abuild)
5
+ [![Maintainability](https://api.codeclimate.com/v1/badges/ced3e74a26a66ed915cb/maintainability)](https://codeclimate.com/github/Gokul595/api_guard/maintainability)
6
6
 
7
7
 
8
8
  [JSON Web Token (JWT)](https://jwt.io/) based authentication solution with token refreshing & blacklisting for APIs
@@ -38,8 +38,8 @@ for cryptographic signing.
38
38
  * [Override finding resource](#override-finding-resource)
39
39
  * [Customizing / translating response messages using I18n](#customizing--translating-response-messages-using-i18n)
40
40
  * [Testing](#testing)
41
- * [Wiki](https://github.com/prateeksinghbundela/api_guard_grape/wiki)
42
- * [Using API Guard with Devise](https://github.com/prateeksinghbundela/api_guard_grape/wiki/Using-API-Guard-with-Devise)
41
+ * [Wiki](https://github.com/Gokul595/api_guard/wiki)
42
+ * [Using API Guard with Devise](https://github.com/Gokul595/api_guard/wiki/Using-API-Guard-with-Devise)
43
43
  * [Contributing](#contributing)
44
44
  * [License](#license)
45
45
 
@@ -48,7 +48,7 @@ for cryptographic signing.
48
48
  Add this line to your application's Gemfile:
49
49
 
50
50
  ```ruby
51
- gem 'api_guard_grape'
51
+ gem 'api_guard'
52
52
  ```
53
53
 
54
54
  And then execute in your terminal:
@@ -58,7 +58,7 @@ $ bundle install
58
58
 
59
59
  Or install it yourself as:
60
60
  ```bash
61
- $ gem install api_guard_grape
61
+ $ gem install api_guard
62
62
  ```
63
63
 
64
64
  ## Getting Started
@@ -82,7 +82,7 @@ $ rails db:migrate
82
82
  Add [has_secure_password](https://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html#method-i-has_secure_password)
83
83
  in `User` model for password authentication.
84
84
 
85
- > Refer [this Wiki](https://github.com/prateeksinghbundela/api_guard_grape/wiki/Using-API-Guard-with-Devise#authentication) for configuring API Guard authentication to work with Devise instead of using `has_secure_password`.
85
+ > Refer [this Wiki](https://github.com/Gokul595/api_guard/wiki/Using-API-Guard-with-Devise#authentication) for configuring API Guard authentication to work with Devise instead of using `has_secure_password`.
86
86
 
87
87
  ```ruby
88
88
  class User < ApplicationRecord
@@ -114,7 +114,7 @@ api_guard_routes for: 'users'
114
114
 
115
115
  This will generate default routes such as sign up, sign in, sign out, token refresh, password change for User.
116
116
 
117
- > Refer [this Wiki](https://github.com/prateeksinghbundela/api_guard_grape/wiki/Using-API-Guard-with-Devise#routes) for configuring API Guard routes to work with Devise.
117
+ > Refer [this Wiki](https://github.com/Gokul595/api_guard/wiki/Using-API-Guard-with-Devise#routes) for configuring API Guard routes to work with Devise.
118
118
 
119
119
  ### Registration
120
120
 
@@ -633,7 +633,7 @@ en:
633
633
  ```
634
634
 
635
635
  You can find the complete list of available keys in this file:
636
- https://github.com/prateeksinghbundela/api_guard_grape/blob/master/config/locales/en.yml
636
+ https://github.com/Gokul595/api_guard/blob/master/config/locales/en.yml
637
637
 
638
638
  ## Testing
639
639
 
@@ -680,7 +680,7 @@ Then, you can set the access token and refresh token in appropriate request head
680
680
 
681
681
  ## Contributing
682
682
 
683
- Bug reports and pull requests are welcome on GitHub at https://github.com/prateeksinghbundela/api_guard_grape.
683
+ Bug reports and pull requests are welcome on GitHub at https://github.com/Gokul595/api_guard.
684
684
  This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to
685
685
  the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
686
686
 
@@ -4,28 +4,28 @@ module ApiGuard
4
4
  module JwtAuth
5
5
  # Common module for token blacklisting functionality
6
6
  module BlacklistToken
7
- def blacklisted_token_association(resource)
7
+ def self.blacklisted_token_association(resource)
8
8
  resource.class.blacklisted_token_association
9
9
  end
10
10
 
11
- def token_blacklisting_enabled?(resource)
11
+ def self.token_blacklisting_enabled?(resource)
12
12
  blacklisted_token_association(resource).present?
13
13
  end
14
14
 
15
- def blacklisted_tokens_for(resource)
15
+ def self.blacklisted_tokens_for(resource)
16
16
  blacklisted_token_association = blacklisted_token_association(resource)
17
17
  resource.send(blacklisted_token_association)
18
18
  end
19
19
 
20
20
  # Returns whether the JWT token is blacklisted or not
21
- def blacklisted?(resource)
21
+ def self.blacklisted?(resource)
22
22
  return false unless token_blacklisting_enabled?(resource)
23
23
 
24
24
  blacklisted_tokens_for(resource).exists?(token: @token)
25
25
  end
26
26
 
27
27
  # Blacklist the current JWT token from future access
28
- def blacklist_token
28
+ def self.blacklist_token
29
29
  return unless token_blacklisting_enabled?(current_resource)
30
30
 
31
31
  blacklisted_tokens_for(current_resource).create(token: @token, expire_at: Time.at(@decoded_token[:exp]).utc)
@@ -1,30 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'jwt'
4
-
4
+ require 'api_guard/jwt_auth/refresh_jwt_token'
5
5
  module ApiGuard
6
6
  module JwtAuth
7
7
  # Common module for JWT operations
8
8
  module JsonWebToken
9
- def current_time
9
+
10
+ def self.current_time
10
11
  @current_time ||= Time.now.utc
11
12
  end
12
13
 
13
- def token_expire_at
14
+ def self.token_expire_at
14
15
  @token_expire_at ||= (current_time + ApiGuard.token_validity).to_i
15
16
  end
16
17
 
17
- def token_issued_at
18
+ def self.token_issued_at
18
19
  @token_issued_at ||= current_time.to_i
19
20
  end
20
21
 
21
22
  # Encode the payload with the secret key and return the JWT token
22
- def encode(payload)
23
+ def self.encode(payload)
23
24
  JWT.encode(payload, ApiGuard.token_signing_secret)
24
25
  end
25
26
 
26
27
  # Decode the JWT token and return the payload
27
- def decode(token, verify = true)
28
+ def self.decode(token, verify = true)
28
29
  HashWithIndifferentAccess.new(
29
30
  JWT.decode(token, ApiGuard.token_signing_secret, verify, verify_iat: true)[0]
30
31
  )
@@ -34,7 +35,7 @@ module ApiGuard
34
35
  # Also, create refresh token if enabled for the resource.
35
36
  #
36
37
  # This creates expired JWT token if the argument 'expired_token' is true which can be used for testing.
37
- def jwt_and_refresh_token(resource, resource_name, expired_token = false)
38
+ def self.jwt_and_refresh_token(resource, resource_name, expired_token = false)
38
39
  payload = {
39
40
  "#{resource_name}_id": resource.id,
40
41
  exp: expired_token ? token_issued_at : token_expire_at,
@@ -43,18 +44,17 @@ module ApiGuard
43
44
 
44
45
  # Add custom data in the JWT token payload
45
46
  payload.merge!(resource.jwt_token_payload) if resource.respond_to?(:jwt_token_payload)
46
-
47
- [encode(payload), new_refresh_token(resource)]
47
+ [self.encode(payload), self.new_refresh_token(resource)]
48
48
  end
49
49
 
50
50
  # Create tokens and set response headers
51
- def create_token_and_set_header(resource, resource_name)
51
+ def self.create_token_and_set_header(resource, resource_name)
52
52
  access_token, refresh_token = jwt_and_refresh_token(resource, resource_name)
53
53
  set_token_headers(access_token, refresh_token)
54
54
  end
55
55
 
56
56
  # Set token details in response headers
57
- def set_token_headers(token, refresh_token = nil)
57
+ def self.set_token_headers(token, refresh_token = nil)
58
58
  response.headers['Access-Token'] = token
59
59
  response.headers['Refresh-Token'] = refresh_token if refresh_token
60
60
  response.headers['Expire-At'] = token_expire_at.to_s
@@ -62,11 +62,169 @@ module ApiGuard
62
62
 
63
63
  # Set token issued at to current timestamp
64
64
  # to restrict access to old access(JWT) tokens
65
- def invalidate_old_jwt_tokens(resource)
65
+ def self.invalidate_old_jwt_tokens(resource)
66
66
  return unless ApiGuard.invalidate_old_tokens_on_password_change
67
67
 
68
68
  resource.token_issued_at = Time.at(token_issued_at).utc
69
69
  end
70
+
71
+ #refresh token code=======================================
72
+
73
+ def self.refresh_token_association(resource)
74
+ resource.class.refresh_token_association
75
+ end
76
+
77
+ def self.refresh_token_enabled?(resource)
78
+ refresh_token_association(resource).present?
79
+ end
80
+
81
+ def self.refresh_tokens_for(resource)
82
+ refresh_token_association = refresh_token_association(resource)
83
+ resource.send(refresh_token_association)
84
+ end
85
+
86
+ def self.find_refresh_token_of(resource, refresh_token)
87
+ refresh_tokens_for(resource).find_by_token(refresh_token)
88
+ end
89
+
90
+ # Generate and return unique refresh token for the resource
91
+ def self.uniq_refresh_token(resource)
92
+ loop do
93
+ random_token = SecureRandom.urlsafe_base64
94
+ return random_token unless refresh_tokens_for(resource).exists?(token: random_token)
95
+ end
96
+ end
97
+
98
+ # Create a new refresh_token for the current resource
99
+ def self.new_refresh_token(resource)
100
+ return unless refresh_token_enabled?(resource)
101
+
102
+ refresh_tokens_for(resource).create(token: uniq_refresh_token(resource)).token
103
+ end
104
+
105
+ def self.destroy_all_refresh_tokens(resource)
106
+ return unless refresh_token_enabled?(resource)
107
+
108
+ refresh_tokens_for(resource).destroy_all
109
+ end
110
+
111
+ # blacklisted ======================================================
112
+ def self.blacklisted_token_association(resource)
113
+ resource.class.blacklisted_token_association
114
+ end
115
+
116
+ def self.token_blacklisting_enabled?(resource)
117
+ blacklisted_token_association(resource).present?
118
+ end
119
+
120
+ def self.blacklisted_tokens_for(resource)
121
+ blacklisted_token_association = blacklisted_token_association(resource)
122
+ resource.send(blacklisted_token_association)
123
+ end
124
+
125
+ # Returns whether the JWT token is blacklisted or not
126
+ def self.blacklisted?(resource)
127
+ return false unless token_blacklisting_enabled?(resource)
128
+
129
+ blacklisted_tokens_for(resource).exists?(token: @token)
130
+ end
131
+
132
+ # Blacklist the current JWT token from future access
133
+ def self.blacklist_token
134
+
135
+ return unless token_blacklisting_enabled?(current_resource)
136
+ blacklisted_tokens_for(current_resource).create(token: @token, expire_at: Time.at(@decoded_token[:exp]).utc)
137
+ end
138
+
139
+
140
+ def self.method_missing(name, *args)
141
+ method_name = name.to_s
142
+
143
+ if method_name.start_with?('authenticate_and_set_')
144
+ resource_name = method_name.split('authenticate_and_set_')[1]
145
+ authenticate_and_set_resource(resource_name)
146
+ else
147
+ super
148
+ end
149
+ end
150
+
151
+ def self.respond_to_missing?(method_name, include_private = false)
152
+ method_name.to_s.start_with?('authenticate_and_set_') || super
153
+ end
154
+
155
+ # Authenticate the JWT token and set resource
156
+ def self.authenticate_and_set_resource(resource_name)
157
+ @resource_name = resource_name
158
+
159
+ @token = request.headers['Authorization']&.split('Bearer ')&.last
160
+ return render_error(401, message: I18n.t('api_guard.access_token.missing')) unless @token
161
+
162
+ authenticate_token
163
+
164
+ # Render error response only if no resource found and no previous render happened
165
+ render_error(401, message: I18n.t('api_guard.access_token.invalid')) if !current_resource && !performed?
166
+ rescue JWT::DecodeError => e
167
+ if e.message == 'Signature has expired'
168
+ render_error(401, message: I18n.t('api_guard.access_token.expired'))
169
+ else
170
+ render_error(401, message: I18n.t('api_guard.access_token.invalid'))
171
+ end
172
+ end
173
+
174
+ # Decode the JWT token
175
+ # and don't verify token expiry for refresh token API request
176
+ def self.decode_token
177
+ # TODO: Set token refresh controller dynamic
178
+ verify_token = (controller_name != 'tokens' || action_name != 'create')
179
+ @decoded_token = decode(@token, verify_token)
180
+ end
181
+
182
+ # Returns whether the JWT token is issued after the last password change
183
+ # Returns true if password hasn't changed by the user
184
+ def self.valid_issued_at?(resource)
185
+ return true unless ApiGuard.invalidate_old_tokens_on_password_change
186
+
187
+ !resource.token_issued_at || @decoded_token[:iat] >= resource.token_issued_at.to_i
188
+ end
189
+
190
+ # Defines "current_{{resource_name}}" method and "@current_{{resource_name}}" instance variable
191
+ # that returns "resource" value
192
+ def self.define_current_resource_accessors(resource)
193
+ define_singleton_method("current_#{@resource_name}") do
194
+ instance_variable_get("@current_#{@resource_name}") ||
195
+ instance_variable_set("@current_#{@resource_name}", resource)
196
+ end
197
+ end
198
+
199
+ # Authenticate the resource with the '{{resource_name}}_id' in the decoded JWT token
200
+ # and also, check for valid issued at time and not blacklisted
201
+ #
202
+ # Also, set "current_{{resource_name}}" method and "@current_{{resource_name}}" instance variable
203
+ # for accessing the authenticated resource
204
+ def self.authenticate_token
205
+ return unless decode_token
206
+
207
+ resource = find_resource_from_token(@resource_name.classify.constantize)
208
+
209
+ if resource && valid_issued_at?(resource) && !blacklisted?(resource)
210
+ define_current_resource_accessors(resource)
211
+ else
212
+ render_error(401, message: I18n.t('api_guard.access_token.invalid'))
213
+ end
214
+ end
215
+
216
+ def self.find_resource_from_token(resource_class)
217
+ resource_id = @decoded_token[:"#{@resource_name}_id"]
218
+ return if resource_id.blank?
219
+
220
+ resource_class.find_by(id: resource_id)
221
+ end
222
+
223
+ def self.current_resource
224
+ return unless respond_to?("current_#{@resource_name}")
225
+
226
+ public_send("current_#{@resource_name}")
227
+ end
70
228
  end
71
229
  end
72
230
  end
@@ -4,25 +4,25 @@ module ApiGuard
4
4
  module JwtAuth
5
5
  # Common module for refresh token functionality
6
6
  module RefreshJwtToken
7
- def refresh_token_association(resource)
7
+ def self.refresh_token_association(resource)
8
8
  resource.class.refresh_token_association
9
9
  end
10
10
 
11
- def refresh_token_enabled?(resource)
11
+ def self.refresh_token_enabled?(resource)
12
12
  refresh_token_association(resource).present?
13
13
  end
14
14
 
15
- def refresh_tokens_for(resource)
15
+ def self.refresh_tokens_for(resource)
16
16
  refresh_token_association = refresh_token_association(resource)
17
17
  resource.send(refresh_token_association)
18
18
  end
19
19
 
20
- def find_refresh_token_of(resource, refresh_token)
20
+ def self.find_refresh_token_of(resource, refresh_token)
21
21
  refresh_tokens_for(resource).find_by_token(refresh_token)
22
22
  end
23
23
 
24
24
  # Generate and return unique refresh token for the resource
25
- def uniq_refresh_token(resource)
25
+ def self.uniq_refresh_token(resource)
26
26
  loop do
27
27
  random_token = SecureRandom.urlsafe_base64
28
28
  return random_token unless refresh_tokens_for(resource).exists?(token: random_token)
@@ -30,17 +30,108 @@ module ApiGuard
30
30
  end
31
31
 
32
32
  # Create a new refresh_token for the current resource
33
- def new_refresh_token(resource)
33
+ def self.new_refresh_token(resource)
34
34
  return unless refresh_token_enabled?(resource)
35
35
 
36
36
  refresh_tokens_for(resource).create(token: uniq_refresh_token(resource)).token
37
37
  end
38
38
 
39
- def destroy_all_refresh_tokens(resource)
39
+ def self.destroy_all_refresh_tokens(resource)
40
40
  return unless refresh_token_enabled?(resource)
41
41
 
42
42
  refresh_tokens_for(resource).destroy_all
43
43
  end
44
+
45
+ # authenticat-----------
46
+
47
+ def self.method_missing(name, *args)
48
+ method_name = name.to_s
49
+
50
+ if method_name.start_with?('authenticate_and_set_')
51
+ resource_name = method_name.split('authenticate_and_set_')[1]
52
+ authenticate_and_set_resource(resource_name)
53
+ else
54
+ super
55
+ end
56
+ end
57
+
58
+ def self.respond_to_missing?(method_name, include_private = false)
59
+ method_name.to_s.start_with?('authenticate_and_set_') || super
60
+ end
61
+
62
+ # Authenticate the JWT token and set resource
63
+ def self.authenticate_and_set_resource(resource_name)
64
+ @resource_name = resource_name
65
+
66
+ @token = request.headers['Authorization']&.split('Bearer ')&.last
67
+ return render_error(401, message: I18n.t('api_guard.access_token.missing')) unless @token
68
+
69
+ authenticate_token
70
+
71
+ # Render error response only if no resource found and no previous render happened
72
+ render_error(401, message: I18n.t('api_guard.access_token.invalid')) if !current_resource && !performed?
73
+ rescue JWT::DecodeError => e
74
+ if e.message == 'Signature has expired'
75
+ render_error(401, message: I18n.t('api_guard.access_token.expired'))
76
+ else
77
+ render_error(401, message: I18n.t('api_guard.access_token.invalid'))
78
+ end
79
+ end
80
+
81
+ # Decode the JWT token
82
+ # and don't verify token expiry for refresh token API request
83
+ def self.decode_token
84
+ # TODO: Set token refresh controller dynamic
85
+ verify_token = (controller_name != 'tokens' || action_name != 'create')
86
+ @decoded_token = decode(@token, verify_token)
87
+ end
88
+
89
+ # Returns whether the JWT token is issued after the last password change
90
+ # Returns true if password hasn't changed by the user
91
+ def self.valid_issued_at?(resource)
92
+ return true unless ApiGuard.invalidate_old_tokens_on_password_change
93
+
94
+ !resource.token_issued_at || @decoded_token[:iat] >= resource.token_issued_at.to_i
95
+ end
96
+
97
+ # Defines "current_{{resource_name}}" method and "@current_{{resource_name}}" instance variable
98
+ # that returns "resource" value
99
+ def self.define_current_resource_accessors(resource)
100
+ define_singleton_method("current_#{@resource_name}") do
101
+ instance_variable_get("@current_#{@resource_name}") ||
102
+ instance_variable_set("@current_#{@resource_name}", resource)
103
+ end
104
+ end
105
+
106
+ # Authenticate the resource with the '{{resource_name}}_id' in the decoded JWT token
107
+ # and also, check for valid issued at time and not blacklisted
108
+ #
109
+ # Also, set "current_{{resource_name}}" method and "@current_{{resource_name}}" instance variable
110
+ # for accessing the authenticated resource
111
+ def self.authenticate_token
112
+ return unless decode_token
113
+
114
+ resource = find_resource_from_token(@resource_name.classify.constantize)
115
+
116
+ if resource && valid_issued_at?(resource) && !blacklisted?(resource)
117
+ define_current_resource_accessors(resource)
118
+ else
119
+ render_error(401, message: I18n.t('api_guard.access_token.invalid'))
120
+ end
121
+ end
122
+
123
+ def self.find_resource_from_token(resource_class)
124
+ resource_id = @decoded_token[:"#{@resource_name}_id"]
125
+ return if resource_id.blank?
126
+
127
+ resource_class.find_by(id: resource_id)
128
+ end
129
+
130
+ def self.current_resource
131
+ return unless respond_to?("current_#{@resource_name}")
132
+
133
+ public_send("current_#{@resource_name}")
134
+ end
44
135
  end
45
136
  end
46
137
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ApiGuard
4
- VERSION = '0.5.1'
4
+ VERSION = '0.5.6'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: api_guard_grape
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.5.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Prateek Singh
@@ -176,7 +176,7 @@ files:
176
176
  - lib/generators/api_guard/initializer/USAGE
177
177
  - lib/generators/api_guard/initializer/initializer_generator.rb
178
178
  - lib/generators/api_guard/initializer/templates/initializer.rb
179
- homepage: https://github.com/prateeksinghbundela/api_guard
179
+ homepage: https://github.com/prateeksinghbundela/api_guard_grape
180
180
  licenses:
181
181
  - MIT
182
182
  metadata: {}
@@ -195,7 +195,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
195
195
  - !ruby/object:Gem::Version
196
196
  version: '0'
197
197
  requirements: []
198
- rubygems_version: 3.0.3
198
+ rubygems_version: 3.1.4
199
199
  signing_key:
200
200
  specification_version: 4
201
201
  summary: Rails API authentication made easy