kiosk-server 0.5.20

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 (127) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +549 -0
  3. data/LICENSE.txt +202 -0
  4. data/README.md +635 -0
  5. data/app/channels/kiosk_events.rb +138 -0
  6. data/app/controllers/kiosk/server/application_controller.rb +17 -0
  7. data/app/controllers/kiosk/server/assistants_controller.rb +96 -0
  8. data/app/controllers/kiosk/server/auth_controller.rb +187 -0
  9. data/app/controllers/kiosk/server/device_verify_controller.rb +101 -0
  10. data/app/controllers/kiosk/server/discovery_controller.rb +66 -0
  11. data/app/controllers/kiosk/server/jwks_controller.rb +15 -0
  12. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +69 -0
  13. data/app/controllers/kiosk/server/kyc_callback_controller.rb +15 -0
  14. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +73 -0
  15. data/app/controllers/kiosk/server/oauth_token_controller.rb +59 -0
  16. data/app/controllers/kiosk/server/open_api_controller.rb +20 -0
  17. data/app/controllers/kiosk/server/payment_setup_controller.rb +31 -0
  18. data/app/controllers/kiosk/server/verb_controller.rb +61 -0
  19. data/app/controllers/kiosk/server/wire_controller.rb +43 -0
  20. data/app/models/kiosk/cart_mandate.rb +21 -0
  21. data/app/models/kiosk/server/application_record.rb +10 -0
  22. data/app/models/kiosk/settlement.rb +12 -0
  23. data/app/models/kiosk/visit.rb +13 -0
  24. data/app/views/kiosk/server/assistants/show.html.erb +94 -0
  25. data/app/views/kiosk/server/device_verify/decided.html.erb +14 -0
  26. data/app/views/kiosk/server/device_verify/show.html.erb +53 -0
  27. data/lib/generators/kiosk/install/install_generator.rb +125 -0
  28. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +16 -0
  29. data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +17 -0
  30. data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +22 -0
  31. data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +16 -0
  32. data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +20 -0
  33. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +21 -0
  34. data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +16 -0
  35. data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +16 -0
  36. data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +16 -0
  37. data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +17 -0
  38. data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +16 -0
  39. data/lib/generators/kiosk/install/templates/initializer.rb.tt +53 -0
  40. data/lib/kiosk/handler.rb +43 -0
  41. data/lib/kiosk/owned.rb +17 -0
  42. data/lib/kiosk/payable.rb +90 -0
  43. data/lib/kiosk/server/account_binding.rb +188 -0
  44. data/lib/kiosk/server/account_holder_gate.rb +58 -0
  45. data/lib/kiosk/server/action_event.rb +60 -0
  46. data/lib/kiosk/server/actions.rb +14 -0
  47. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +89 -0
  48. data/lib/kiosk/server/agent_login.rb +49 -0
  49. data/lib/kiosk/server/agent_registration.rb +88 -0
  50. data/lib/kiosk/server/argument_decoder.rb +260 -0
  51. data/lib/kiosk/server/audit_sink.rb +42 -0
  52. data/lib/kiosk/server/auth_challenge.rb +33 -0
  53. data/lib/kiosk/server/auth_challenge_stores.rb +74 -0
  54. data/lib/kiosk/server/auth_toll.rb +44 -0
  55. data/lib/kiosk/server/binding_module_gate.rb +25 -0
  56. data/lib/kiosk/server/caller_timezone.rb +50 -0
  57. data/lib/kiosk/server/column_spending_cap.rb +26 -0
  58. data/lib/kiosk/server/configuration_extension.rb +328 -0
  59. data/lib/kiosk/server/controller_verb.rb +63 -0
  60. data/lib/kiosk/server/current_request.rb +31 -0
  61. data/lib/kiosk/server/cursor.rb +33 -0
  62. data/lib/kiosk/server/device_authorization.rb +136 -0
  63. data/lib/kiosk/server/device_authorization_stores.rb +184 -0
  64. data/lib/kiosk/server/device_code_grant.rb +145 -0
  65. data/lib/kiosk/server/device_verification.rb +46 -0
  66. data/lib/kiosk/server/engine.rb +203 -0
  67. data/lib/kiosk/server/errors.rb +339 -0
  68. data/lib/kiosk/server/event_stores.rb +124 -0
  69. data/lib/kiosk/server/events.rb +86 -0
  70. data/lib/kiosk/server/events_cable.rb +102 -0
  71. data/lib/kiosk/server/events_connection.rb +170 -0
  72. data/lib/kiosk/server/executor/repeat.rb +42 -0
  73. data/lib/kiosk/server/executor/spending_cap.rb +102 -0
  74. data/lib/kiosk/server/executor.rb +397 -0
  75. data/lib/kiosk/server/failure_log.rb +28 -0
  76. data/lib/kiosk/server/handler_mixin.rb +418 -0
  77. data/lib/kiosk/server/handler_registrations.rb +57 -0
  78. data/lib/kiosk/server/handler_serving.rb +197 -0
  79. data/lib/kiosk/server/headers.rb +82 -0
  80. data/lib/kiosk/server/headers_middleware.rb +31 -0
  81. data/lib/kiosk/server/identity_resolution.rb +20 -0
  82. data/lib/kiosk/server/issuer_middleware.rb +22 -0
  83. data/lib/kiosk/server/jwks.rb +14 -0
  84. data/lib/kiosk/server/jwt_issuer.rb +98 -0
  85. data/lib/kiosk/server/kyc.rb +261 -0
  86. data/lib/kiosk/server/kyc_verifier.rb +106 -0
  87. data/lib/kiosk/server/link_code.rb +90 -0
  88. data/lib/kiosk/server/linked_assistants.rb +116 -0
  89. data/lib/kiosk/server/mandate_verifier.rb +221 -0
  90. data/lib/kiosk/server/open_api.rb +543 -0
  91. data/lib/kiosk/server/page.rb +19 -0
  92. data/lib/kiosk/server/payment_claim.rb +293 -0
  93. data/lib/kiosk/server/payment_setup.rb +102 -0
  94. data/lib/kiosk/server/pop_verifier.rb +87 -0
  95. data/lib/kiosk/server/pow_gate.rb +121 -0
  96. data/lib/kiosk/server/pow_spent_stores.rb +96 -0
  97. data/lib/kiosk/server/pow_toll.rb +136 -0
  98. data/lib/kiosk/server/queries.rb +14 -0
  99. data/lib/kiosk/server/reputation_factors.rb +38 -0
  100. data/lib/kiosk/server/request_validation.rb +171 -0
  101. data/lib/kiosk/server/response_validation.rb +45 -0
  102. data/lib/kiosk/server/result.rb +25 -0
  103. data/lib/kiosk/server/revocation_stores.rb +57 -0
  104. data/lib/kiosk/server/routing.rb +76 -0
  105. data/lib/kiosk/server/schema_definitions.rb +351 -0
  106. data/lib/kiosk/server/schema_document.rb +116 -0
  107. data/lib/kiosk/server/schema_slots.rb +119 -0
  108. data/lib/kiosk/server/schemas/auth.schema.json +135 -0
  109. data/lib/kiosk/server/schemas/binding.schema.json +145 -0
  110. data/lib/kiosk/server/schemas/kyc.schema.json +114 -0
  111. data/lib/kiosk/server/schemas/mandates.schema.json +75 -0
  112. data/lib/kiosk/server/schemas/pow.schema.json +94 -0
  113. data/lib/kiosk/server/session_context.rb +88 -0
  114. data/lib/kiosk/server/settled_spend.rb +21 -0
  115. data/lib/kiosk/server/signing_key.rb +82 -0
  116. data/lib/kiosk/server/verb_registry.rb +77 -0
  117. data/lib/kiosk/server/verb_vocabulary.rb +163 -0
  118. data/lib/kiosk/server/verb_wire.rb +85 -0
  119. data/lib/kiosk/server/version.rb +7 -0
  120. data/lib/kiosk/server/well_known.rb +416 -0
  121. data/lib/kiosk/server/wire_rendering.rb +116 -0
  122. data/lib/kiosk/server/wrong_door.rb +42 -0
  123. data/lib/kiosk/server.rb +42 -0
  124. data/lib/kiosk/skill_link.rb +16 -0
  125. data/listen.py +278 -0
  126. data/requirements.txt +1 -0
  127. metadata +349 -0
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'digest'
5
+
6
+ module Kiosk
7
+ module Server
8
+ # One account-binding request on the RFC 8628 device-grant wire: a `:claim`
9
+ # the agent opens and a human approves, or a `:link` a signed-in human
10
+ # creates already approved. Only SHA-256 digests of its two codes are kept.
11
+ #
12
+ # `requested_role` is the role of the human the row belongs to, set by the
13
+ # operator, never by a client.
14
+ #
15
+ # Lifecycle: `:pending → :approved | :denied → :consumed | :expired`.
16
+ class DeviceAuthorization < Data.define(
17
+ :id,
18
+ :device_code_hash,
19
+ :user_code_hash,
20
+ :public_key_pem,
21
+ :kind,
22
+ :client_id,
23
+ :requested_role,
24
+ :status,
25
+ :user_id,
26
+ :expires_at,
27
+ :consumed_at,
28
+ :created_at
29
+ )
30
+ STATUSES = %i[pending approved denied consumed expired].freeze
31
+
32
+ KINDS = %i[claim link].freeze
33
+
34
+ # A-Z without I/L/O and digits 2-9: 31^8 ≈ 8.5 × 10^11 codes.
35
+ USER_CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'.chars.freeze
36
+ USER_CODE_LENGTH = 8
37
+
38
+ DEVICE_CODE_BYTES = 32
39
+
40
+ # Seconds.
41
+ DEFAULT_EXPIRES_IN = 900
42
+
43
+ # A caller bug, not an OAuth error.
44
+ class StateError < StandardError; end
45
+
46
+ # The plain codes exist only in this return value. `binding` takes `kind:`,
47
+ # `public_key_pem:` and `requested_role:`.
48
+ def self.generate(client_id:, expires_in: DEFAULT_EXPIRES_IN, now: Time.current, **binding)
49
+ raise ArgumentError, 'expires_in must be > 0' unless expires_in.positive?
50
+
51
+ plain_device_code = SecureRandom.urlsafe_base64(DEVICE_CODE_BYTES)
52
+ plain_user_code = USER_CODE_LENGTH.times.map { USER_CODE_ALPHABET.sample(random: SecureRandom) }.join
53
+ da = new(
54
+ id: SecureRandom.uuid, device_code_hash: hash_device_code(plain_device_code),
55
+ user_code_hash: hash_user_code(plain_user_code), **pending_attributes(client_id, **binding),
56
+ expires_at: now + expires_in, consumed_at: nil, created_at: now
57
+ )
58
+ [plain_device_code, plain_user_code, da]
59
+ end
60
+
61
+ def self.pending_attributes(client_id, kind: :claim, public_key_pem: nil, requested_role: nil)
62
+ raise ArgumentError, 'client_id required' if client_id.nil? || client_id.to_s.empty?
63
+
64
+ { public_key_pem: public_key_pem, kind: kind, client_id: client_id.to_s,
65
+ requested_role: requested_role&.to_s, status: :pending, user_id: nil }
66
+ end
67
+ private_class_method :pending_attributes
68
+
69
+ def self.hash_device_code(plain_device_code)
70
+ Digest::SHA256.hexdigest(plain_device_code)
71
+ end
72
+
73
+ # Expects a code already normalised ({DeviceVerification.normalize_user_code}).
74
+ def self.hash_user_code(plain_user_code)
75
+ Digest::SHA256.hexdigest(plain_user_code)
76
+ end
77
+
78
+ def self.display_user_code(plain_user_code)
79
+ "#{plain_user_code[0, 4]}-#{plain_user_code[4, 4]}"
80
+ end
81
+
82
+ def initialize(status:, kind:, **rest)
83
+ super(status: member!(STATUSES, status, 'status'), kind: member!(KINDS, kind, 'kind'), **rest)
84
+ end
85
+
86
+ def pending? = status == :pending
87
+ def approved? = status == :approved
88
+ def denied? = status == :denied
89
+ def consumed? = status == :consumed
90
+ def expired? = status == :expired
91
+
92
+ def claim? = kind == :claim
93
+ def link? = kind == :link
94
+
95
+ # Checked by the consuming endpoints; the stored status lags behind.
96
+ def expired_at_time?(now = Time.current)
97
+ now >= expires_at
98
+ end
99
+
100
+ # A nil `role:` keeps the role already on the row (a `:link` row has one).
101
+ def approve(user_id:, role: nil)
102
+ raise StateError, "cannot approve a #{status} authorization" unless pending?
103
+ raise ArgumentError, 'user_id required' if user_id.nil?
104
+
105
+ with(status: :approved, user_id: user_id, requested_role: role&.to_s || requested_role)
106
+ end
107
+
108
+ def deny
109
+ raise StateError, "cannot deny a #{status} authorization" unless pending?
110
+
111
+ with(status: :denied)
112
+ end
113
+
114
+ def consume(now: Time.current)
115
+ raise StateError, "cannot consume a #{status} authorization" unless approved?
116
+
117
+ with(status: :consumed, consumed_at: now)
118
+ end
119
+
120
+ def expire
121
+ raise StateError, "cannot expire a #{status} authorization" unless pending? || approved?
122
+
123
+ with(status: :expired)
124
+ end
125
+
126
+ private
127
+
128
+ def member!(allowed, value, name)
129
+ symbol = value.to_sym
130
+ return symbol if allowed.include?(symbol)
131
+
132
+ raise ArgumentError, "#{name} must be one of #{allowed.inspect}, got #{value.inspect}"
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Kiosk
6
+ module Server
7
+ # Storage for {DeviceAuthorization} rows: {ActiveRecord} (the default) or
8
+ # {InMemory} for tests.
9
+ module DeviceAuthorizationStores
10
+ class UniqueConstraintError < StandardError; end
11
+
12
+ class NotFoundError < StandardError; end
13
+
14
+ # An adapter must make {#claim_consume} one atomic operation (never a read
15
+ # then {#update}), and {#lookup_by_user_code_hash} must answer only pending rows.
16
+ class Base
17
+ def create(_device_authorization) = raise(NotImplementedError)
18
+ def update(_device_authorization) = raise(NotImplementedError)
19
+ def lookup_by_device_code_hash(_hash) = raise(NotImplementedError)
20
+ def lookup_by_user_code_hash(_hash) = raise(NotImplementedError)
21
+ def claim_consume(_device_authorization, now: Time.current) = raise(NotImplementedError)
22
+ end
23
+
24
+ class InMemory < Base
25
+ def initialize
26
+ super
27
+ @by_id = {}
28
+ @mutex = Mutex.new
29
+ end
30
+
31
+ def create(authorization)
32
+ @mutex.synchronize do
33
+ ensure_unique!(authorization)
34
+ @by_id[authorization.id] = authorization
35
+ end
36
+ authorization
37
+ end
38
+
39
+ def update(authorization)
40
+ @mutex.synchronize do
41
+ raise NotFoundError, "device_authorization #{authorization.id} not found" unless @by_id.key?(authorization.id)
42
+
43
+ @by_id[authorization.id] = authorization
44
+ end
45
+ authorization
46
+ end
47
+
48
+ def lookup_by_device_code_hash(hash)
49
+ @mutex.synchronize do
50
+ @by_id.values.find { |x| x.device_code_hash == hash }
51
+ end
52
+ end
53
+
54
+ # nil when the row is no longer approved.
55
+ def claim_consume(authorization, now: Time.current)
56
+ @mutex.synchronize do
57
+ current = @by_id[authorization.id]
58
+ raise NotFoundError, "device_authorization #{authorization.id} not found" if current.nil?
59
+ return nil unless current.approved?
60
+
61
+ @by_id[authorization.id] = current.consume(now: now)
62
+ end
63
+ end
64
+
65
+ def lookup_by_user_code_hash(hash)
66
+ @mutex.synchronize do
67
+ @by_id.values.find { |x| x.user_code_hash == hash && x.pending? }
68
+ end
69
+ end
70
+
71
+ def reset!
72
+ @mutex.synchronize { @by_id.clear }
73
+ end
74
+
75
+ def size
76
+ @mutex.synchronize { @by_id.size }
77
+ end
78
+
79
+ private
80
+
81
+ def ensure_unique!(authorization)
82
+ rows = @by_id.values
83
+ raise UniqueConstraintError, 'device_code_hash already exists' if rows.any? { |x| x.device_code_hash == authorization.device_code_hash }
84
+ return unless authorization.pending? && rows.any? { |x| x.pending? && x.user_code_hash == authorization.user_code_hash }
85
+
86
+ raise UniqueConstraintError, 'user_code_hash already exists among pending rows'
87
+ end
88
+ end
89
+
90
+ class ActiveRecord < Base
91
+ COLUMNS = %w[id device_code_hash user_code_hash public_key_pem kind client_id requested_role
92
+ status user_id expires_at consumed_at created_at].freeze
93
+ TIME_COLUMNS = %w[expires_at consumed_at created_at].freeze
94
+
95
+ def create(authorization)
96
+ binds = COLUMNS.map { |column| bind_value(authorization.public_send(column)) }
97
+ connection.exec_query(insert_sql, 'Kiosk device_authorization insert', binds)
98
+ authorization
99
+ rescue ::ActiveRecord::RecordNotUnique => e
100
+ raise UniqueConstraintError, e.message
101
+ end
102
+
103
+ def update(authorization)
104
+ binds = %i[public_key_pem status user_id consumed_at requested_role id].map do |attribute|
105
+ bind_value(authorization.public_send(attribute))
106
+ end
107
+ updated = connection.exec_query(update_sql, 'Kiosk device_authorization update', binds).to_a
108
+ raise NotFoundError, "device_authorization #{authorization.id} not found" if updated.empty?
109
+
110
+ authorization
111
+ end
112
+
113
+ # nil when the row is no longer approved; the row, not Ruby, decides the race.
114
+ def claim_consume(authorization, now: Time.current)
115
+ sql = <<~SQL.squish
116
+ UPDATE #{table}
117
+ SET status = 'consumed', consumed_at = $1
118
+ WHERE id = $2 AND status = 'approved'
119
+ RETURNING id
120
+ SQL
121
+ updated = connection.exec_query(sql, 'Kiosk device_authorization claim', [now, authorization.id]).to_a
122
+ return nil if updated.empty?
123
+
124
+ authorization.consume(now: now)
125
+ end
126
+
127
+ def lookup_by_device_code_hash(hash)
128
+ sql = <<~SQL.squish
129
+ SELECT * FROM #{table}
130
+ WHERE device_code_hash = $1
131
+ LIMIT 1
132
+ SQL
133
+ row = connection.exec_query(sql, 'Kiosk device_authorization by device code', [hash]).to_a.first
134
+ row && row_to_authorization(row)
135
+ end
136
+
137
+ def lookup_by_user_code_hash(hash)
138
+ sql = <<~SQL.squish
139
+ SELECT * FROM #{table}
140
+ WHERE user_code_hash = $1 AND status = 'pending'
141
+ LIMIT 1
142
+ SQL
143
+ row = connection.exec_query(sql, 'Kiosk device_authorization by user code', [hash]).to_a.first
144
+ row && row_to_authorization(row)
145
+ end
146
+
147
+ private
148
+
149
+ def connection = ::ActiveRecord::Base.lease_connection
150
+ def table = %("#{Kiosk.configuration.schema}".device_authorizations)
151
+
152
+ def insert_sql
153
+ placeholders = (1..COLUMNS.size).map { |i| "$#{i}" }.join(', ')
154
+ "INSERT INTO #{table} (#{COLUMNS.join(', ')}) VALUES (#{placeholders})"
155
+ end
156
+
157
+ def update_sql
158
+ <<~SQL.squish
159
+ UPDATE #{table}
160
+ SET public_key_pem = $1, status = $2, user_id = $3, consumed_at = $4, requested_role = $5
161
+ WHERE id = $6
162
+ RETURNING id
163
+ SQL
164
+ end
165
+
166
+ def bind_value(value) = value.is_a?(Symbol) ? value.to_s : value
167
+
168
+ def row_to_authorization(row)
169
+ attributes = COLUMNS.to_h do |column|
170
+ value = row.fetch(column)
171
+ [column.to_sym, TIME_COLUMNS.include?(column) ? to_time(value) : value]
172
+ end
173
+ DeviceAuthorization.new(**attributes)
174
+ end
175
+
176
+ def to_time(value)
177
+ return value if value.nil? || value.is_a?(Time)
178
+
179
+ Time.parse(value.to_s).utc
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # The claim ceremony on the RFC 8628 device-grant wire: binds an agent key
6
+ # to an existing human account once the human approves.
7
+ #
8
+ # .start — POST /oauth/device_authorization
9
+ # .exchange — POST /oauth/token (grant_type=device_code)
10
+ module DeviceCodeGrant
11
+ GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'
12
+
13
+ # Seconds; a faster poll gets `slow_down` (RFC 8628 §3.5).
14
+ DEFAULT_POLL_INTERVAL = 5
15
+
16
+ # Seconds a poll time is remembered; longer than any code lives.
17
+ POLL_REGISTRY_TTL = 3600
18
+ @poll_registry = {}
19
+ @poll_mutex = Mutex.new
20
+
21
+ PROOF_REQUIRED = 'signed proof-of-possession required ' \
22
+ '(GET /auth/challenge?public_key=… then sign {aud, nonce, jti})'
23
+
24
+ STATUS_FAILURES = {
25
+ pending: [:authorization_pending, 'the account holder has not yet approved'],
26
+ denied: [:access_denied, 'the account holder denied the request'],
27
+ consumed: [:invalid_grant, 'device_code already used'],
28
+ expired: [:expired_token, 'the device_code has expired']
29
+ }.freeze
30
+
31
+ module_function
32
+
33
+ # No role parameter on purpose: the row takes the approving human's role.
34
+ def start(client_id:,
35
+ public_key_pem:,
36
+ store: Kiosk.configuration.device_authorization_store,
37
+ expires_in: DeviceAuthorization::DEFAULT_EXPIRES_IN,
38
+ now: Time.current)
39
+ plain_device_code, plain_user_code, da = DeviceAuthorization.generate(
40
+ client_id: client_id, kind: :claim, public_key_pem: public_key_pem.to_s.strip,
41
+ expires_in: expires_in, now: now
42
+ )
43
+ store.create(da)
44
+ { device_code: plain_device_code, user_code: DeviceAuthorization.display_user_code(plain_user_code),
45
+ expires_in: expires_in, interval: DEFAULT_POLL_INTERVAL, da: da }
46
+ end
47
+
48
+ # Returns `{ok: true, access_token:, …}` or `{ok: false, error:, description:}`
49
+ # with an RFC 8628 §3.5 error code. The auth toll raises its own errors.
50
+ def exchange(device_code:, # rubocop:disable Metrics/ParameterLists -- the token controller and the specs pass these by name
51
+ signed: nil,
52
+ pow: nil,
53
+ store: Kiosk.configuration.device_authorization_store,
54
+ interval: DEFAULT_POLL_INTERVAL,
55
+ now: Time.current)
56
+ return failure(:invalid_request, 'device_code parameter required') if device_code.to_s.empty?
57
+
58
+ hash = DeviceAuthorization.hash_device_code(device_code)
59
+ pay_toll_once!(device_code, hash, pow, now)
60
+ da = store.lookup_by_device_code_hash(hash)
61
+ return failure(:invalid_grant, 'unknown device_code') if da.nil?
62
+ return failure(:slow_down, 'polling faster than the advertised interval') if polled_too_fast?(hash, interval, now)
63
+
64
+ da = expire_if_due(da, store, now)
65
+ return bind_and_mint(authorization: da, signed: signed, store: store, now: now) if da.approved?
66
+
67
+ failure(*STATUS_FAILURES.fetch(da.status))
68
+ end
69
+
70
+ def reset_poll_registry!
71
+ @poll_mutex.synchronize { @poll_registry.clear }
72
+ end
73
+
74
+ class << self
75
+ private
76
+
77
+ # The first poll of each device code value pays the auth toll; later polls
78
+ # of that value are free until any code could have expired. The store is
79
+ # not read first, so an unknown code costs the same work as a live one.
80
+ def pay_toll_once!(device_code, hash, pow, now)
81
+ config = Kiosk.configuration
82
+ paid = "device_code:#{hash}"
83
+ return if !AuthToll.on?(config) || config.pow_spent_store.spent?(paid)
84
+
85
+ AuthToll.gate(verb: 'oauth/token', body: { device_code: device_code.to_s }, pow: pow, config: config)
86
+ config.pow_spent_store.mark_spent(paid, now.to_i + DeviceAuthorization::DEFAULT_EXPIRES_IN)
87
+ end
88
+
89
+ def expire_if_due(authorization, store, now)
90
+ return authorization unless authorization.expired_at_time?(now)
91
+ return authorization unless authorization.pending? || authorization.approved?
92
+
93
+ store.update(authorization.expire)
94
+ end
95
+
96
+ # Possession of the row's key is proven before binding; a failed proof
97
+ # is `invalid_client` and leaves the row approved for a retry.
98
+ def bind_and_mint(authorization:, signed:, store:, now:)
99
+ refusal = possession_refusal(authorization.public_key_pem, signed)
100
+ return refusal if refusal
101
+ # Atomic, so two concurrent polls mint one token.
102
+ return failure(:invalid_grant, 'device_code already used') if store.claim_consume(authorization, now: now).nil?
103
+
104
+ token_response(authorization)
105
+ end
106
+
107
+ def possession_refusal(public_key_pem, signed)
108
+ return failure(:invalid_client, PROOF_REQUIRED) if signed.nil? || signed.to_s.empty?
109
+ return failure(:invalid_client, 'no public key bound to this authorization') if public_key_pem.blank?
110
+
111
+ payload = PopVerifier.verify!(public_key_pem: public_key_pem, signed: signed)
112
+ AuthChallenge.consume!(public_key_pem: public_key_pem, nonce: payload.fetch(:nonce))
113
+ nil
114
+ rescue Errors::Base => e
115
+ failure(:invalid_client, e.message)
116
+ end
117
+
118
+ def token_response(authorization)
119
+ role = authorization.requested_role
120
+ result = AccountBinding.bind!(public_key_pem: authorization.public_key_pem,
121
+ user_id: authorization.user_id, requested_role: role)
122
+ response = { ok: true, access_token: result[:access_token], token_type: 'Bearer',
123
+ expires_in: Kiosk.configuration.access_token_ttl }
124
+ # RFC 6749 §5.1: the granted scope is the approving human's role.
125
+ response[:scope] = role if role
126
+ response
127
+ end
128
+
129
+ def failure(error, description)
130
+ { ok: false, error: error.to_s, description: description }
131
+ end
132
+
133
+ # Per process; a multi-process origin rate-limits at its edge.
134
+ def polled_too_fast?(hash, interval, now)
135
+ @poll_mutex.synchronize do
136
+ @poll_registry.delete_if { |_, at| now - at > POLL_REGISTRY_TTL }
137
+ last = @poll_registry[hash]
138
+ @poll_registry[hash] = now
139
+ !last.nil? && (now - last) < interval
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # The human half of the claim ceremony: find, approve or deny a pending
6
+ # authorization by the code the human typed. An operator's own consent
7
+ # page may call these instead of {DeviceVerifyController}.
8
+ module DeviceVerification
9
+ # The human typed a wrong or expired code.
10
+ class CodeNotFoundError < StandardError; end
11
+
12
+ module_function
13
+
14
+ def find_pending(user_code:, store: Kiosk.configuration.device_authorization_store)
15
+ normalized = normalize_user_code(user_code)
16
+ return nil if normalized.empty?
17
+
18
+ store.lookup_by_user_code_hash(DeviceAuthorization.hash_user_code(normalized))
19
+ end
20
+
21
+ # `role:` is the approving human's own role (`user_idp`'s `Identity#role`,
22
+ # nil when it reports none); it is required so a custom page cannot omit it by accident.
23
+ def approve(user_code:, user_id:, role:,
24
+ store: Kiosk.configuration.device_authorization_store)
25
+ raise ArgumentError, 'user_id required' if user_id.nil? || user_id.to_s.empty?
26
+
27
+ da = find_pending(user_code: user_code, store: store)
28
+ raise CodeNotFoundError, 'user_code does not match any pending authorization' if da.nil?
29
+
30
+ store.update(da.approve(user_id: user_id, role: role))
31
+ end
32
+
33
+ def deny(user_code:, store: Kiosk.configuration.device_authorization_store)
34
+ da = find_pending(user_code: user_code, store: store)
35
+ raise CodeNotFoundError, 'user_code does not match any pending authorization' if da.nil?
36
+
37
+ store.update(da.deny)
38
+ end
39
+
40
+ # Drops the `XXXX-XXXX` dash and whitespace; the alphabet is uppercase.
41
+ def normalize_user_code(raw)
42
+ raw.to_s.gsub(/[\s-]/, '').upcase
43
+ end
44
+ end
45
+ end
46
+ end