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.
Files changed (28) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +320 -0
  4. data/Rakefile +34 -0
  5. data/app/controllers/doorkeeper/device_authorization_grant/device_authorizations_controller.rb +68 -0
  6. data/app/controllers/doorkeeper/device_authorization_grant/device_codes_controller.rb +28 -0
  7. data/app/views/doorkeeper/device_authorization_grant/device_authorizations/index.html.erb +19 -0
  8. data/config/locales/en.yml +15 -0
  9. data/db/migrate/20200629094624_create_doorkeeper_device_grants.rb +28 -0
  10. data/lib/doorkeeper/device_authorization_grant.rb +47 -0
  11. data/lib/doorkeeper/device_authorization_grant/config.rb +92 -0
  12. data/lib/doorkeeper/device_authorization_grant/engine.rb +12 -0
  13. data/lib/doorkeeper/device_authorization_grant/errors.rb +43 -0
  14. data/lib/doorkeeper/device_authorization_grant/oauth/device_authorization_request.rb +88 -0
  15. data/lib/doorkeeper/device_authorization_grant/oauth/device_authorization_response.rb +73 -0
  16. data/lib/doorkeeper/device_authorization_grant/oauth/device_code_request.rb +105 -0
  17. data/lib/doorkeeper/device_authorization_grant/oauth/helpers/user_code.rb +39 -0
  18. data/lib/doorkeeper/device_authorization_grant/orm/active_record.rb +27 -0
  19. data/lib/doorkeeper/device_authorization_grant/orm/active_record/device_grant.rb +150 -0
  20. data/lib/doorkeeper/device_authorization_grant/rails/routes.rb +65 -0
  21. data/lib/doorkeeper/device_authorization_grant/rails/routes/mapper.rb +40 -0
  22. data/lib/doorkeeper/device_authorization_grant/rails/routes/mapping.rb +49 -0
  23. data/lib/doorkeeper/device_authorization_grant/request/device_authorization.rb +38 -0
  24. data/lib/doorkeeper/device_authorization_grant/version.rb +8 -0
  25. data/lib/doorkeeper/request/device_code.rb +33 -0
  26. data/lib/generators/doorkeeper/device_authorization_grant/install_generator.rb +16 -0
  27. data/lib/generators/doorkeeper/device_authorization_grant/templates/initializer.rb +33 -0
  28. 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