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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +549 -0
- data/LICENSE.txt +202 -0
- data/README.md +635 -0
- data/app/channels/kiosk_events.rb +138 -0
- data/app/controllers/kiosk/server/application_controller.rb +17 -0
- data/app/controllers/kiosk/server/assistants_controller.rb +96 -0
- data/app/controllers/kiosk/server/auth_controller.rb +187 -0
- data/app/controllers/kiosk/server/device_verify_controller.rb +101 -0
- data/app/controllers/kiosk/server/discovery_controller.rb +66 -0
- data/app/controllers/kiosk/server/jwks_controller.rb +15 -0
- data/app/controllers/kiosk/server/kyc_attestation_controller.rb +69 -0
- data/app/controllers/kiosk/server/kyc_callback_controller.rb +15 -0
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +73 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +59 -0
- data/app/controllers/kiosk/server/open_api_controller.rb +20 -0
- data/app/controllers/kiosk/server/payment_setup_controller.rb +31 -0
- data/app/controllers/kiosk/server/verb_controller.rb +61 -0
- data/app/controllers/kiosk/server/wire_controller.rb +43 -0
- data/app/models/kiosk/cart_mandate.rb +21 -0
- data/app/models/kiosk/server/application_record.rb +10 -0
- data/app/models/kiosk/settlement.rb +12 -0
- data/app/models/kiosk/visit.rb +13 -0
- data/app/views/kiosk/server/assistants/show.html.erb +94 -0
- data/app/views/kiosk/server/device_verify/decided.html.erb +14 -0
- data/app/views/kiosk/server/device_verify/show.html.erb +53 -0
- data/lib/generators/kiosk/install/install_generator.rb +125 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +17 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +22 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +20 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +21 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +17 -0
- data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +16 -0
- data/lib/generators/kiosk/install/templates/initializer.rb.tt +53 -0
- data/lib/kiosk/handler.rb +43 -0
- data/lib/kiosk/owned.rb +17 -0
- data/lib/kiosk/payable.rb +90 -0
- data/lib/kiosk/server/account_binding.rb +188 -0
- data/lib/kiosk/server/account_holder_gate.rb +58 -0
- data/lib/kiosk/server/action_event.rb +60 -0
- data/lib/kiosk/server/actions.rb +14 -0
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +89 -0
- data/lib/kiosk/server/agent_login.rb +49 -0
- data/lib/kiosk/server/agent_registration.rb +88 -0
- data/lib/kiosk/server/argument_decoder.rb +260 -0
- data/lib/kiosk/server/audit_sink.rb +42 -0
- data/lib/kiosk/server/auth_challenge.rb +33 -0
- data/lib/kiosk/server/auth_challenge_stores.rb +74 -0
- data/lib/kiosk/server/auth_toll.rb +44 -0
- data/lib/kiosk/server/binding_module_gate.rb +25 -0
- data/lib/kiosk/server/caller_timezone.rb +50 -0
- data/lib/kiosk/server/column_spending_cap.rb +26 -0
- data/lib/kiosk/server/configuration_extension.rb +328 -0
- data/lib/kiosk/server/controller_verb.rb +63 -0
- data/lib/kiosk/server/current_request.rb +31 -0
- data/lib/kiosk/server/cursor.rb +33 -0
- data/lib/kiosk/server/device_authorization.rb +136 -0
- data/lib/kiosk/server/device_authorization_stores.rb +184 -0
- data/lib/kiosk/server/device_code_grant.rb +145 -0
- data/lib/kiosk/server/device_verification.rb +46 -0
- data/lib/kiosk/server/engine.rb +203 -0
- data/lib/kiosk/server/errors.rb +339 -0
- data/lib/kiosk/server/event_stores.rb +124 -0
- data/lib/kiosk/server/events.rb +86 -0
- data/lib/kiosk/server/events_cable.rb +102 -0
- data/lib/kiosk/server/events_connection.rb +170 -0
- data/lib/kiosk/server/executor/repeat.rb +42 -0
- data/lib/kiosk/server/executor/spending_cap.rb +102 -0
- data/lib/kiosk/server/executor.rb +397 -0
- data/lib/kiosk/server/failure_log.rb +28 -0
- data/lib/kiosk/server/handler_mixin.rb +418 -0
- data/lib/kiosk/server/handler_registrations.rb +57 -0
- data/lib/kiosk/server/handler_serving.rb +197 -0
- data/lib/kiosk/server/headers.rb +82 -0
- data/lib/kiosk/server/headers_middleware.rb +31 -0
- data/lib/kiosk/server/identity_resolution.rb +20 -0
- data/lib/kiosk/server/issuer_middleware.rb +22 -0
- data/lib/kiosk/server/jwks.rb +14 -0
- data/lib/kiosk/server/jwt_issuer.rb +98 -0
- data/lib/kiosk/server/kyc.rb +261 -0
- data/lib/kiosk/server/kyc_verifier.rb +106 -0
- data/lib/kiosk/server/link_code.rb +90 -0
- data/lib/kiosk/server/linked_assistants.rb +116 -0
- data/lib/kiosk/server/mandate_verifier.rb +221 -0
- data/lib/kiosk/server/open_api.rb +543 -0
- data/lib/kiosk/server/page.rb +19 -0
- data/lib/kiosk/server/payment_claim.rb +293 -0
- data/lib/kiosk/server/payment_setup.rb +102 -0
- data/lib/kiosk/server/pop_verifier.rb +87 -0
- data/lib/kiosk/server/pow_gate.rb +121 -0
- data/lib/kiosk/server/pow_spent_stores.rb +96 -0
- data/lib/kiosk/server/pow_toll.rb +136 -0
- data/lib/kiosk/server/queries.rb +14 -0
- data/lib/kiosk/server/reputation_factors.rb +38 -0
- data/lib/kiosk/server/request_validation.rb +171 -0
- data/lib/kiosk/server/response_validation.rb +45 -0
- data/lib/kiosk/server/result.rb +25 -0
- data/lib/kiosk/server/revocation_stores.rb +57 -0
- data/lib/kiosk/server/routing.rb +76 -0
- data/lib/kiosk/server/schema_definitions.rb +351 -0
- data/lib/kiosk/server/schema_document.rb +116 -0
- data/lib/kiosk/server/schema_slots.rb +119 -0
- data/lib/kiosk/server/schemas/auth.schema.json +135 -0
- data/lib/kiosk/server/schemas/binding.schema.json +145 -0
- data/lib/kiosk/server/schemas/kyc.schema.json +114 -0
- data/lib/kiosk/server/schemas/mandates.schema.json +75 -0
- data/lib/kiosk/server/schemas/pow.schema.json +94 -0
- data/lib/kiosk/server/session_context.rb +88 -0
- data/lib/kiosk/server/settled_spend.rb +21 -0
- data/lib/kiosk/server/signing_key.rb +82 -0
- data/lib/kiosk/server/verb_registry.rb +77 -0
- data/lib/kiosk/server/verb_vocabulary.rb +163 -0
- data/lib/kiosk/server/verb_wire.rb +85 -0
- data/lib/kiosk/server/version.rb +7 -0
- data/lib/kiosk/server/well_known.rb +416 -0
- data/lib/kiosk/server/wire_rendering.rb +116 -0
- data/lib/kiosk/server/wrong_door.rb +42 -0
- data/lib/kiosk/server.rb +42 -0
- data/lib/kiosk/skill_link.rb +16 -0
- data/listen.py +278 -0
- data/requirements.txt +1 -0
- 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
|