activeadmin-device_authorization 0.1.3

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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +25 -0
  3. data/CODE_OF_CONDUCT.md +26 -0
  4. data/CONTRIBUTING.md +23 -0
  5. data/GOVERNANCE.md +25 -0
  6. data/LICENSE.md +21 -0
  7. data/README.md +162 -0
  8. data/SECURITY.md +25 -0
  9. data/activeadmin-device_authorization.gemspec +62 -0
  10. data/app/controllers/active_admin/device_authorization/application_controller.rb +9 -0
  11. data/app/controllers/active_admin/device_authorization/consents_controller.rb +83 -0
  12. data/app/controllers/active_admin/device_authorization/device_authorizations_controller.rb +51 -0
  13. data/app/controllers/active_admin/device_authorization/json_response.rb +25 -0
  14. data/app/controllers/active_admin/device_authorization/tokens_controller.rb +39 -0
  15. data/app/views/active_admin/device_authorization/_consent.html.erb +111 -0
  16. data/app/views/active_admin/device_authorization/_enter_code.html.erb +12 -0
  17. data/app/views/active_admin/device_authorization/_standalone_styles.html.erb +147 -0
  18. data/app/views/active_admin/device_authorization/consents/index.html.erb +19 -0
  19. data/app/views/active_admin/device_authorization/consents/show.html.erb +14 -0
  20. data/config/locales/en.yml +29 -0
  21. data/config/locales/fi.yml +29 -0
  22. data/config/locales/sv.yml +29 -0
  23. data/config/polyrun_coverage.yml +8 -0
  24. data/lib/active_admin/device_authorization/access_token_expiry.rb +46 -0
  25. data/lib/active_admin/device_authorization/callback.rb +15 -0
  26. data/lib/active_admin/device_authorization/configuration.rb +229 -0
  27. data/lib/active_admin/device_authorization/consent.rb +65 -0
  28. data/lib/active_admin/device_authorization/device_authorization_request.rb +70 -0
  29. data/lib/active_admin/device_authorization/engine.rb +17 -0
  30. data/lib/active_admin/device_authorization/page.rb +126 -0
  31. data/lib/active_admin/device_authorization/retention.rb +39 -0
  32. data/lib/active_admin/device_authorization/routes.rb +60 -0
  33. data/lib/active_admin/device_authorization/token_exchange.rb +144 -0
  34. data/lib/active_admin/device_authorization/user_code.rb +48 -0
  35. data/lib/active_admin/device_authorization/version.rb +7 -0
  36. data/lib/active_admin/device_authorization.rb +41 -0
  37. data/lib/activeadmin-device_authorization.rb +3 -0
  38. data/lib/tasks/active_admin_device_authorization.rake +10 -0
  39. data/sig/active_admin/device_authorization.rbs +5 -0
  40. metadata +296 -0
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module DeviceAuthorization
5
+ class TokenExchange
6
+ class Error < StandardError
7
+ attr_reader :code, :http_status
8
+
9
+ def initialize(code, http_status: 400)
10
+ @code = code
11
+ @http_status = http_status
12
+ super(code)
13
+ end
14
+ end
15
+
16
+ GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
17
+
18
+ def initialize(client_id:, device_code:, config: ActiveAdmin::DeviceAuthorization.config)
19
+ @client_id = client_id
20
+ @device_code = device_code
21
+ @config = config
22
+ @expiry = AccessTokenExpiry.new(config: config)
23
+ end
24
+
25
+ def call
26
+ client = @config.client_class.enabled.find_by(client_id: @client_id)
27
+ raise Error.new("invalid_client") unless client
28
+
29
+ result = @config.grant_class.transaction do
30
+ grant = @config.grant_class.lock.find_by(
31
+ client_association => client,
32
+ :device_code_digest => @config.digest(@device_code)
33
+ )
34
+ raise Error.new("invalid_grant") unless grant
35
+
36
+ grant.mark_expired_if_needed!
37
+ grant.reload
38
+
39
+ case grant.status
40
+ when "pending"
41
+ poll_error(grant)
42
+ when "denied"
43
+ raise Error.new("access_denied")
44
+ when "expired"
45
+ raise Error.new("expired_token")
46
+ when "approved"
47
+ issue_or_reject_already_delivered!(grant, client)
48
+ else
49
+ raise Error.new("invalid_grant")
50
+ end
51
+ end
52
+ raise result if result.is_a?(Error)
53
+
54
+ Callback.call(
55
+ @config.on_token_issued,
56
+ resource_owner: result.fetch(:token).public_send(resource_owner_association),
57
+ token: result.fetch(:token)
58
+ )
59
+ result.fetch(:payload)
60
+ end
61
+
62
+ private
63
+
64
+ def client_association
65
+ @config.client_class_name.underscore.to_sym
66
+ end
67
+
68
+ def resource_owner_association
69
+ @config.resource_owner_class_name.underscore.to_sym
70
+ end
71
+
72
+ def access_token_association
73
+ # has_one name on grant; default DeviceAuthorizationGrant -> device_authorization_access_token
74
+ @config.access_token_class_name.underscore.to_sym
75
+ end
76
+
77
+ def issue_or_reject_already_delivered!(grant, client)
78
+ if grant.public_send(access_token_association).present?
79
+ raise Error.new("invalid_grant")
80
+ end
81
+
82
+ plaintext = SecureRandom.urlsafe_base64(32)
83
+ now = Time.current
84
+ ttl = grant.try(:absolute_ttl_seconds)
85
+ absolute_expires_at = @expiry.absolute_expires_at_for(ttl_seconds: ttl, now: now)
86
+ # Column is NOT NULL in typical hosts; when absolute expiry is disabled,
87
+ # store a far-future sentinel so ActiveRecord hosts stay compatible.
88
+ absolute_expires_at ||= now + (100 * 365 * 24 * 60 * 60)
89
+
90
+ token = @config.access_token_class.create!(
91
+ client_association => client,
92
+ resource_owner_association => grant.public_send(resource_owner_association),
93
+ grant_association => grant,
94
+ :token_digest => @config.digest(plaintext),
95
+ :scopes => grant.granted_scopes,
96
+ :absolute_expires_at => absolute_expires_at,
97
+ :last_used_at => now
98
+ )
99
+ payload = {
100
+ access_token: plaintext,
101
+ token_type: "Bearer",
102
+ scope: Array(token.scopes).join(" ")
103
+ }
104
+ expires_in = @expiry.expires_in_seconds(
105
+ absolute_expires_at: token.absolute_expires_at,
106
+ last_used_at: token.last_used_at,
107
+ now: now
108
+ )
109
+ payload[:expires_in] = expires_in unless expires_in.nil?
110
+ {payload: payload, token: token}
111
+ end
112
+
113
+ def grant_association
114
+ @config.grant_class_name.underscore.to_sym
115
+ end
116
+
117
+ def poll_error(grant)
118
+ required = required_poll_interval(grant)
119
+ too_fast = grant.last_poll_at.present? && grant.last_poll_at > required.seconds.ago
120
+ attrs = {last_poll_at: Time.current}
121
+ if too_fast
122
+ next_interval = required + @config.slow_down_increment.to_i
123
+ if grant.respond_to?(:poll_interval_seconds=)
124
+ attrs[:poll_interval_seconds] = next_interval
125
+ end
126
+ end
127
+ grant.update!(attrs)
128
+ Error.new(too_fast ? "slow_down" : "authorization_pending")
129
+ end
130
+
131
+ # Hosts may store the raised interval on the grant (poll_interval_seconds).
132
+ # Without that column, slow_down still gates on the base poll_interval only.
133
+ def required_poll_interval(grant)
134
+ base = @config.poll_interval.to_i
135
+ stored =
136
+ if grant.respond_to?(:poll_interval_seconds)
137
+ grant.poll_interval_seconds
138
+ end
139
+ value = stored.to_i
140
+ value.positive? ? value : base
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module DeviceAuthorization
5
+ module UserCode
6
+ MAX_GENERATION_ATTEMPTS = 100
7
+ class GenerationExhausted < StandardError; end
8
+
9
+ module_function
10
+
11
+ def generate(config: ActiveAdmin::DeviceAuthorization.config, exists: nil)
12
+ alphabet = Array(config.user_code_alphabet)
13
+ raise ArgumentError, "user_code_alphabet must not be empty" if alphabet.empty?
14
+
15
+ count = config.user_code_character_count.to_i
16
+ group = config.user_code_group_size.to_i
17
+ raise ArgumentError, "user_code_character_count must be positive" if count <= 0
18
+
19
+ MAX_GENERATION_ATTEMPTS.times do
20
+ chars = Array.new(count) { alphabet[SecureRandom.random_number(alphabet.size)] }
21
+ code =
22
+ if group.positive?
23
+ chars.each_slice(group).map(&:join).join("-")
24
+ else
25
+ chars.join
26
+ end
27
+ return code if exists.nil? || !exists.call(code)
28
+ end
29
+
30
+ raise GenerationExhausted, "could not allocate a unique user code"
31
+ end
32
+
33
+ def normalize(value, config: ActiveAdmin::DeviceAuthorization.config)
34
+ raw = value.to_s.strip.upcase
35
+ alphabet = Array(config.user_code_alphabet).each_with_object({}) do |character, canonical|
36
+ canonical[character.to_s.upcase] = character.to_s
37
+ end
38
+ characters = raw.each_char.filter_map { |character| alphabet[character.upcase] }
39
+ return raw unless characters.length == config.user_code_character_count.to_i
40
+
41
+ group = config.user_code_group_size.to_i
42
+ return characters.join unless group.positive?
43
+
44
+ characters.each_slice(group).map(&:join).join("-")
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module DeviceAuthorization
5
+ VERSION = "0.1.3"
6
+ end
7
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "securerandom"
5
+ require "active_support"
6
+ require "active_support/core_ext/object/blank"
7
+ require "active_support/core_ext/numeric/time"
8
+
9
+ require_relative "device_authorization/version"
10
+ require_relative "device_authorization/configuration"
11
+ require_relative "device_authorization/callback"
12
+ require_relative "device_authorization/user_code"
13
+ require_relative "device_authorization/access_token_expiry"
14
+ require_relative "device_authorization/device_authorization_request"
15
+ require_relative "device_authorization/consent"
16
+ require_relative "device_authorization/token_exchange"
17
+ require_relative "device_authorization/retention"
18
+ require_relative "device_authorization/routes"
19
+ require_relative "device_authorization/page"
20
+
21
+ module ActiveAdmin
22
+ module DeviceAuthorization
23
+ class << self
24
+ def config
25
+ @config ||= Configuration.new
26
+ end
27
+
28
+ def configure
29
+ yield config
30
+ config.validate!
31
+ config
32
+ end
33
+
34
+ def reset_config!
35
+ @config = Configuration.new
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ require_relative "device_authorization/engine" if defined?(Rails::Engine)
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_admin/device_authorization"
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :active_admin do
4
+ namespace :device_authorization do
5
+ desc "Delete expired device authorization records"
6
+ task retention: :environment do
7
+ ActiveAdmin::DeviceAuthorization::Retention.call
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,5 @@
1
+ module ActiveAdmin
2
+ module DeviceAuthorization
3
+ VERSION: String
4
+ end
5
+ end
metadata ADDED
@@ -0,0 +1,296 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activeadmin-device_authorization
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.3
5
+ platform: ruby
6
+ authors:
7
+ - Andrei Makarov
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activeadmin
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '3.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 7.2.3.2
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 7.2.3.2
40
+ - !ruby/object:Gem::Dependency
41
+ name: appraisal
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2'
54
+ - !ruby/object:Gem::Dependency
55
+ name: bundler
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '2'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '2'
68
+ - !ruby/object:Gem::Dependency
69
+ name: bundler-audit
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.9'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.9'
82
+ - !ruby/object:Gem::Dependency
83
+ name: polyrun
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 2.2.0
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 2.2.0
96
+ - !ruby/object:Gem::Dependency
97
+ name: rake
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '13'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '13'
110
+ - !ruby/object:Gem::Dependency
111
+ name: rbs
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '4'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '4'
124
+ - !ruby/object:Gem::Dependency
125
+ name: rspec
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '3'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '3'
138
+ - !ruby/object:Gem::Dependency
139
+ name: rubocop-rspec
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '3.8'
145
+ type: :development
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '3.8'
152
+ - !ruby/object:Gem::Dependency
153
+ name: rubocop-thread_safety
154
+ requirement: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - "~>"
157
+ - !ruby/object:Gem::Version
158
+ version: '0.7'
159
+ type: :development
160
+ prerelease: false
161
+ version_requirements: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - "~>"
164
+ - !ruby/object:Gem::Version
165
+ version: '0.7'
166
+ - !ruby/object:Gem::Dependency
167
+ name: standard
168
+ requirement: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - "~>"
171
+ - !ruby/object:Gem::Version
172
+ version: '1.52'
173
+ type: :development
174
+ prerelease: false
175
+ version_requirements: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - "~>"
178
+ - !ruby/object:Gem::Version
179
+ version: '1.52'
180
+ - !ruby/object:Gem::Dependency
181
+ name: standard-custom
182
+ requirement: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - "~>"
185
+ - !ruby/object:Gem::Version
186
+ version: '1.0'
187
+ type: :development
188
+ prerelease: false
189
+ version_requirements: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - "~>"
192
+ - !ruby/object:Gem::Version
193
+ version: '1.0'
194
+ - !ruby/object:Gem::Dependency
195
+ name: standard-performance
196
+ requirement: !ruby/object:Gem::Requirement
197
+ requirements:
198
+ - - "~>"
199
+ - !ruby/object:Gem::Version
200
+ version: '1.8'
201
+ type: :development
202
+ prerelease: false
203
+ version_requirements: !ruby/object:Gem::Requirement
204
+ requirements:
205
+ - - "~>"
206
+ - !ruby/object:Gem::Version
207
+ version: '1.8'
208
+ - !ruby/object:Gem::Dependency
209
+ name: standard-rspec
210
+ requirement: !ruby/object:Gem::Requirement
211
+ requirements:
212
+ - - "~>"
213
+ - !ruby/object:Gem::Version
214
+ version: '0.3'
215
+ type: :development
216
+ prerelease: false
217
+ version_requirements: !ruby/object:Gem::Requirement
218
+ requirements:
219
+ - - "~>"
220
+ - !ruby/object:Gem::Version
221
+ version: '0.3'
222
+ description: Configurable device authorization grant, ActiveAdmin consent UI, and
223
+ short-lived bearer tokens for admin APIs.
224
+ email:
225
+ - contact@kiskolabs.com
226
+ executables: []
227
+ extensions: []
228
+ extra_rdoc_files: []
229
+ files:
230
+ - CHANGELOG.md
231
+ - CODE_OF_CONDUCT.md
232
+ - CONTRIBUTING.md
233
+ - GOVERNANCE.md
234
+ - LICENSE.md
235
+ - README.md
236
+ - SECURITY.md
237
+ - activeadmin-device_authorization.gemspec
238
+ - app/controllers/active_admin/device_authorization/application_controller.rb
239
+ - app/controllers/active_admin/device_authorization/consents_controller.rb
240
+ - app/controllers/active_admin/device_authorization/device_authorizations_controller.rb
241
+ - app/controllers/active_admin/device_authorization/json_response.rb
242
+ - app/controllers/active_admin/device_authorization/tokens_controller.rb
243
+ - app/views/active_admin/device_authorization/_consent.html.erb
244
+ - app/views/active_admin/device_authorization/_enter_code.html.erb
245
+ - app/views/active_admin/device_authorization/_standalone_styles.html.erb
246
+ - app/views/active_admin/device_authorization/consents/index.html.erb
247
+ - app/views/active_admin/device_authorization/consents/show.html.erb
248
+ - config/locales/en.yml
249
+ - config/locales/fi.yml
250
+ - config/locales/sv.yml
251
+ - config/polyrun_coverage.yml
252
+ - lib/active_admin/device_authorization.rb
253
+ - lib/active_admin/device_authorization/access_token_expiry.rb
254
+ - lib/active_admin/device_authorization/callback.rb
255
+ - lib/active_admin/device_authorization/configuration.rb
256
+ - lib/active_admin/device_authorization/consent.rb
257
+ - lib/active_admin/device_authorization/device_authorization_request.rb
258
+ - lib/active_admin/device_authorization/engine.rb
259
+ - lib/active_admin/device_authorization/page.rb
260
+ - lib/active_admin/device_authorization/retention.rb
261
+ - lib/active_admin/device_authorization/routes.rb
262
+ - lib/active_admin/device_authorization/token_exchange.rb
263
+ - lib/active_admin/device_authorization/user_code.rb
264
+ - lib/active_admin/device_authorization/version.rb
265
+ - lib/activeadmin-device_authorization.rb
266
+ - lib/tasks/active_admin_device_authorization.rake
267
+ - sig/active_admin/device_authorization.rbs
268
+ homepage: https://github.com/amkisko/activeadmin-device_authorization.rb
269
+ licenses:
270
+ - MIT
271
+ metadata:
272
+ homepage_uri: https://github.com/amkisko/activeadmin-device_authorization.rb
273
+ source_code_uri: https://github.com/amkisko/activeadmin-device_authorization.rb/tree/main
274
+ changelog_uri: https://github.com/amkisko/activeadmin-device_authorization.rb/blob/main/CHANGELOG.md
275
+ bug_tracker_uri: https://github.com/amkisko/activeadmin-device_authorization.rb/issues
276
+ documentation_uri: https://github.com/amkisko/activeadmin-device_authorization.rb#readme
277
+ allowed_push_host: https://rubygems.org
278
+ rubygems_mfa_required: 'true'
279
+ rdoc_options: []
280
+ require_paths:
281
+ - lib
282
+ required_ruby_version: !ruby/object:Gem::Requirement
283
+ requirements:
284
+ - - ">="
285
+ - !ruby/object:Gem::Version
286
+ version: 3.4.0
287
+ required_rubygems_version: !ruby/object:Gem::Requirement
288
+ requirements:
289
+ - - ">="
290
+ - !ruby/object:Gem::Version
291
+ version: '0'
292
+ requirements: []
293
+ rubygems_version: 4.0.6
294
+ specification_version: 4
295
+ summary: RFC 8628 device authorization for ActiveAdmin bearer access
296
+ test_files: []