kiosk-server 0.5.20 → 0.6.1
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 +4 -4
- data/CHANGELOG.md +58 -0
- data/README.md +63 -41
- data/app/controllers/kiosk/server/auth_controller.rb +18 -62
- data/app/controllers/kiosk/server/discovery_controller.rb +25 -3
- data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
- data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +11 -29
- data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +33 -32
- data/app/models/kiosk/checkout_mandate.rb +22 -0
- data/app/models/kiosk/settlement.rb +1 -1
- data/lib/generators/kiosk/install/install_generator.rb +0 -5
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +4 -5
- data/lib/kiosk/payable.rb +19 -25
- data/lib/kiosk/server/account_binding.rb +19 -58
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
- data/lib/kiosk/server/agent_rotation.rb +62 -0
- data/lib/kiosk/server/assistant_key.rb +86 -0
- data/lib/kiosk/server/auth_toll.rb +1 -1
- data/lib/kiosk/server/binding_module_gate.rb +1 -1
- data/lib/kiosk/server/checkout.rb +57 -0
- data/lib/kiosk/server/client.rb +61 -0
- data/lib/kiosk/server/client_assertion.rb +153 -0
- data/lib/kiosk/server/client_registration.rb +94 -0
- data/lib/kiosk/server/configuration_extension.rb +56 -44
- data/lib/kiosk/server/device_code_grant.rb +38 -69
- data/lib/kiosk/server/dpop.rb +121 -0
- data/lib/kiosk/server/eddsa.rb +38 -0
- data/lib/kiosk/server/engine.rb +14 -11
- data/lib/kiosk/server/errors.rb +7 -0
- data/lib/kiosk/server/events_connection.rb +15 -5
- data/lib/kiosk/server/executor/repeat.rb +13 -12
- data/lib/kiosk/server/executor/spending_cap.rb +25 -26
- data/lib/kiosk/server/executor.rb +82 -101
- data/lib/kiosk/server/identity_resolution.rb +12 -3
- data/lib/kiosk/server/jwk.rb +83 -0
- data/lib/kiosk/server/jwks.rb +6 -0
- data/lib/kiosk/server/jwt_issuer.rb +7 -13
- data/lib/kiosk/server/kyc_verifier.rb +2 -2
- data/lib/kiosk/server/link_code.rb +9 -18
- data/lib/kiosk/server/linked_assistants.rb +1 -1
- data/lib/kiosk/server/mandate_verifier.rb +97 -139
- data/lib/kiosk/server/oauth_answers.rb +30 -0
- data/lib/kiosk/server/oauth_error.rb +30 -0
- data/lib/kiosk/server/open_api.rb +22 -20
- data/lib/kiosk/server/payment_claim.rb +44 -75
- data/lib/kiosk/server/receipts.rb +29 -0
- data/lib/kiosk/server/request_validation.rb +1 -2
- data/lib/kiosk/server/revocation_stores.rb +8 -0
- data/lib/kiosk/server/schema_definitions.rb +33 -64
- data/lib/kiosk/server/schemas/auth.schema.json +368 -57
- data/lib/kiosk/server/schemas/binding.schema.json +23 -58
- data/lib/kiosk/server/schemas/kyc.schema.json +19 -76
- data/lib/kiosk/server/schemas/mandates.schema.json +122 -38
- data/lib/kiosk/server/sd_jwt.rb +104 -0
- data/lib/kiosk/server/signing_key.rb +18 -52
- data/lib/kiosk/server/verb_wire.rb +1 -1
- data/lib/kiosk/server/version.rb +1 -1
- data/lib/kiosk/server/well_known.rb +268 -204
- data/lib/kiosk/server/wire_rendering.rb +1 -1
- data/lib/kiosk/server.rb +2 -0
- data/lib/kiosk/skill_link.rb +6 -2
- data/listen.py +38 -8
- metadata +26 -17
- data/app/models/kiosk/cart_mandate.rb +0 -21
- data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
- data/lib/kiosk/server/agent_login.rb +0 -49
- data/lib/kiosk/server/agent_registration.rb +0 -88
- data/lib/kiosk/server/auth_challenge.rb +0 -33
- data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
- data/lib/kiosk/server/pop_verifier.rb +0 -87
|
@@ -1,213 +1,171 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'jwt'
|
|
4
|
-
|
|
5
3
|
module Kiosk
|
|
6
4
|
module Server
|
|
7
|
-
# Verifies the
|
|
8
|
-
# signed
|
|
9
|
-
#
|
|
5
|
+
# Verifies the two closed AP2 v0.2 mandates of a `pay`. Each is an SD-JWT
|
|
6
|
+
# signed with the authenticated agent's key. The checkout mandate binds a
|
|
7
|
+
# checkout this origin signed for this account; the payment mandate pays
|
|
8
|
+
# exactly that checkout, to this origin.
|
|
10
9
|
module MandateVerifier
|
|
11
10
|
# §11.1: an ISO 4217 alpha-3 code, matched after {canonical_currency}.
|
|
12
11
|
CURRENCY_CODE = /\A[a-z]{3}\z/
|
|
13
12
|
|
|
14
|
-
|
|
13
|
+
CHECKOUT_VCT = 'mandate.checkout.1'
|
|
14
|
+
PAYMENT_VCT = 'mandate.payment.1'
|
|
15
|
+
TYP = 'dc+sd-jwt'
|
|
15
16
|
|
|
16
17
|
# Longer than this counts as non-expiring, which the spec refuses.
|
|
17
18
|
MAX_MANDATE_LIFETIME_SECONDS = 24 * 60 * 60
|
|
18
19
|
|
|
19
20
|
module_function
|
|
20
21
|
|
|
21
|
-
def
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
def verify_checkout(sd_jwt:, identity:)
|
|
23
|
+
content = Token.content(sd_jwt, identity, CHECKOUT_VCT)
|
|
24
|
+
checkout_jwt, checkout_hash = Binding.checkout_jwt!(content)
|
|
25
|
+
checkout = Checkout.verify(checkout_jwt, identity: identity)
|
|
26
|
+
Kiosk::Mandate::CheckoutMandate.new(
|
|
27
|
+
**Token.common(sd_jwt, content, identity),
|
|
28
|
+
checkout_hash: checkout_hash,
|
|
29
|
+
line_items: checkout['line_items'],
|
|
30
|
+
total_amount_cents: checkout['total_amount_cents'],
|
|
31
|
+
currency: canonical_currency(checkout['currency'])
|
|
29
32
|
)
|
|
30
33
|
end
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
currency = require_currency!(payload)
|
|
37
|
-
Binding.payment_to_cart!(payload, currency, cart)
|
|
35
|
+
def verify_payment(sd_jwt:, identity:, checkout:)
|
|
36
|
+
content = Token.content(sd_jwt, identity, PAYMENT_VCT)
|
|
37
|
+
Binding.payment_to_checkout!(content, checkout)
|
|
38
|
+
instrument = Binding.instrument!(content['payment_instrument'])
|
|
38
39
|
|
|
39
40
|
Kiosk::Mandate::PaymentMandate.new(
|
|
40
|
-
**Token.common(
|
|
41
|
-
|
|
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\""
|
|
41
|
+
**Token.common(sd_jwt, content, identity), transaction_id: content['transaction_id'],
|
|
42
|
+
payment_instrument: instrument, amount_cents: checkout.total_amount_cents, currency: checkout.currency
|
|
100
43
|
)
|
|
101
44
|
end
|
|
102
45
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
end
|
|
46
|
+
CHECKOUT_HINT = 'a checkout mandate carries the checkout_jwt the operator answered, unchanged, and ' \
|
|
47
|
+
'checkout_hash: the base64url SHA-256 of that string'
|
|
106
48
|
|
|
107
49
|
# Public: an operator reading `settlements.currency` needs the same lower-casing.
|
|
108
50
|
def canonical_currency(value)
|
|
109
51
|
value.to_s.downcase
|
|
110
52
|
end
|
|
111
|
-
private_class_method :require_amount!, :blank_currency_hint
|
|
112
53
|
|
|
113
|
-
# How
|
|
54
|
+
# How the payment mandate must agree with the checkout it pays.
|
|
114
55
|
module Binding
|
|
115
56
|
module_function
|
|
116
57
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
58
|
+
# @return [Array(String, String)] the checkout_jwt and its hash
|
|
59
|
+
def checkout_jwt!(content)
|
|
60
|
+
checkout_jwt = content['checkout_jwt']
|
|
61
|
+
raise Errors::Forbidden.new('checkout mandate has no checkout_jwt', hint: CHECKOUT_HINT) unless checkout_jwt.is_a?(String)
|
|
62
|
+
|
|
63
|
+
checkout_hash = SdJwt.digest(checkout_jwt)
|
|
64
|
+
return [checkout_jwt, checkout_hash] if content['checkout_hash'] == checkout_hash
|
|
65
|
+
|
|
66
|
+
raise Errors::Forbidden.new('checkout_hash is not the hash of checkout_jwt', hint: CHECKOUT_HINT)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def payment_to_checkout!(content, checkout)
|
|
70
|
+
raise Errors::Forbidden.new('payment not bound to the checkout', hint: 'transaction_id is the checkout_hash of the checkout mandate') unless content['transaction_id'] == checkout.checkout_hash
|
|
120
71
|
|
|
121
|
-
|
|
72
|
+
payee!(content['payee'])
|
|
73
|
+
amount!(content['payment_amount'], checkout)
|
|
122
74
|
end
|
|
123
75
|
|
|
124
|
-
def
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
cart_currency!(cart, intent)
|
|
130
|
-
cart_cap!(cart, intent)
|
|
76
|
+
def payee!(payee)
|
|
77
|
+
issuer = Kiosk.current_issuer
|
|
78
|
+
return if payee.is_a?(Hash) && payee['id'] == issuer
|
|
79
|
+
|
|
80
|
+
raise Errors::Forbidden.new('payment payee is not this origin', hint: "payee is the checkout's merchant, whose id is #{issuer.inspect}")
|
|
131
81
|
end
|
|
132
82
|
|
|
133
|
-
#
|
|
134
|
-
def
|
|
135
|
-
return if
|
|
83
|
+
# The signed amount must be the checkout's, in its currency, compared as a code.
|
|
84
|
+
def amount!(amount, checkout)
|
|
85
|
+
return if amount.is_a?(Hash) && amount['amount'] == checkout.total_amount_cents &&
|
|
86
|
+
amount['currency'].is_a?(String) && MandateVerifier.canonical_currency(amount['currency']) == checkout.currency
|
|
136
87
|
|
|
137
|
-
raise Errors::Forbidden.new('
|
|
138
|
-
hint: "
|
|
88
|
+
raise Errors::Forbidden.new('payment amount/currency does not match the checkout',
|
|
89
|
+
hint: "payment_amount is {amount: #{checkout.total_amount_cents}, currency: #{checkout.currency.inspect}}")
|
|
139
90
|
end
|
|
140
91
|
|
|
141
|
-
|
|
142
|
-
|
|
92
|
+
# @return [String] the instrument id: a PSP payment-method id, or `on_file`
|
|
93
|
+
def instrument!(instrument)
|
|
94
|
+
return instrument['id'] if instrument.is_a?(Hash) && [instrument['id'], instrument['type']].all? { it.is_a?(String) && !it.empty? }
|
|
143
95
|
|
|
144
|
-
raise Errors::Forbidden.new('
|
|
145
|
-
hint:
|
|
96
|
+
raise Errors::Forbidden.new('payment mandate has no payment_instrument',
|
|
97
|
+
hint: 'payment_instrument is {id, type}; {"id": "on_file", "type": "card"} pays with the saved card')
|
|
146
98
|
end
|
|
147
99
|
end
|
|
148
100
|
|
|
149
|
-
# The
|
|
101
|
+
# The SD-JWT: its signature, its type and its lifetime.
|
|
150
102
|
module Token
|
|
151
103
|
module_function
|
|
152
104
|
|
|
153
|
-
# The attributes
|
|
154
|
-
def common(
|
|
155
|
-
{
|
|
156
|
-
|
|
105
|
+
# The attributes both mandates carry.
|
|
106
|
+
def common(sd_jwt, content, identity)
|
|
107
|
+
{ reference: SdJwt.digest(sd_jwt), user_id: identity.user_id, agent_id: identity.agent_id,
|
|
108
|
+
issuer: Kiosk.current_issuer, expires_at: Time.at(content['exp']).utc, sd_jwt: sd_jwt }
|
|
157
109
|
end
|
|
158
110
|
|
|
159
|
-
# The
|
|
160
|
-
def
|
|
111
|
+
# The one mandate in `delegate_payload`, with every disclosure in place.
|
|
112
|
+
def content(sd_jwt, identity, vct)
|
|
113
|
+
payload = decode(sd_jwt, identity)
|
|
114
|
+
content = payload['delegate_payload']
|
|
115
|
+
content = content.first if content.is_a?(Array) && content.one?
|
|
116
|
+
raise Errors::Forbidden.new('mandate carries no mandate', hint: SHAPE_HINT) unless content.is_a?(Hash)
|
|
117
|
+
raise Errors::Forbidden.new("mandate vct is not #{vct}", hint: VCT_HINT) unless content['vct'] == vct
|
|
161
118
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
check_principal!(payload, identity)
|
|
165
|
-
require_numeric_timestamp!(payload, :iat)
|
|
166
|
-
require_numeric_timestamp!(payload, :exp)
|
|
167
|
-
enforce_max_lifetime!(payload)
|
|
168
|
-
payload
|
|
119
|
+
check_lifetime!(content)
|
|
120
|
+
content
|
|
169
121
|
end
|
|
170
122
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
123
|
+
SHAPE_HINT = 'the signed payload is {"_sd_alg": "sha-256", "delegate_payload": [{"...": <digest>}]}, ' \
|
|
124
|
+
'and the one disclosure is the mandate'
|
|
125
|
+
VCT_HINT = "send closed mandates: #{CHECKOUT_VCT} and #{PAYMENT_VCT}. This operator takes no open mandate".freeze
|
|
126
|
+
|
|
127
|
+
def decode(sd_jwt, identity)
|
|
128
|
+
key = payment_key(identity)
|
|
129
|
+
algorithm = AssistantKey.algorithm(key)
|
|
130
|
+
payload, header = SdJwt.verify(sd_jwt, key:, algorithm:)
|
|
131
|
+
return payload if header['typ'] == TYP
|
|
132
|
+
|
|
133
|
+
raise Errors::Forbidden.new("mandate typ is not #{TYP}", hint: "the JWT header is {\"alg\": \"#{algorithm}\", \"typ\": \"#{TYP}\"}")
|
|
134
|
+
rescue SdJwt::Invalid => e
|
|
135
|
+
raise Errors::Forbidden.new('mandate is not a valid SD-JWT', hint: e.message)
|
|
178
136
|
rescue ::JWT::DecodeError
|
|
179
|
-
raise Errors::Forbidden.new('mandate signature invalid',
|
|
137
|
+
raise Errors::Forbidden.new('mandate signature invalid',
|
|
138
|
+
hint: "a mandate is an SD-JWT that the agent's registered key signs (#{AssistantKey.algorithms.join(' or ')})")
|
|
180
139
|
end
|
|
181
140
|
|
|
182
141
|
def payment_key(identity)
|
|
183
142
|
AgentIdentityProviders::DefaultAgentIdp.new.agent_payment_key(identity.agent_id)
|
|
184
|
-
rescue Kiosk::AgentIdentityProviders::InvalidToken
|
|
143
|
+
rescue Kiosk::AgentIdentityProviders::InvalidToken, Errors::BadRequest
|
|
185
144
|
# The agent was revoked between authentication and now.
|
|
186
145
|
raise Errors::Forbidden.new('mandate agent has no registered payment key', hint: 'the authenticated agent is revoked or unknown')
|
|
187
146
|
end
|
|
188
147
|
|
|
189
|
-
def
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return if payload[:agent_id].to_s == identity.agent_id.to_s && payload[:user_id].to_s == identity.user_id.to_s
|
|
148
|
+
def check_lifetime!(content)
|
|
149
|
+
require_numeric_timestamp!(content, 'iat')
|
|
150
|
+
require_numeric_timestamp!(content, 'exp')
|
|
151
|
+
raise Errors::Forbidden, 'mandate expired' if content['exp'] <= Time.now.to_i
|
|
194
152
|
|
|
195
|
-
|
|
153
|
+
enforce_max_lifetime!(content)
|
|
196
154
|
end
|
|
197
155
|
|
|
198
156
|
# Before `Time.at`, which raises on a String.
|
|
199
|
-
def require_numeric_timestamp!(
|
|
200
|
-
return if
|
|
157
|
+
def require_numeric_timestamp!(content, field)
|
|
158
|
+
return if content[field].is_a?(Integer)
|
|
201
159
|
|
|
202
160
|
raise Errors::BadRequest.new(
|
|
203
161
|
"mandate #{field} must be an integer Unix timestamp",
|
|
204
|
-
hint: "#{field} was #{
|
|
162
|
+
hint: "#{field} was #{content[field].class} — send #{field} as a NumericDate integer"
|
|
205
163
|
)
|
|
206
164
|
end
|
|
207
165
|
|
|
208
166
|
# exp − now, not exp − iat, so a future-dated iat cannot stretch it.
|
|
209
|
-
def enforce_max_lifetime!(
|
|
210
|
-
return if
|
|
167
|
+
def enforce_max_lifetime!(content)
|
|
168
|
+
return if content['exp'] - Time.now.to_i <= MAX_MANDATE_LIFETIME_SECONDS
|
|
211
169
|
|
|
212
170
|
raise Errors::BadRequest.new(
|
|
213
171
|
"mandate lifetime exceeds the maximum of #{MAX_MANDATE_LIFETIME_SECONDS}s",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# The answers of the `/oauth/*` endpoints: JSON that no cache keeps
|
|
6
|
+
# (RFC 6749 Section 5.1), and `{error, error_description}` for a refusal.
|
|
7
|
+
module OauthAnswers
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def render_oauth(body, status: :ok)
|
|
11
|
+
Headers.add_to(response.headers)
|
|
12
|
+
response.set_header('Cache-Control', 'no-store')
|
|
13
|
+
render json: body, status: status
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render_oauth_error(error)
|
|
17
|
+
render_oauth(error.to_h, status: error.status)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The client that the request's client assertion authenticates.
|
|
21
|
+
# @raise [OauthError] `invalid_client`
|
|
22
|
+
def authenticate_client!
|
|
23
|
+
ClientAssertion.authenticate!(params[:client_assertion_type].to_s, params[:client_assertion].to_s,
|
|
24
|
+
client_id: params[:client_id].presence)
|
|
25
|
+
rescue ClientAssertion::Refused => e
|
|
26
|
+
raise e.to_oauth
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kiosk
|
|
4
|
+
module Server
|
|
5
|
+
# A refusal on an `/oauth/*` endpoint, answered as `{error, error_description}`
|
|
6
|
+
# (RFC 6749 Section 5.2, RFC 7591 Section 3.2.2), never as a problem document.
|
|
7
|
+
class OauthError < StandardError
|
|
8
|
+
# The token and device-authorization codes, and the registration code.
|
|
9
|
+
CODES = %w[
|
|
10
|
+
invalid_request invalid_client invalid_grant unsupported_grant_type invalid_dpop_proof
|
|
11
|
+
authorization_pending slow_down expired_token access_denied
|
|
12
|
+
invalid_client_metadata
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :code
|
|
16
|
+
|
|
17
|
+
def initialize(code, description)
|
|
18
|
+
@code = code.to_s
|
|
19
|
+
raise ArgumentError, "#{@code} is not an OAuth error code of this origin" unless CODES.include?(@code)
|
|
20
|
+
|
|
21
|
+
super(description)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# RFC 6749 Section 5.2: only a failed client authentication is a 401.
|
|
25
|
+
def status = code == 'invalid_client' ? 401 : 400
|
|
26
|
+
|
|
27
|
+
def to_h = { error: code, error_description: message }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -76,7 +76,7 @@ module Kiosk
|
|
|
76
76
|
info: info(config),
|
|
77
77
|
externalDocs: EXTERNAL_DOCS,
|
|
78
78
|
servers: [{ url: endpoint, description: "This origin's Kiosk endpoint." }],
|
|
79
|
-
security: [{
|
|
79
|
+
security: [{ dpopAuth: [] }],
|
|
80
80
|
tags: TAGS,
|
|
81
81
|
paths: paths,
|
|
82
82
|
components: Wire.components(schemas)
|
|
@@ -289,8 +289,8 @@ module Kiosk
|
|
|
289
289
|
'PUBLIC: no credential is required. The MODULE set this origin ' \
|
|
290
290
|
'serves is `capabilities` in /.well-known/kiosk.json.'
|
|
291
291
|
|
|
292
|
-
PAY_DESCRIPTION =
|
|
293
|
-
'
|
|
292
|
+
PAY_DESCRIPTION = "Pay an operator's checkout: submit the closed AP2 checkout and " \
|
|
293
|
+
'payment mandates. Answers 402 `payment_setup_required` when the ' \
|
|
294
294
|
'identity has no card on file and 402 `payment_failed` when the ' \
|
|
295
295
|
"charge did not settle — branch on the problem document's `code`."
|
|
296
296
|
|
|
@@ -311,7 +311,7 @@ module Kiosk
|
|
|
311
311
|
|
|
312
312
|
def components(schemas)
|
|
313
313
|
{
|
|
314
|
-
securitySchemes: {
|
|
314
|
+
securitySchemes: { dpopAuth: Parameters::DPOP_SCHEME },
|
|
315
315
|
parameters: Parameters::RESERVED_PARAMETERS,
|
|
316
316
|
headers: Parameters::PAGINATION_HEADERS,
|
|
317
317
|
responses: problem_responses,
|
|
@@ -324,7 +324,7 @@ module Kiosk
|
|
|
324
324
|
operationId: 'schema',
|
|
325
325
|
tags: ['wire'],
|
|
326
326
|
description: SCHEMA_DESCRIPTION,
|
|
327
|
-
# Public: opts out of the global `
|
|
327
|
+
# Public: opts out of the global `dpopAuth`.
|
|
328
328
|
security: [],
|
|
329
329
|
responses: ok_responses('The catalog.', 'schema.response')
|
|
330
330
|
}
|
|
@@ -452,36 +452,38 @@ module Kiosk
|
|
|
452
452
|
|
|
453
453
|
PAY = {
|
|
454
454
|
'pay.request' => {
|
|
455
|
-
type: 'object', title: 'The AP2
|
|
456
|
-
properties: %w[
|
|
457
|
-
[:"#{what}
|
|
455
|
+
type: 'object', title: 'The two closed AP2 mandates',
|
|
456
|
+
properties: %w[checkout payment].to_h do |what|
|
|
457
|
+
[:"#{what}_mandate", { type: 'string', description: "Compact SD-JWT: the closed AP2 #{what} mandate." }]
|
|
458
458
|
end,
|
|
459
|
-
required: %w[
|
|
459
|
+
required: %w[checkout_mandate payment_mandate],
|
|
460
460
|
additionalProperties: false
|
|
461
461
|
},
|
|
462
462
|
'pay.response' => {
|
|
463
|
-
type: 'object', title: 'Settlement
|
|
463
|
+
type: 'object', title: 'Settlement and its AP2 receipts',
|
|
464
464
|
properties: {
|
|
465
465
|
settlement_id: { type: 'string', description: "This origin's settlement row id." },
|
|
466
466
|
psp_reference: { type: 'string', description: "The processor's own reference." },
|
|
467
467
|
settled_amount_cents: { type: 'integer' },
|
|
468
|
-
currency: { type: 'string' }
|
|
468
|
+
currency: { type: 'string' },
|
|
469
|
+
**%w[checkout payment].to_h { [:"#{it}_receipt", { type: 'string', description: "The AP2 #{it} receipt: a JWT this origin signs EdDSA." }] }
|
|
469
470
|
},
|
|
470
|
-
required: %w[settlement_id psp_reference settled_amount_cents currency],
|
|
471
|
+
required: %w[settlement_id psp_reference settled_amount_cents currency checkout_receipt payment_receipt],
|
|
471
472
|
additionalProperties: false
|
|
472
473
|
}
|
|
473
474
|
}.freeze
|
|
474
475
|
end
|
|
475
476
|
|
|
476
|
-
# The reserved query parameters, the shared response headers and the
|
|
477
|
+
# The reserved query parameters, the shared response headers and the DPoP scheme.
|
|
477
478
|
module Parameters
|
|
478
|
-
|
|
479
|
+
DPOP_SCHEME = {
|
|
479
480
|
type: 'http',
|
|
480
|
-
scheme: '
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
'
|
|
484
|
-
'
|
|
481
|
+
scheme: 'DPoP',
|
|
482
|
+
description: 'An access token from the token endpoint ' \
|
|
483
|
+
'(`POST <endpoint>/oauth/token`), ' \
|
|
484
|
+
'verifiable against `<endpoint>/.well-known/jwks.json` and bound to the ' \
|
|
485
|
+
'registered key: send it as `Authorization: DPoP <token>` with a `DPoP` ' \
|
|
486
|
+
'proof header that the key signs (RFC 9449).'
|
|
485
487
|
}.freeze
|
|
486
488
|
|
|
487
489
|
# §8.4. OAS 3.1 §4.8.21.1: a Header Object carries no `name` or `in`.
|
|
@@ -509,7 +511,7 @@ module Kiosk
|
|
|
509
511
|
CHALLENGE_HEADERS = {
|
|
510
512
|
'WWW-Authenticate' => {
|
|
511
513
|
description: '`Kiosk-PoW realm="<issuer>"` for a proof-of-work toll, ' \
|
|
512
|
-
'`Payment realm="<issuer>"
|
|
514
|
+
'`Kiosk-Payment-Setup realm="<issuer>"` for payment setup.',
|
|
513
515
|
schema: { type: 'string' }
|
|
514
516
|
}
|
|
515
517
|
}.freeze
|