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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/CODE_OF_CONDUCT.md +26 -0
- data/CONTRIBUTING.md +23 -0
- data/GOVERNANCE.md +25 -0
- data/LICENSE.md +21 -0
- data/README.md +162 -0
- data/SECURITY.md +25 -0
- data/activeadmin-device_authorization.gemspec +62 -0
- data/app/controllers/active_admin/device_authorization/application_controller.rb +9 -0
- data/app/controllers/active_admin/device_authorization/consents_controller.rb +83 -0
- data/app/controllers/active_admin/device_authorization/device_authorizations_controller.rb +51 -0
- data/app/controllers/active_admin/device_authorization/json_response.rb +25 -0
- data/app/controllers/active_admin/device_authorization/tokens_controller.rb +39 -0
- data/app/views/active_admin/device_authorization/_consent.html.erb +111 -0
- data/app/views/active_admin/device_authorization/_enter_code.html.erb +12 -0
- data/app/views/active_admin/device_authorization/_standalone_styles.html.erb +147 -0
- data/app/views/active_admin/device_authorization/consents/index.html.erb +19 -0
- data/app/views/active_admin/device_authorization/consents/show.html.erb +14 -0
- data/config/locales/en.yml +29 -0
- data/config/locales/fi.yml +29 -0
- data/config/locales/sv.yml +29 -0
- data/config/polyrun_coverage.yml +8 -0
- data/lib/active_admin/device_authorization/access_token_expiry.rb +46 -0
- data/lib/active_admin/device_authorization/callback.rb +15 -0
- data/lib/active_admin/device_authorization/configuration.rb +229 -0
- data/lib/active_admin/device_authorization/consent.rb +65 -0
- data/lib/active_admin/device_authorization/device_authorization_request.rb +70 -0
- data/lib/active_admin/device_authorization/engine.rb +17 -0
- data/lib/active_admin/device_authorization/page.rb +126 -0
- data/lib/active_admin/device_authorization/retention.rb +39 -0
- data/lib/active_admin/device_authorization/routes.rb +60 -0
- data/lib/active_admin/device_authorization/token_exchange.rb +144 -0
- data/lib/active_admin/device_authorization/user_code.rb +48 -0
- data/lib/active_admin/device_authorization/version.rb +7 -0
- data/lib/active_admin/device_authorization.rb +41 -0
- data/lib/activeadmin-device_authorization.rb +3 -0
- data/lib/tasks/active_admin_device_authorization.rake +10 -0
- data/sig/active_admin/device_authorization.rbs +5 -0
- metadata +296 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAdmin
|
|
4
|
+
module DeviceAuthorization
|
|
5
|
+
# Host-facing knobs for RFC 8628 device authorization and bearer access tokens.
|
|
6
|
+
#
|
|
7
|
+
# Challenge lifetime and poll interval follow RFC 8628 (expires_in required;
|
|
8
|
+
# interval defaults to 5 when omitted). Access-token absolute and idle TTLs
|
|
9
|
+
# follow common OAuth practice (short-lived bearer; idle + absolute caps as
|
|
10
|
+
# in major IdP refresh/access policies). Either access-token TTL may be nil
|
|
11
|
+
# to disable that axis; both nil means revoke-only expiry.
|
|
12
|
+
class Configuration
|
|
13
|
+
DEFAULT_USER_CODE_ALPHABET = (
|
|
14
|
+
("A".."Z").to_a + ("2".."9").to_a - %w[I O]
|
|
15
|
+
).freeze
|
|
16
|
+
|
|
17
|
+
attr_accessor :resource_owner_class_name,
|
|
18
|
+
:client_class_name,
|
|
19
|
+
:grant_class_name,
|
|
20
|
+
:access_token_class_name,
|
|
21
|
+
:current_resource_owner,
|
|
22
|
+
:challenge_expires_in,
|
|
23
|
+
:poll_interval,
|
|
24
|
+
:slow_down_increment,
|
|
25
|
+
:access_token_absolute_ttl,
|
|
26
|
+
:access_token_idle_ttl,
|
|
27
|
+
:access_token_default_absolute_ttl,
|
|
28
|
+
:access_token_min_absolute_ttl,
|
|
29
|
+
:access_token_max_absolute_ttl,
|
|
30
|
+
:access_token_absolute_ttl_choices,
|
|
31
|
+
:user_code_character_count,
|
|
32
|
+
:user_code_group_size,
|
|
33
|
+
:user_code_alphabet,
|
|
34
|
+
:verification_path,
|
|
35
|
+
:oauth_path_prefix,
|
|
36
|
+
:path_namespace,
|
|
37
|
+
:scopes,
|
|
38
|
+
:default_scopes,
|
|
39
|
+
:scope_labels,
|
|
40
|
+
:grant_retention_period,
|
|
41
|
+
:token_retention_period,
|
|
42
|
+
:retention_batch_size,
|
|
43
|
+
:token_digest,
|
|
44
|
+
:on_consent_approved,
|
|
45
|
+
:on_consent_denied,
|
|
46
|
+
:on_token_issued,
|
|
47
|
+
# Callable(controller) -> truthy to continue approve, falsy if the host
|
|
48
|
+
# already redirected (step-up, etc.).
|
|
49
|
+
:before_approve,
|
|
50
|
+
# Callable(controller) for GET/POST step-up page_action when present.
|
|
51
|
+
:on_approve_step_up,
|
|
52
|
+
:rate_limit_device_authorization,
|
|
53
|
+
:rate_limit_token,
|
|
54
|
+
:register_active_admin_page,
|
|
55
|
+
:menu_parent,
|
|
56
|
+
:menu_priority,
|
|
57
|
+
:menu_label,
|
|
58
|
+
:verification_base_url
|
|
59
|
+
|
|
60
|
+
def initialize
|
|
61
|
+
@resource_owner_class_name = "AdminUser"
|
|
62
|
+
@client_class_name = "DeviceAuthorizationClient"
|
|
63
|
+
@grant_class_name = "DeviceAuthorizationGrant"
|
|
64
|
+
@access_token_class_name = "DeviceAuthorizationAccessToken"
|
|
65
|
+
@current_resource_owner = ->(controller) { controller.send(:current_admin_user) }
|
|
66
|
+
|
|
67
|
+
# RFC 8628 example uses 1800s; Doorkeeper device grant defaults to 300s.
|
|
68
|
+
# Ten minutes is a practical admin-consent window.
|
|
69
|
+
@challenge_expires_in = 10 * 60
|
|
70
|
+
@poll_interval = 5
|
|
71
|
+
@slow_down_increment = 5
|
|
72
|
+
|
|
73
|
+
@access_token_absolute_ttl = 4 * 60 * 60
|
|
74
|
+
@access_token_idle_ttl = 60 * 60
|
|
75
|
+
@access_token_default_absolute_ttl = 60 * 60
|
|
76
|
+
@access_token_min_absolute_ttl = 60 * 60
|
|
77
|
+
@access_token_max_absolute_ttl = 4 * 60 * 60
|
|
78
|
+
@access_token_absolute_ttl_choices = [1, 2, 3, 4].map { |hours| hours * 60 * 60 }
|
|
79
|
+
|
|
80
|
+
@user_code_character_count = 8
|
|
81
|
+
@user_code_group_size = 4
|
|
82
|
+
@user_code_alphabet = DEFAULT_USER_CODE_ALPHABET
|
|
83
|
+
|
|
84
|
+
@verification_path = "device_auth"
|
|
85
|
+
@oauth_path_prefix = "oauth"
|
|
86
|
+
@path_namespace = :admin
|
|
87
|
+
|
|
88
|
+
@scopes = []
|
|
89
|
+
@default_scopes = []
|
|
90
|
+
@scope_labels = {}
|
|
91
|
+
|
|
92
|
+
@grant_retention_period = 3 * 24 * 60 * 60
|
|
93
|
+
@token_retention_period = 30 * 24 * 60 * 60
|
|
94
|
+
@retention_batch_size = 1000
|
|
95
|
+
|
|
96
|
+
@token_digest = ->(plaintext) { Digest::SHA256.hexdigest(plaintext.to_s) }
|
|
97
|
+
|
|
98
|
+
@on_consent_approved = nil
|
|
99
|
+
@on_consent_denied = nil
|
|
100
|
+
@on_token_issued = nil
|
|
101
|
+
@before_approve = nil
|
|
102
|
+
@on_approve_step_up = nil
|
|
103
|
+
@rate_limit_device_authorization = nil
|
|
104
|
+
@rate_limit_token = nil
|
|
105
|
+
|
|
106
|
+
@register_active_admin_page = true
|
|
107
|
+
@menu_parent = "Admin"
|
|
108
|
+
@menu_priority = 91
|
|
109
|
+
@menu_label = nil
|
|
110
|
+
@verification_base_url = nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def resource_owner_class
|
|
114
|
+
resource_owner_class_name.constantize
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def client_class
|
|
118
|
+
client_class_name.constantize
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def grant_class
|
|
122
|
+
grant_class_name.constantize
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def access_token_class
|
|
126
|
+
access_token_class_name.constantize
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def absolute_expiry_enabled?
|
|
130
|
+
!access_token_absolute_ttl.nil?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def idle_expiry_enabled?
|
|
134
|
+
!access_token_idle_ttl.nil?
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def digest(plaintext)
|
|
138
|
+
token_digest.call(plaintext)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def label_for_scope(scope)
|
|
142
|
+
key = scope.to_s
|
|
143
|
+
return scope_labels[key] if scope_labels.key?(key)
|
|
144
|
+
return scope_labels[key.to_sym] if scope_labels.key?(key.to_sym)
|
|
145
|
+
|
|
146
|
+
key
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def normalize_scopes(requested)
|
|
150
|
+
allowed = Array(scopes).map(&:to_s)
|
|
151
|
+
Array(requested).flatten.compact.map(&:to_s).map(&:strip).uniq.select { |scope| allowed.include?(scope) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def validate_rate_limits!
|
|
155
|
+
[:rate_limit_device_authorization, :rate_limit_token].each do |setting|
|
|
156
|
+
raise ArgumentError, "#{setting} must be callable" unless public_send(setting).respond_to?(:call)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Hard ceiling is access_token_absolute_ttl. Consent max cannot exceed it.
|
|
161
|
+
def effective_max_absolute_ttl
|
|
162
|
+
return nil unless absolute_expiry_enabled?
|
|
163
|
+
|
|
164
|
+
[access_token_max_absolute_ttl.to_i, access_token_absolute_ttl.to_i].min
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def clamp_absolute_ttl(seconds)
|
|
168
|
+
return nil unless absolute_expiry_enabled?
|
|
169
|
+
|
|
170
|
+
value = seconds.to_i
|
|
171
|
+
value = access_token_default_absolute_ttl.to_i if value <= 0
|
|
172
|
+
min = access_token_min_absolute_ttl.to_i
|
|
173
|
+
max = effective_max_absolute_ttl
|
|
174
|
+
value.clamp(min, max)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def validate!
|
|
178
|
+
if challenge_expires_in.to_i <= 0
|
|
179
|
+
raise ArgumentError, "challenge_expires_in must be a positive number of seconds (RFC 8628 expires_in)"
|
|
180
|
+
end
|
|
181
|
+
if poll_interval.to_i <= 0
|
|
182
|
+
raise ArgumentError, "poll_interval must be a positive number of seconds (RFC 8628 interval)"
|
|
183
|
+
end
|
|
184
|
+
if slow_down_increment.to_i <= 0
|
|
185
|
+
raise ArgumentError, "slow_down_increment must be a positive number of seconds (RFC 8628 slow_down)"
|
|
186
|
+
end
|
|
187
|
+
if absolute_expiry_enabled?
|
|
188
|
+
validate_absolute_ttl_window!
|
|
189
|
+
end
|
|
190
|
+
if idle_expiry_enabled? && access_token_idle_ttl.to_i <= 0
|
|
191
|
+
raise ArgumentError, "access_token_idle_ttl must be positive when set"
|
|
192
|
+
end
|
|
193
|
+
true
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def validate_absolute_ttl_window!
|
|
199
|
+
abs = access_token_absolute_ttl.to_i
|
|
200
|
+
min = access_token_min_absolute_ttl.to_i
|
|
201
|
+
max = access_token_max_absolute_ttl.to_i
|
|
202
|
+
default = access_token_default_absolute_ttl.to_i
|
|
203
|
+
raise ArgumentError, "access_token_absolute_ttl must be positive when set" if abs <= 0
|
|
204
|
+
raise ArgumentError, "access_token_min_absolute_ttl must be positive" if min <= 0
|
|
205
|
+
raise ArgumentError, "access_token_max_absolute_ttl must be positive" if max <= 0
|
|
206
|
+
raise ArgumentError, "access_token_default_absolute_ttl must be positive" if default <= 0
|
|
207
|
+
if max > abs
|
|
208
|
+
raise ArgumentError,
|
|
209
|
+
"access_token_max_absolute_ttl (#{max}) cannot exceed access_token_absolute_ttl (#{abs})"
|
|
210
|
+
end
|
|
211
|
+
if min > max
|
|
212
|
+
raise ArgumentError,
|
|
213
|
+
"access_token_min_absolute_ttl (#{min}) cannot exceed access_token_max_absolute_ttl (#{max})"
|
|
214
|
+
end
|
|
215
|
+
if default < min || default > max
|
|
216
|
+
raise ArgumentError,
|
|
217
|
+
"access_token_default_absolute_ttl (#{default}) must be within min..max (#{min}..#{max})"
|
|
218
|
+
end
|
|
219
|
+
Array(access_token_absolute_ttl_choices).each do |choice|
|
|
220
|
+
seconds = choice.to_i
|
|
221
|
+
next if seconds.between?(min, max)
|
|
222
|
+
|
|
223
|
+
raise ArgumentError,
|
|
224
|
+
"access_token_absolute_ttl_choices entry #{seconds} must be within min..max (#{min}..#{max})"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAdmin
|
|
4
|
+
module DeviceAuthorization
|
|
5
|
+
class Consent
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
def initialize(grant:, resource_owner:, scopes:, absolute_ttl_seconds:, config: ActiveAdmin::DeviceAuthorization.config)
|
|
9
|
+
@grant = grant
|
|
10
|
+
@resource_owner = resource_owner
|
|
11
|
+
@scopes = config.normalize_scopes(scopes)
|
|
12
|
+
@absolute_ttl_seconds = config.clamp_absolute_ttl(absolute_ttl_seconds)
|
|
13
|
+
@config = config
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def approve!
|
|
17
|
+
@grant.with_lock do
|
|
18
|
+
raise Error, "not_pending" unless @grant.pending?
|
|
19
|
+
raise Error, "expired" if @grant.mark_expired_if_needed!
|
|
20
|
+
raise Error, "invalid_scope" if @scopes.empty?
|
|
21
|
+
raise Error, "unsupported_scope" unless client.allows_scopes?(@scopes)
|
|
22
|
+
raise Error, "scope_not_requested" unless (@scopes - @grant.requested_scopes).empty?
|
|
23
|
+
if @resource_owner.respond_to?(:active_for_authentication?) && !@resource_owner.active_for_authentication?
|
|
24
|
+
raise Error, "resource_owner_inactive"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attrs = {
|
|
28
|
+
status: "approved",
|
|
29
|
+
granted_scopes: @scopes
|
|
30
|
+
}
|
|
31
|
+
attrs[resource_owner_association] = @resource_owner
|
|
32
|
+
attrs[:absolute_ttl_seconds] = @absolute_ttl_seconds if @grant.respond_to?(:absolute_ttl_seconds=)
|
|
33
|
+
@grant.update!(attrs)
|
|
34
|
+
end
|
|
35
|
+
Callback.call(@config.on_consent_approved, resource_owner: @resource_owner, grant: @grant)
|
|
36
|
+
@grant
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def deny!
|
|
40
|
+
@grant.with_lock do
|
|
41
|
+
raise Error, "not_pending" unless @grant.pending?
|
|
42
|
+
@grant.mark_expired_if_needed!
|
|
43
|
+
raise Error, "expired" if @grant.status == "expired"
|
|
44
|
+
|
|
45
|
+
attrs = {status: "denied"}
|
|
46
|
+
attrs[resource_owner_association] = @resource_owner
|
|
47
|
+
@grant.update!(attrs)
|
|
48
|
+
end
|
|
49
|
+
Callback.call(@config.on_consent_denied, resource_owner: @resource_owner, grant: @grant)
|
|
50
|
+
@grant
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def client
|
|
56
|
+
association = @config.client_class_name.underscore
|
|
57
|
+
@grant.public_send(association)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def resource_owner_association
|
|
61
|
+
@config.resource_owner_class_name.underscore.to_sym
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAdmin
|
|
4
|
+
module DeviceAuthorization
|
|
5
|
+
class DeviceAuthorizationRequest
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
Result = Data.define(
|
|
9
|
+
:device_code,
|
|
10
|
+
:user_code,
|
|
11
|
+
:verification_uri,
|
|
12
|
+
:verification_uri_complete,
|
|
13
|
+
:expires_in,
|
|
14
|
+
:interval
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
def initialize(client_id:, scopes:, verification_base_url:, config: ActiveAdmin::DeviceAuthorization.config)
|
|
18
|
+
@client_id = client_id
|
|
19
|
+
@requested_scopes = Array(scopes).flatten.compact.map(&:to_s).map(&:strip).reject(&:empty?).uniq
|
|
20
|
+
@scopes = config.normalize_scopes(scopes)
|
|
21
|
+
@verification_base_url = verification_base_url
|
|
22
|
+
@config = config
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call
|
|
26
|
+
client = @config.client_class.enabled.find_by!(client_id: @client_id)
|
|
27
|
+
raise Error, "invalid_scope" if @scopes.empty?
|
|
28
|
+
raise Error, "invalid_scope" unless (@requested_scopes - @scopes).empty?
|
|
29
|
+
raise Error, "invalid_scope" unless client.allows_scopes?(@scopes)
|
|
30
|
+
|
|
31
|
+
device_code = SecureRandom.urlsafe_base64(32)
|
|
32
|
+
user_code = UserCode.generate(
|
|
33
|
+
config: @config,
|
|
34
|
+
exists: ->(code) { @config.grant_class.exists?(user_code: code) }
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
@config.grant_class.create!(
|
|
38
|
+
client_association_name => client,
|
|
39
|
+
:device_code_digest => @config.digest(device_code),
|
|
40
|
+
:user_code => user_code,
|
|
41
|
+
:requested_scopes => @scopes,
|
|
42
|
+
:status => "pending",
|
|
43
|
+
:challenge_expires_at => Time.current + @config.challenge_expires_in.to_i
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
verification_uri = build_verification_uri
|
|
47
|
+
Result.new(
|
|
48
|
+
device_code: device_code,
|
|
49
|
+
user_code: user_code,
|
|
50
|
+
verification_uri: verification_uri,
|
|
51
|
+
verification_uri_complete: "#{verification_uri}/#{user_code}",
|
|
52
|
+
expires_in: @config.challenge_expires_in.to_i,
|
|
53
|
+
interval: @config.poll_interval.to_i
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def client_association_name
|
|
60
|
+
@config.client_class_name.underscore.to_sym
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_verification_uri
|
|
64
|
+
base = (@config.verification_base_url.presence || @verification_base_url).to_s.chomp("/")
|
|
65
|
+
path = [@config.path_namespace, @config.verification_path].compact.join("/")
|
|
66
|
+
File.join(base, path)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAdmin
|
|
4
|
+
module DeviceAuthorization
|
|
5
|
+
class Engine < ::Rails::Engine
|
|
6
|
+
initializer "active_admin.device_authorization.locales" do |app|
|
|
7
|
+
app.config.i18n.load_path += Dir[
|
|
8
|
+
root.join("config/locales/*.{rb,yml}")
|
|
9
|
+
]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
initializer "active_admin.device_authorization.filter_parameters" do |app|
|
|
13
|
+
app.config.filter_parameters |= [:device_code, :user_code, :access_token]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAdmin
|
|
4
|
+
module DeviceAuthorization
|
|
5
|
+
# Registers the ActiveAdmin consent page. Call after ActiveAdmin.load! setup,
|
|
6
|
+
# typically from config/initializers/active_admin_device_authorization.rb once
|
|
7
|
+
# host scopes and model class names are configured.
|
|
8
|
+
module Page
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def register!
|
|
12
|
+
config = ActiveAdmin::DeviceAuthorization.config
|
|
13
|
+
return unless config.register_active_admin_page
|
|
14
|
+
|
|
15
|
+
menu_label = config.menu_label || -> { I18n.t("active_admin.device_authorization.menu") }
|
|
16
|
+
verification = config.verification_path
|
|
17
|
+
|
|
18
|
+
ActiveAdmin.register_page "Device Auth" do
|
|
19
|
+
menu parent: config.menu_parent,
|
|
20
|
+
priority: config.menu_priority,
|
|
21
|
+
label: menu_label
|
|
22
|
+
|
|
23
|
+
controller do
|
|
24
|
+
helper_method :device_auth_grant
|
|
25
|
+
|
|
26
|
+
define_method(:index) do
|
|
27
|
+
user_code = ActiveAdmin::DeviceAuthorization::UserCode.normalize(params[:user_code])
|
|
28
|
+
if user_code.present?
|
|
29
|
+
redirect_to send(:"#{config.path_namespace}_device_auth_grant_path", user_code)
|
|
30
|
+
return
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@page_title = I18n.t("active_admin.device_authorization.enter_code")
|
|
34
|
+
super()
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
define_method(:show) do
|
|
38
|
+
load_grant!
|
|
39
|
+
@grant.mark_expired_if_needed!
|
|
40
|
+
@page_title = I18n.t("active_admin.device_authorization.title")
|
|
41
|
+
render "active_admin/page/index"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
define_method(:approve) do
|
|
45
|
+
load_grant!
|
|
46
|
+
if config.before_approve && !config.before_approve.call(self)
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
ActiveAdmin::DeviceAuthorization::Consent.new(
|
|
51
|
+
grant: @grant,
|
|
52
|
+
resource_owner: config.current_resource_owner.call(self),
|
|
53
|
+
scopes: Array(params[:scopes]),
|
|
54
|
+
absolute_ttl_seconds: params[:absolute_ttl_seconds]
|
|
55
|
+
).approve!
|
|
56
|
+
redirect_to send(:"#{config.path_namespace}_device_auth_grant_path", @grant.user_code),
|
|
57
|
+
notice: I18n.t("active_admin.device_authorization.approved")
|
|
58
|
+
rescue ActiveAdmin::DeviceAuthorization::Consent::Error => error
|
|
59
|
+
redirect_to send(:"#{config.path_namespace}_device_auth_grant_path", @grant.user_code),
|
|
60
|
+
alert: I18n.t(
|
|
61
|
+
"active_admin.device_authorization.error.#{error.message}",
|
|
62
|
+
default: error.message
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
define_method(:deny) do
|
|
67
|
+
load_grant!
|
|
68
|
+
ActiveAdmin::DeviceAuthorization::Consent.new(
|
|
69
|
+
grant: @grant,
|
|
70
|
+
resource_owner: config.current_resource_owner.call(self),
|
|
71
|
+
scopes: @grant.requested_scopes,
|
|
72
|
+
absolute_ttl_seconds: config.access_token_default_absolute_ttl
|
|
73
|
+
).deny!
|
|
74
|
+
redirect_to send(:"#{config.path_namespace}_device_auth_grant_path", @grant.user_code),
|
|
75
|
+
notice: I18n.t("active_admin.device_authorization.denied")
|
|
76
|
+
rescue ActiveAdmin::DeviceAuthorization::Consent::Error => error
|
|
77
|
+
redirect_to send(:"#{config.path_namespace}_device_auth_grant_path", @grant.user_code),
|
|
78
|
+
alert: I18n.t(
|
|
79
|
+
"active_admin.device_authorization.error.#{error.message}",
|
|
80
|
+
default: error.message
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
define_method(:device_auth_grant) { @grant }
|
|
85
|
+
|
|
86
|
+
define_method(:page_presenter) do
|
|
87
|
+
if action_name == "show"
|
|
88
|
+
active_admin_config.get_page_presenter(:index)
|
|
89
|
+
else
|
|
90
|
+
super()
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
define_method(:load_grant!) do
|
|
97
|
+
user_code = ActiveAdmin::DeviceAuthorization::UserCode.normalize(params[:user_code])
|
|
98
|
+
@grant = config.grant_class.find_by!(user_code: user_code)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
page_action :step_up, method: [:get, :post] do
|
|
103
|
+
handler = ActiveAdmin::DeviceAuthorization.config.on_approve_step_up
|
|
104
|
+
if handler
|
|
105
|
+
handler.call(self)
|
|
106
|
+
else
|
|
107
|
+
redirect_to send(:"#{ActiveAdmin::DeviceAuthorization.config.path_namespace}_device_auth_path")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
content title: proc { I18n.t("active_admin.device_authorization.title") } do
|
|
112
|
+
if device_auth_grant
|
|
113
|
+
render partial: "active_admin/device_authorization/consent",
|
|
114
|
+
locals: {grant: device_auth_grant}
|
|
115
|
+
else
|
|
116
|
+
render partial: "active_admin/device_authorization/enter_code"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Host must also route show/approve/deny; Page only registers the AA index shell.
|
|
122
|
+
verification
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAdmin
|
|
4
|
+
module DeviceAuthorization
|
|
5
|
+
module Retention
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Deletes tokens that have been expired or revoked longer than token_retention_period.
|
|
9
|
+
# Absolute, idle, and revoke axes are all covered so a far-future absolute sentinel
|
|
10
|
+
# (absolute expiry disabled) cannot retain idle-expired rows for decades.
|
|
11
|
+
def call(config: ActiveAdmin::DeviceAuthorization.config)
|
|
12
|
+
token_cutoff = Time.current - config.token_retention_period.to_i
|
|
13
|
+
predicates = ["absolute_expires_at < ?", "revoked_at < ?"]
|
|
14
|
+
binds = [token_cutoff, token_cutoff]
|
|
15
|
+
|
|
16
|
+
if config.idle_expiry_enabled?
|
|
17
|
+
idle_cutoff = Time.current -
|
|
18
|
+
config.access_token_idle_ttl.to_i -
|
|
19
|
+
config.token_retention_period.to_i
|
|
20
|
+
predicates << "last_used_at < ?"
|
|
21
|
+
binds << idle_cutoff
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
config.access_token_class
|
|
25
|
+
.where(predicates.join(" OR "), *binds)
|
|
26
|
+
.in_batches(of: config.retention_batch_size)
|
|
27
|
+
.delete_all
|
|
28
|
+
|
|
29
|
+
grant_cutoff = Time.current - config.grant_retention_period.to_i
|
|
30
|
+
token_assoc = config.access_token_class_name.underscore.to_sym
|
|
31
|
+
config.grant_class
|
|
32
|
+
.where(challenge_expires_at: ...grant_cutoff)
|
|
33
|
+
.where.missing(token_assoc)
|
|
34
|
+
.in_batches(of: config.retention_batch_size)
|
|
35
|
+
.delete_all
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAdmin
|
|
4
|
+
module DeviceAuthorization
|
|
5
|
+
module Routes
|
|
6
|
+
# Embed under the host admin namespace, for example:
|
|
7
|
+
#
|
|
8
|
+
# namespace :admin do
|
|
9
|
+
# ActiveAdmin::DeviceAuthorization::Routes.draw(self)
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# OAuth JSON endpoints always use absolute gem controller paths so nesting
|
|
13
|
+
# under `namespace :admin` does not invent `Admin::ActiveAdmin::...`.
|
|
14
|
+
#
|
|
15
|
+
# consent: :active_admin_page (default) routes show/approve/deny to the
|
|
16
|
+
# ActiveAdmin "Device Auth" page controller (`device_auth#...`).
|
|
17
|
+
# consent: :controller routes them to the gem ConsentsController instead.
|
|
18
|
+
def self.draw(mapper, config: ActiveAdmin::DeviceAuthorization.config, consent: :active_admin_page)
|
|
19
|
+
config.validate_rate_limits!
|
|
20
|
+
oauth = config.oauth_path_prefix.to_s
|
|
21
|
+
verification = config.verification_path.to_s
|
|
22
|
+
|
|
23
|
+
mapper.namespace :oauth, path: oauth do
|
|
24
|
+
mapper.post :device_authorization,
|
|
25
|
+
to: "/active_admin/device_authorization/device_authorizations#create"
|
|
26
|
+
mapper.post :token,
|
|
27
|
+
to: "/active_admin/device_authorization/tokens#create"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
case consent
|
|
31
|
+
when :active_admin_page
|
|
32
|
+
mapper.get "#{verification}/:user_code",
|
|
33
|
+
to: "device_auth#show",
|
|
34
|
+
as: :device_auth_grant
|
|
35
|
+
mapper.post "#{verification}/:user_code/approve",
|
|
36
|
+
to: "device_auth#approve",
|
|
37
|
+
as: :approve_device_auth
|
|
38
|
+
mapper.post "#{verification}/:user_code/deny",
|
|
39
|
+
to: "device_auth#deny",
|
|
40
|
+
as: :deny_device_auth
|
|
41
|
+
when :controller
|
|
42
|
+
mapper.get verification,
|
|
43
|
+
to: "/active_admin/device_authorization/consents#index",
|
|
44
|
+
as: :device_auth
|
|
45
|
+
mapper.get "#{verification}/:user_code",
|
|
46
|
+
to: "/active_admin/device_authorization/consents#show",
|
|
47
|
+
as: :device_auth_grant
|
|
48
|
+
mapper.post "#{verification}/:user_code/approve",
|
|
49
|
+
to: "/active_admin/device_authorization/consents#approve",
|
|
50
|
+
as: :approve_device_auth
|
|
51
|
+
mapper.post "#{verification}/:user_code/deny",
|
|
52
|
+
to: "/active_admin/device_authorization/consents#deny",
|
|
53
|
+
as: :deny_device_auth
|
|
54
|
+
else
|
|
55
|
+
raise ArgumentError, "consent must be :active_admin_page or :controller (got #{consent.inspect})"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|