doorkeeper-device_authorization_grant 0.1.0

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