doorkeeper-device_authorization_grant 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|