doorkeeper-device_authorization_grant 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +320 -0
- data/Rakefile +34 -0
- data/app/controllers/doorkeeper/device_authorization_grant/device_authorizations_controller.rb +68 -0
- data/app/controllers/doorkeeper/device_authorization_grant/device_codes_controller.rb +28 -0
- data/app/views/doorkeeper/device_authorization_grant/device_authorizations/index.html.erb +19 -0
- data/config/locales/en.yml +15 -0
- data/db/migrate/20200629094624_create_doorkeeper_device_grants.rb +28 -0
- data/lib/doorkeeper/device_authorization_grant.rb +47 -0
- data/lib/doorkeeper/device_authorization_grant/config.rb +92 -0
- data/lib/doorkeeper/device_authorization_grant/engine.rb +12 -0
- data/lib/doorkeeper/device_authorization_grant/errors.rb +43 -0
- data/lib/doorkeeper/device_authorization_grant/oauth/device_authorization_request.rb +88 -0
- data/lib/doorkeeper/device_authorization_grant/oauth/device_authorization_response.rb +73 -0
- data/lib/doorkeeper/device_authorization_grant/oauth/device_code_request.rb +105 -0
- data/lib/doorkeeper/device_authorization_grant/oauth/helpers/user_code.rb +39 -0
- data/lib/doorkeeper/device_authorization_grant/orm/active_record.rb +27 -0
- data/lib/doorkeeper/device_authorization_grant/orm/active_record/device_grant.rb +150 -0
- data/lib/doorkeeper/device_authorization_grant/rails/routes.rb +65 -0
- data/lib/doorkeeper/device_authorization_grant/rails/routes/mapper.rb +40 -0
- data/lib/doorkeeper/device_authorization_grant/rails/routes/mapping.rb +49 -0
- data/lib/doorkeeper/device_authorization_grant/request/device_authorization.rb +38 -0
- data/lib/doorkeeper/device_authorization_grant/version.rb +8 -0
- data/lib/doorkeeper/request/device_code.rb +33 -0
- data/lib/generators/doorkeeper/device_authorization_grant/install_generator.rb +16 -0
- data/lib/generators/doorkeeper/device_authorization_grant/templates/initializer.rb +33 -0
- metadata +139 -0
@@ -0,0 +1,105 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Doorkeeper
|
4
|
+
module DeviceAuthorizationGrant
|
5
|
+
module OAuth
|
6
|
+
# Doorkeeper request object for handling OAuth 2.0 Device Access Token Requests.
|
7
|
+
#
|
8
|
+
# @see https://tools.ietf.org/html/rfc8628#section-3.4 RFC 8628, sect. 3.4
|
9
|
+
class DeviceCodeRequest < ::Doorkeeper::OAuth::BaseRequest
|
10
|
+
attr_accessor :server
|
11
|
+
attr_accessor :client
|
12
|
+
|
13
|
+
# @return [DeviceGrant]
|
14
|
+
attr_accessor :device_grant
|
15
|
+
|
16
|
+
attr_accessor :access_token
|
17
|
+
|
18
|
+
validate :client, error: :invalid_client
|
19
|
+
validate :device_grant, error: :invalid_grant
|
20
|
+
|
21
|
+
# @param server
|
22
|
+
# @param client
|
23
|
+
# @param device_grant [DeviceGrant]
|
24
|
+
def initialize(server, client, device_grant)
|
25
|
+
@server = server
|
26
|
+
@client = client
|
27
|
+
@device_grant = device_grant
|
28
|
+
|
29
|
+
# this should be `urn:ietf:params:oauth:grant-type:device_code`
|
30
|
+
@grant_type = 'device_code'
|
31
|
+
end
|
32
|
+
|
33
|
+
def before_successful_response
|
34
|
+
check_grant_errors!
|
35
|
+
check_user_interaction!
|
36
|
+
|
37
|
+
device_grant.transaction do
|
38
|
+
device_grant.lock!
|
39
|
+
device_grant.destroy!
|
40
|
+
generate_access_token
|
41
|
+
end
|
42
|
+
|
43
|
+
super
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def generate_access_token
|
49
|
+
find_or_create_access_token(
|
50
|
+
device_grant.application,
|
51
|
+
device_grant.resource_owner_id,
|
52
|
+
device_grant.scopes,
|
53
|
+
server
|
54
|
+
)
|
55
|
+
end
|
56
|
+
|
57
|
+
def check_grant_errors!
|
58
|
+
return unless device_grant.expired?
|
59
|
+
|
60
|
+
device_grant.destroy!
|
61
|
+
raise Errors::ExpiredToken
|
62
|
+
end
|
63
|
+
|
64
|
+
def check_user_interaction!
|
65
|
+
raise Errors::SlowDown if polling_too_fast?
|
66
|
+
|
67
|
+
device_grant.update!(last_polling_at: Time.now)
|
68
|
+
|
69
|
+
raise Errors::AuthorizationPending if authorization_pending?
|
70
|
+
end
|
71
|
+
|
72
|
+
# @return [Boolean]
|
73
|
+
def polling_too_fast?
|
74
|
+
!device_grant.last_polling_at.nil? &&
|
75
|
+
device_grant.last_polling_at > device_code_polling_interval.ago
|
76
|
+
end
|
77
|
+
|
78
|
+
# @return [Boolean]
|
79
|
+
def authorization_pending?
|
80
|
+
!device_grant.user_code.nil?
|
81
|
+
end
|
82
|
+
|
83
|
+
# @return [Boolean]
|
84
|
+
def validate_client
|
85
|
+
client.present?
|
86
|
+
end
|
87
|
+
|
88
|
+
# @return [Boolean]
|
89
|
+
def validate_device_grant
|
90
|
+
device_grant.present? && device_grant.application_id == client.id
|
91
|
+
end
|
92
|
+
|
93
|
+
# @return [ActiveSupport::Duration]
|
94
|
+
def device_code_polling_interval
|
95
|
+
configuration.device_code_polling_interval.seconds
|
96
|
+
end
|
97
|
+
|
98
|
+
# @return [::Doorkeeper::DeviceAuthorizationGrant::Config]
|
99
|
+
def configuration
|
100
|
+
Doorkeeper::DeviceAuthorizationGrant.configuration
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
|
5
|
+
module Doorkeeper
|
6
|
+
module DeviceAuthorizationGrant
|
7
|
+
module OAuth
|
8
|
+
module Helpers
|
9
|
+
# Simple module providing a method to generate a user code for device verification.
|
10
|
+
module UserCode
|
11
|
+
# @private
|
12
|
+
# @return [Integer] virtually includes the range `0-9` _plus_ `a-z`
|
13
|
+
BASE = 36
|
14
|
+
private_constant :BASE
|
15
|
+
|
16
|
+
# @private
|
17
|
+
# @return [Integer]
|
18
|
+
MAX_LENGTH = 8
|
19
|
+
private_constant :MAX_LENGTH
|
20
|
+
|
21
|
+
# @private
|
22
|
+
# @return [Integer]
|
23
|
+
MAX_NUMBER = BASE**MAX_LENGTH
|
24
|
+
private_constant :MAX_NUMBER
|
25
|
+
|
26
|
+
# Generates an alphanumeric user code for device verification, using `SecureRandom` generator.
|
27
|
+
# @return [String]
|
28
|
+
def self.generate
|
29
|
+
SecureRandom
|
30
|
+
.random_number(MAX_NUMBER)
|
31
|
+
.to_s(BASE)
|
32
|
+
.upcase
|
33
|
+
.rjust(MAX_LENGTH, '0')
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/lazy_load_hooks'
|
4
|
+
|
5
|
+
module Doorkeeper # rubocop:disable Style/Documentation
|
6
|
+
module DeviceAuthorizationGrant
|
7
|
+
module Orm
|
8
|
+
# @deprecated Doorkeeper `active_record_options` is deprecated: customize Doorkeeper models instead.
|
9
|
+
module ActiveRecord
|
10
|
+
def self.initialize_models!
|
11
|
+
super
|
12
|
+
|
13
|
+
ActiveSupport.on_load(:active_record) do
|
14
|
+
require_relative 'active_record/device_grant'
|
15
|
+
|
16
|
+
options = Doorkeeper.configuration.active_record_options
|
17
|
+
establish_connection_option = options[:establish_connection]
|
18
|
+
|
19
|
+
DeviceGrant.establish_connection(establish_connection_option) if establish_connection_option
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
Orm::ActiveRecord.singleton_class.prepend(DeviceAuthorizationGrant::Orm::ActiveRecord)
|
27
|
+
end
|
@@ -0,0 +1,150 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Doorkeeper
|
4
|
+
module DeviceAuthorizationGrant
|
5
|
+
# Model class, similar to Doorkeeper `AccessGrant`, but specific for
|
6
|
+
# handling OAuth 2.0 Device Authorization Grant.
|
7
|
+
class DeviceGrant < ActiveRecord::Base
|
8
|
+
self.table_name = "#{table_name_prefix}oauth_device_grants#{table_name_suffix}"
|
9
|
+
|
10
|
+
include ::Doorkeeper::Models::Expirable
|
11
|
+
|
12
|
+
delegate :secret_strategy, :fallback_secret_strategy, to: :class
|
13
|
+
|
14
|
+
belongs_to :application, class_name: Doorkeeper.configuration.application_class, optional: true
|
15
|
+
|
16
|
+
before_validation :generate_device_code, on: :create
|
17
|
+
|
18
|
+
validates :application_id, presence: true
|
19
|
+
validates :expires_in, presence: true
|
20
|
+
validates :device_code, presence: true, uniqueness: true
|
21
|
+
|
22
|
+
validates :user_code, presence: true, uniqueness: true, if: -> { resource_owner_id.blank? }
|
23
|
+
validates :user_code, absence: true, if: -> { resource_owner_id.present? }
|
24
|
+
|
25
|
+
validates :resource_owner_id, presence: true, if: -> { user_code.blank? }
|
26
|
+
validates :resource_owner_id, absence: true, if: -> { user_code.present? }
|
27
|
+
|
28
|
+
scope(
|
29
|
+
:expired,
|
30
|
+
lambda do
|
31
|
+
exp_in = DeviceAuthorizationGrant.configuration.device_code_expires_in
|
32
|
+
where('created_at <= :expiration_date', expiration_date: exp_in.seconds.ago)
|
33
|
+
end
|
34
|
+
)
|
35
|
+
|
36
|
+
scope(
|
37
|
+
:unexpired,
|
38
|
+
lambda do
|
39
|
+
exp_in = DeviceAuthorizationGrant.configuration.device_code_expires_in
|
40
|
+
where('created_at > :expiration_date', expiration_date: exp_in.seconds.ago)
|
41
|
+
end
|
42
|
+
)
|
43
|
+
|
44
|
+
# @!attribute application_id
|
45
|
+
# @return [Integer]
|
46
|
+
|
47
|
+
# @!attribute resource_owner_id
|
48
|
+
# @return [Integer, nil]
|
49
|
+
|
50
|
+
# @!attribute expires_in
|
51
|
+
# @return [Integer]
|
52
|
+
|
53
|
+
# @!attribute scopes
|
54
|
+
# @return [String]
|
55
|
+
|
56
|
+
# @!attribute device_code
|
57
|
+
# @return [String]
|
58
|
+
|
59
|
+
# @!attribute user_code
|
60
|
+
# @return [String, nil]
|
61
|
+
|
62
|
+
# @!attribute created_at
|
63
|
+
# @return [Time]
|
64
|
+
|
65
|
+
# @!attribute last_polling_at
|
66
|
+
# @return [Time, nil]
|
67
|
+
|
68
|
+
class << self
|
69
|
+
# Returns an instance of the DeviceGrant with specific device code
|
70
|
+
# value.
|
71
|
+
#
|
72
|
+
# @param device_code [#to_s] device code value
|
73
|
+
# @return [Doorkeeper::DeviceAuthorizationGrant::DeviceGrant, nil]
|
74
|
+
# DeviceGrant object, or nil if there is no record with such code
|
75
|
+
def find_by_plaintext_device_code(device_code)
|
76
|
+
device_code = device_code.to_s
|
77
|
+
|
78
|
+
find_by(device_code: secret_strategy.transform_secret(device_code)) ||
|
79
|
+
find_by_fallback_device_code(device_code)
|
80
|
+
end
|
81
|
+
|
82
|
+
alias by_device_code find_by_plaintext_device_code
|
83
|
+
|
84
|
+
# Allow looking up previously plain device codes as a fallback IFF a
|
85
|
+
# fallback strategy has been defined
|
86
|
+
#
|
87
|
+
# @param plain_secret [#to_s] plain secret value
|
88
|
+
# @return [Doorkeeper::DeviceAuthorizationGrant::DeviceGrant, nil]
|
89
|
+
# DeviceGrant object or nil if there is no record with such code
|
90
|
+
def find_by_fallback_device_code(plain_secret)
|
91
|
+
return nil unless fallback_secret_strategy
|
92
|
+
|
93
|
+
# Use the previous strategy to look up
|
94
|
+
stored_code = fallback_secret_strategy.transform_secret(plain_secret)
|
95
|
+
find_by(device_code: stored_code).tap do |resource|
|
96
|
+
upgrade_fallback_value(resource, plain_secret) if resource
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# Allows to replace a plain value fallback, to avoid it remaining as
|
101
|
+
# plain text.
|
102
|
+
#
|
103
|
+
# @param instance [Doorkeeper::DeviceAuthorizationGrant::DeviceGrant]
|
104
|
+
# An instance of this model with a plain value device code.
|
105
|
+
# @param plain_secret [String] The plain secret to upgrade.
|
106
|
+
def upgrade_fallback_value(instance, plain_secret)
|
107
|
+
upgraded =
|
108
|
+
secret_strategy.store_secret(instance, :device_code, plain_secret)
|
109
|
+
instance.update(device_code: upgraded)
|
110
|
+
end
|
111
|
+
|
112
|
+
# Determines the secret storing transformer
|
113
|
+
# Unless configured otherwise, uses the plain secret strategy
|
114
|
+
def secret_strategy
|
115
|
+
::Doorkeeper.configuration.token_secret_strategy
|
116
|
+
end
|
117
|
+
|
118
|
+
# Determine the fallback storing strategy
|
119
|
+
# Unless configured, there will be no fallback
|
120
|
+
def fallback_secret_strategy
|
121
|
+
::Doorkeeper.configuration.token_secret_fallback_strategy
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
# We keep a volatile copy of the raw device code for initial
|
126
|
+
# communication.
|
127
|
+
#
|
128
|
+
# Some strategies allow restoring stored secrets (e.g. symmetric
|
129
|
+
# encryption) while hashing strategies do not, so you cannot rely on
|
130
|
+
# this value returning a present value for persisted device codes.
|
131
|
+
def plaintext_device_code
|
132
|
+
if secret_strategy.allows_restoring_secrets?
|
133
|
+
secret_strategy.restore_secret(self, :device_code)
|
134
|
+
else
|
135
|
+
@raw_device_code
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
private
|
140
|
+
|
141
|
+
# Generates a device code value with UniqueToken class.
|
142
|
+
#
|
143
|
+
# @return [String] device code value
|
144
|
+
def generate_device_code
|
145
|
+
@raw_device_code = Doorkeeper::OAuth::Helpers::UniqueToken.generate
|
146
|
+
secret_strategy.store_secret(self, :device_code, @raw_device_code)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'routes/mapper'
|
4
|
+
|
5
|
+
module Doorkeeper
|
6
|
+
module DeviceAuthorizationGrant
|
7
|
+
module Rails
|
8
|
+
class Routes # rubocop:disable Style/Documentation
|
9
|
+
module Helper # rubocop:disable Style/Documentation
|
10
|
+
# @param options [Hash]
|
11
|
+
def use_doorkeeper_device_authorization_grant(options = {}, &block)
|
12
|
+
::Doorkeeper::DeviceAuthorizationGrant::Rails::Routes
|
13
|
+
.new(self, &block).generate_routes!(options)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.install!
|
18
|
+
::ActionDispatch::Routing::Mapper.include(
|
19
|
+
::Doorkeeper::DeviceAuthorizationGrant::Rails::Routes::Helper
|
20
|
+
)
|
21
|
+
end
|
22
|
+
|
23
|
+
attr_accessor :routes
|
24
|
+
|
25
|
+
def initialize(routes, &block)
|
26
|
+
@routes = routes
|
27
|
+
@block = block
|
28
|
+
end
|
29
|
+
|
30
|
+
# @param options [Hash]
|
31
|
+
def generate_routes!(options)
|
32
|
+
@mapping = Mapper.new.map(&@block)
|
33
|
+
|
34
|
+
routes.scope(options[:scope] || 'oauth', as: 'oauth') do
|
35
|
+
map_route(:device_codes, :device_code_routes)
|
36
|
+
map_route(:device_authorizations, :device_authorization_routes)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
# @param name [Symbol]
|
43
|
+
# @param method [Symbol]
|
44
|
+
def map_route(name, method)
|
45
|
+
return if @mapping.skipped?(name)
|
46
|
+
|
47
|
+
mapping = @mapping[name]
|
48
|
+
|
49
|
+
routes.scope(controller: mapping[:controller], as: mapping[:as]) do
|
50
|
+
__send__(method)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def device_authorization_routes
|
55
|
+
routes.get(:index, path: 'device')
|
56
|
+
routes.post(:authorize, path: 'device')
|
57
|
+
end
|
58
|
+
|
59
|
+
def device_code_routes
|
60
|
+
routes.post(:create, path: 'authorize_device')
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'mapping'
|
4
|
+
|
5
|
+
module Doorkeeper
|
6
|
+
module DeviceAuthorizationGrant
|
7
|
+
module Rails
|
8
|
+
class Routes
|
9
|
+
class Mapper # rubocop:disable Style/Documentation
|
10
|
+
# @param mapping [Mapping]
|
11
|
+
def initialize(mapping = Mapping.new)
|
12
|
+
@mapping = mapping
|
13
|
+
end
|
14
|
+
|
15
|
+
# @return [Mapping]
|
16
|
+
def map(&block)
|
17
|
+
instance_eval(&block) if block
|
18
|
+
@mapping
|
19
|
+
end
|
20
|
+
|
21
|
+
# @param controller_names [Hash{Symbol => String}]
|
22
|
+
# @return [Hash{Symbol => String}]
|
23
|
+
def controller(controller_names = {})
|
24
|
+
@mapping.controllers.merge!(controller_names)
|
25
|
+
end
|
26
|
+
|
27
|
+
# @param controller_names [Array<Symbol>]
|
28
|
+
def skip_controllers(*controller_names)
|
29
|
+
@mapping.skips = controller_names
|
30
|
+
end
|
31
|
+
|
32
|
+
# @param alias_names [Hash{Symbol => Symbol}]
|
33
|
+
def as(alias_names = {})
|
34
|
+
@mapping.as.merge!(alias_names)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Doorkeeper
|
4
|
+
module DeviceAuthorizationGrant
|
5
|
+
module Rails
|
6
|
+
class Routes
|
7
|
+
class Mapping # rubocop:disable Style/Documentation
|
8
|
+
# @return [Hash{Symbol => String}]
|
9
|
+
attr_accessor :controllers
|
10
|
+
|
11
|
+
# @return [Hash{Symbol => Symbol}]
|
12
|
+
attr_accessor :as
|
13
|
+
|
14
|
+
# @return [Array<Symbol>]
|
15
|
+
attr_accessor :skips
|
16
|
+
|
17
|
+
def initialize
|
18
|
+
@controllers = {
|
19
|
+
device_authorizations: 'doorkeeper/device_authorization_grant/device_authorizations',
|
20
|
+
device_codes: 'doorkeeper/device_authorization_grant/device_codes'
|
21
|
+
}
|
22
|
+
|
23
|
+
@as = {
|
24
|
+
device_authorizations: :device_authorizations,
|
25
|
+
device_codes: :device_codes
|
26
|
+
}
|
27
|
+
|
28
|
+
@skips = []
|
29
|
+
end
|
30
|
+
|
31
|
+
# @param routes [Symbol]
|
32
|
+
# @return [Hash]
|
33
|
+
def [](routes)
|
34
|
+
{
|
35
|
+
controller: @controllers[routes],
|
36
|
+
as: @as[routes]
|
37
|
+
}
|
38
|
+
end
|
39
|
+
|
40
|
+
# @param controller [Symbol]
|
41
|
+
# @return [Boolean]
|
42
|
+
def skipped?(controller)
|
43
|
+
@skips.include?(controller)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|