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,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
|