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,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # Account binding started by the human: the signed-in holder mints a
6
+ # single-use link code ({.mint}, POST /auth/link) and their assistant
7
+ # redeems it with its key, a possession proof and the auth toll ({.redeem}, POST /auth/claim).
8
+ module LinkCode
9
+ CLIENT_ID = 'kiosk-link'
10
+
11
+ module_function
12
+
13
+ # Born `:approved`: the human is the approval. `requested_role:` is the
14
+ # minting human's own role, never anything the assistant sends.
15
+ def mint(user_id:,
16
+ requested_role: nil,
17
+ store: Kiosk.configuration.device_authorization_store,
18
+ expires_in: DeviceAuthorization::DEFAULT_EXPIRES_IN,
19
+ now: Time.current)
20
+ plain_device_code, _plain_user_code, da = DeviceAuthorization.generate(
21
+ client_id: CLIENT_ID,
22
+ kind: :link,
23
+ requested_role: requested_role,
24
+ expires_in: expires_in,
25
+ now: now
26
+ )
27
+ da = da.approve(user_id: user_id)
28
+ store.create(da)
29
+
30
+ { link_code: plain_device_code, expires_in: expires_in, da: da }
31
+ end
32
+
33
+ # The toll comes before the lookup, so an unknown code costs the same work as a live one.
34
+ def redeem(code:, public_key_pem:, signed:, pow: nil, # rubocop:disable Metrics/ParameterLists -- the claim controller and the specs pass these by name
35
+ store: Kiosk.configuration.device_authorization_store,
36
+ now: Time.current)
37
+ raise Errors::BadRequest, 'code required' if code.to_s.empty?
38
+
39
+ pem = public_key_pem.to_s.strip
40
+ PopVerifier.load_public_key(pem)
41
+ AuthToll.gate(verb: 'auth/claim', body: { code: code.to_s, public_key: pem }, pow: pow)
42
+ da = live_link(store, code.to_s, now)
43
+
44
+ # The proof comes before the consume, so a failed one leaves the code live.
45
+ prove_possession!(pem, signed)
46
+ # The atomic consume, not `consumed?` below, decides single use under a race.
47
+ raise Errors::Conflict, 'link code already used' if store.claim_consume(da, now: now).nil?
48
+
49
+ bind(pem, da)
50
+ end
51
+
52
+ class << self
53
+ private
54
+
55
+ def prove_possession!(pem, signed)
56
+ payload = PopVerifier.verify!(public_key_pem: pem, signed: signed)
57
+ AuthChallenge.consume!(public_key_pem: pem, nonce: payload.fetch(:nonce))
58
+ end
59
+
60
+ def bind(pem, link)
61
+ result = AccountBinding.bind!(public_key_pem: pem, user_id: link.user_id, requested_role: link.requested_role)
62
+ { agent_id: result[:agent_id], user_id: result[:user_id], access_token: result[:access_token] }
63
+ end
64
+
65
+ def live_link(store, code, now)
66
+ da = store.lookup_by_device_code_hash(DeviceAuthorization.hash_device_code(code))
67
+ unless da&.link?
68
+ raise Errors::NotFound.new(
69
+ 'unknown link code',
70
+ hint: 'the account holder mints one at POST /auth/link (link codes are single-use and short-lived)'
71
+ )
72
+ end
73
+ raise Errors::Conflict, 'link code already used' if da.consumed?
74
+
75
+ expire_if_due(store, da, now)
76
+ end
77
+
78
+ def expire_if_due(store, link, now)
79
+ if link.expired_at_time?(now) && link.approved?
80
+ store.update(link.expire)
81
+ raise Errors::NotFound, 'link code expired'
82
+ end
83
+ raise Errors::NotFound, 'link code expired' if link.expired?
84
+
85
+ link
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # The rows behind the «Link an assistant» page: an account holder's live
6
+ # agents, and the settings the holder may edit on one of them.
7
+ module LinkedAssistants
8
+ module_function
9
+
10
+ # The holder's live agents, each with its spend per currency. Without the
11
+ # payment tables (no payment surface) an agent has no spend.
12
+ def list(user_id)
13
+ agents = run(agents_sql, 'Kiosk bound assistants', [user_id, Kiosk.current_issuer]).to_a
14
+ spend = spend_by_agent(user_id)
15
+ agents.map { |row| present(row, spend.fetch(row.fetch('id'), {})) }
16
+ end
17
+
18
+ # Column names are literals; every value, above all the free-text label, is a bind.
19
+ def save_settings(agent_id, user_id, changes)
20
+ binds = []
21
+ assignments = changes.map do |column, value|
22
+ next "#{column} = NULL" if value.nil?
23
+
24
+ binds << value
25
+ "#{column} = $#{binds.size}"
26
+ end
27
+ # The ownership predicate is the security boundary: `agent_id` from the caller, `user_id` from the session.
28
+ binds.push(agent_id, user_id, Kiosk.current_issuer)
29
+ run(settings_sql(assignments, binds.size), 'Kiosk assistant update', binds)
30
+ end
31
+
32
+ class << self
33
+ private
34
+
35
+ def settings_sql(assignments, bind_count)
36
+ <<~SQL.squish
37
+ UPDATE #{Kiosk.configuration.schema}.agents
38
+ SET #{assignments.join(', ')}
39
+ WHERE id = $#{bind_count - 2}
40
+ AND user_id = $#{bind_count - 1}
41
+ AND issuer = $#{bind_count}
42
+ AND revoked_at IS NULL
43
+ SQL
44
+ end
45
+
46
+ def run(sql, name, binds) = ::ActiveRecord::Base.lease_connection.exec_query(sql, name, binds)
47
+
48
+ def agents_sql
49
+ <<~SQL.squish
50
+ SELECT id, public_key, created_at, human_label, spending_cap_cents, spending_cap_currency
51
+ FROM #{Kiosk.configuration.schema}.agents
52
+ WHERE user_id = $1 AND issuer = $2 AND revoked_at IS NULL
53
+ ORDER BY created_at
54
+ SQL
55
+ end
56
+
57
+ # `{ agent_id => { currency => cents } }`: the figure the spending cap
58
+ # counts, within `spending_cap_window_days` when set.
59
+ def spend_by_agent(user_id)
60
+ window_days = Kiosk.configuration.spending_cap_window_days&.to_i
61
+ tally(run(spend_sql(window_days), 'Kiosk assistant spend', [user_id, Kiosk.current_issuer, *window_days]))
62
+ rescue ::ActiveRecord::StatementInvalid => e
63
+ raise unless missing_table?(e)
64
+
65
+ {}
66
+ end
67
+
68
+ def tally(rows)
69
+ rows.to_a.group_by { |row| row.fetch('agent_id') }.transform_values do |agent_rows|
70
+ agent_rows.to_h { |row| [row.fetch('currency'), row.fetch('cents').to_i] }
71
+ end
72
+ end
73
+
74
+ # The day count, when set, is the third bind.
75
+ def spend_sql(window_days)
76
+ schema = Kiosk.configuration.schema
77
+ window = window_days ? 'AND settlements.settled_at >= now() - make_interval(days => $3)' : ''
78
+ <<~SQL.squish
79
+ SELECT settlements.agent_id, lower(btrim(settlements.currency)) AS currency,
80
+ SUM(#{SettledSpend.net_cents_sql(schema)}) AS cents
81
+ FROM #{schema}.settlements
82
+ JOIN #{schema}.agents ON agents.id = settlements.agent_id
83
+ WHERE agents.user_id = $1 AND agents.issuer = $2 AND agents.revoked_at IS NULL #{window}
84
+ GROUP BY 1, 2 ORDER BY 2
85
+ SQL
86
+ end
87
+
88
+ # By class name, so this file does not load `PG`.
89
+ def missing_table?(error)
90
+ error.cause&.class&.name == 'PG::UndefinedTable'
91
+ end
92
+
93
+ def present(row, spent_cents)
94
+ cap = row.fetch('spending_cap_cents', nil)
95
+ {
96
+ agent_id: row.fetch('id'),
97
+ fingerprint: fingerprint(row.fetch('public_key')),
98
+ created_at: row.fetch('created_at'),
99
+ human_label: row.fetch('human_label', nil),
100
+ spending_cap_cents: cap&.to_i,
101
+ spending_cap_currency: row.fetch('spending_cap_currency', nil),
102
+ spent_cents: spent_cents
103
+ }
104
+ end
105
+
106
+ def fingerprint(pem)
107
+ return '(no key)' if pem.blank?
108
+
109
+ SigningKey.from_pem(pem).kid
110
+ rescue StandardError
111
+ '(unreadable key)'
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+
5
+ module Kiosk
6
+ module Server
7
+ # Verifies the agent-signed AP2 mandate chain (intent → cart → payment):
8
+ # signed by the authenticated agent's key, issued for this origin, bound to
9
+ # the authenticated principal, and within the intent's cap.
10
+ module MandateVerifier
11
+ # §11.1: an ISO 4217 alpha-3 code, matched after {canonical_currency}.
12
+ CURRENCY_CODE = /\A[a-z]{3}\z/
13
+
14
+ REQUIRED_CLAIMS = %w[id user_id agent_id iss iat exp].freeze
15
+
16
+ # Longer than this counts as non-expiring, which the spec refuses.
17
+ MAX_MANDATE_LIFETIME_SECONDS = 24 * 60 * 60
18
+
19
+ module_function
20
+
21
+ def verify_intent(raw_jws:, identity:)
22
+ payload = Token.decode_and_check(raw_jws, identity)
23
+ require_amount!(payload, :cap_amount_cents)
24
+ currency = require_currency!(payload)
25
+
26
+ Kiosk::Mandate::IntentMandate.new(
27
+ **Token.common(payload, raw_jws),
28
+ scope: payload[:scope], cap_amount_cents: payload[:cap_amount_cents], currency: currency
29
+ )
30
+ end
31
+
32
+ # `payment_method` is optional: the PSP charges the principal's card on file.
33
+ def verify_payment(raw_jws:, identity:, cart:)
34
+ payload = Token.decode_and_check(raw_jws, identity)
35
+ require_amount!(payload, :amount_cents)
36
+ currency = require_currency!(payload)
37
+ Binding.payment_to_cart!(payload, currency, cart)
38
+
39
+ Kiosk::Mandate::PaymentMandate.new(
40
+ **Token.common(payload, raw_jws),
41
+ cart_mandate_id: payload[:cart_mandate_id], payment_method: payload[:payment_method],
42
+ amount_cents: payload[:amount_cents], currency: currency
43
+ )
44
+ end
45
+
46
+ def verify_cart(raw_jws:, identity:, intent:)
47
+ payload = Token.decode_and_check(raw_jws, identity)
48
+ require_amount!(payload, :total_amount_cents)
49
+ currency = require_currency!(payload)
50
+ require_line_items!(payload)
51
+
52
+ fields = Token.claims(payload, :intent_mandate_id, :line_items, :total_amount_cents)
53
+ cart = Kiosk::Mandate::CartMandate.new(**Token.common(payload, raw_jws), **fields, currency: currency)
54
+ Binding.cart_to_intent!(cart, intent)
55
+ cart
56
+ end
57
+
58
+ # Before any `.to_i`: nil, zero or negative would launder the cap.
59
+ def require_amount!(payload, field)
60
+ value = payload[field]
61
+ raise Errors::Forbidden.new("mandate missing required amount field: #{field}", hint: "#{field} is a required AP2 mandate field") if value.nil?
62
+ return if value.is_a?(Integer) && value.positive?
63
+
64
+ raise Errors::Forbidden.new(
65
+ "mandate #{field} must be a positive integer number of cents",
66
+ hint: "#{field} was #{value.inspect}"
67
+ )
68
+ end
69
+
70
+ # §11.2: at least one entry; the settlement is reconciled from it.
71
+ def require_line_items!(payload)
72
+ value = payload[:line_items]
73
+ raise Errors::Forbidden.new('mandate missing required line_items field', hint: LINE_ITEMS_HINT) if value.nil?
74
+ raise Errors::Forbidden.new('mandate line_items must be an array', hint: "line_items was #{value.class}") unless value.is_a?(Array)
75
+ return unless value.empty?
76
+
77
+ raise Errors::Forbidden.new(
78
+ 'mandate line_items must not be empty',
79
+ hint: 'a cart mandate says WHAT is being bought and the settlement is reconciled ' \
80
+ 'from it — an empty array withholds both while a positive total is charged'
81
+ )
82
+ end
83
+
84
+ LINE_ITEMS_HINT = 'line_items is a required AP2 cart-mandate field — the settlement trail ' \
85
+ 'is reconciled from it'
86
+
87
+ # §11.1: refuses a non-String, blank or non-alpha-3 currency and returns
88
+ # the canonical form, so the spending-cap tally has one key per currency.
89
+ def require_currency!(payload)
90
+ value = payload[:currency]
91
+ raise Errors::Forbidden.new('mandate missing required currency field', hint: 'currency is a required AP2 mandate field') if value.nil?
92
+ raise Errors::Forbidden.new('mandate currency must be a non-empty string', hint: blank_currency_hint(value)) unless value.is_a?(String) && !value.strip.empty?
93
+
94
+ code = canonical_currency(value)
95
+ return code if CURRENCY_CODE.match?(code)
96
+
97
+ raise Errors::Forbidden.new(
98
+ 'mandate currency is not an ISO 4217 alpha-3 code',
99
+ hint: "currency was #{value.inspect} — send a three-letter currency code, such as \"eur\""
100
+ )
101
+ end
102
+
103
+ def blank_currency_hint(value)
104
+ "currency was #{value.inspect} — send the three-letter currency code of this mandate, such as \"eur\""
105
+ end
106
+
107
+ # Public: an operator reading `settlements.currency` needs the same lower-casing.
108
+ def canonical_currency(value)
109
+ value.to_s.downcase
110
+ end
111
+ private_class_method :require_amount!, :blank_currency_hint
112
+
113
+ # How each mandate in the chain must agree with the one it follows.
114
+ module Binding
115
+ module_function
116
+
117
+ def payment_to_cart!(payload, currency, cart)
118
+ raise Errors::Forbidden, 'payment not bound to the cart' unless payload[:cart_mandate_id] == cart.id
119
+ return if payload[:amount_cents].to_i == cart.total_amount_cents.to_i && currency == cart.currency
120
+
121
+ raise Errors::Forbidden, 'payment amount/currency does not match cart'
122
+ end
123
+
124
+ def cart_to_intent!(cart, intent)
125
+ unless cart.intent_mandate_id == intent.id
126
+ raise Errors::Forbidden.new('cart not bound to the intent',
127
+ hint: "expected intent_mandate_id #{intent.id.inspect}")
128
+ end
129
+ cart_currency!(cart, intent)
130
+ cart_cap!(cart, intent)
131
+ end
132
+
133
+ # A cap is meaningless across currencies.
134
+ def cart_currency!(cart, intent)
135
+ return if cart.currency == intent.currency
136
+
137
+ raise Errors::Forbidden.new('cart currency does not match intent cap currency',
138
+ hint: "cart #{cart.currency.inspect} != intent #{intent.currency.inspect}")
139
+ end
140
+
141
+ def cart_cap!(cart, intent)
142
+ return unless cart.total_amount_cents.to_i > intent.cap_amount_cents.to_i
143
+
144
+ raise Errors::Forbidden.new('cart total exceeds intent cap',
145
+ hint: "cart #{cart.total_amount_cents} > cap #{intent.cap_amount_cents}")
146
+ end
147
+ end
148
+
149
+ # The signed JWS: its signature, issuer, principal and lifetime.
150
+ module Token
151
+ module_function
152
+
153
+ # The attributes every mandate carries.
154
+ def common(payload, raw_jws)
155
+ { id: payload[:id], user_id: payload[:user_id], agent_id: payload[:agent_id], issuer: payload[:iss],
156
+ expires_at: Time.at(payload[:exp]).utc, created_at: Time.at(payload[:iat]).utc, raw_jws: raw_jws }
157
+ end
158
+
159
+ # The named claims, nil when absent.
160
+ def claims(payload, *names) = names.index_with { |name| payload[name] }
161
+
162
+ def decode_and_check(raw_jws, identity)
163
+ payload = decode(raw_jws, identity)
164
+ check_principal!(payload, identity)
165
+ require_numeric_timestamp!(payload, :iat)
166
+ require_numeric_timestamp!(payload, :exp)
167
+ enforce_max_lifetime!(payload)
168
+ payload
169
+ end
170
+
171
+ def decode(raw_jws, identity)
172
+ payload, = ::JWT.decode(raw_jws, payment_key(identity), true, algorithms: ['RS256'], required_claims: REQUIRED_CLAIMS)
173
+ payload.transform_keys(&:to_sym)
174
+ rescue ::JWT::ExpiredSignature
175
+ raise Errors::Forbidden, 'mandate expired'
176
+ rescue ::JWT::MissingRequiredClaim
177
+ raise Errors::Forbidden.new('mandate missing a required claim', hint: "a mandate carries #{REQUIRED_CLAIMS.join(', ')}")
178
+ rescue ::JWT::DecodeError
179
+ raise Errors::Forbidden.new('mandate signature invalid', hint: "a mandate is a compact RS256 JWS signed with the agent's payment key")
180
+ end
181
+
182
+ def payment_key(identity)
183
+ AgentIdentityProviders::DefaultAgentIdp.new.agent_payment_key(identity.agent_id)
184
+ rescue Kiosk::AgentIdentityProviders::InvalidToken
185
+ # The agent was revoked between authentication and now.
186
+ raise Errors::Forbidden.new('mandate agent has no registered payment key', hint: 'the authenticated agent is revoked or unknown')
187
+ end
188
+
189
+ def check_principal!(payload, identity)
190
+ issuer = Kiosk.current_issuer
191
+ raise Errors::Forbidden.new('mandate issuer mismatch', hint: "expected #{issuer.inspect}") if payload[:iss] != issuer
192
+ # As strings: a bigint-PK host's identity carries an Integer.
193
+ return if payload[:agent_id].to_s == identity.agent_id.to_s && payload[:user_id].to_s == identity.user_id.to_s
194
+
195
+ raise Errors::Forbidden.new('mandate principal mismatch', hint: 'mandate must be signed for the authenticated agent/user')
196
+ end
197
+
198
+ # Before `Time.at`, which raises on a String.
199
+ def require_numeric_timestamp!(payload, field)
200
+ return if payload[field].is_a?(Numeric)
201
+
202
+ raise Errors::BadRequest.new(
203
+ "mandate #{field} must be an integer Unix timestamp",
204
+ hint: "#{field} was #{payload[field].class} — send #{field} as a NumericDate integer"
205
+ )
206
+ end
207
+
208
+ # exp − now, not exp − iat, so a future-dated iat cannot stretch it.
209
+ def enforce_max_lifetime!(payload)
210
+ return if payload[:exp].to_i - Time.now.to_i <= MAX_MANDATE_LIFETIME_SECONDS
211
+
212
+ raise Errors::BadRequest.new(
213
+ "mandate lifetime exceeds the maximum of #{MAX_MANDATE_LIFETIME_SECONDS}s",
214
+ hint: "exp must be at most #{MAX_MANDATE_LIFETIME_SECONDS}s in the future; " \
215
+ 'a non-expiring mandate is rejected'
216
+ )
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end