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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/README.md +63 -41
  4. data/app/controllers/kiosk/server/auth_controller.rb +18 -62
  5. data/app/controllers/kiosk/server/discovery_controller.rb +25 -3
  6. data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
  7. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
  8. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +11 -29
  9. data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
  10. data/app/controllers/kiosk/server/oauth_token_controller.rb +33 -32
  11. data/app/models/kiosk/checkout_mandate.rb +22 -0
  12. data/app/models/kiosk/settlement.rb +1 -1
  13. data/lib/generators/kiosk/install/install_generator.rb +0 -5
  14. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +4 -5
  15. data/lib/kiosk/payable.rb +19 -25
  16. data/lib/kiosk/server/account_binding.rb +19 -58
  17. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
  18. data/lib/kiosk/server/agent_rotation.rb +62 -0
  19. data/lib/kiosk/server/assistant_key.rb +86 -0
  20. data/lib/kiosk/server/auth_toll.rb +1 -1
  21. data/lib/kiosk/server/binding_module_gate.rb +1 -1
  22. data/lib/kiosk/server/checkout.rb +57 -0
  23. data/lib/kiosk/server/client.rb +61 -0
  24. data/lib/kiosk/server/client_assertion.rb +153 -0
  25. data/lib/kiosk/server/client_registration.rb +94 -0
  26. data/lib/kiosk/server/configuration_extension.rb +56 -44
  27. data/lib/kiosk/server/device_code_grant.rb +38 -69
  28. data/lib/kiosk/server/dpop.rb +121 -0
  29. data/lib/kiosk/server/eddsa.rb +38 -0
  30. data/lib/kiosk/server/engine.rb +14 -11
  31. data/lib/kiosk/server/errors.rb +7 -0
  32. data/lib/kiosk/server/events_connection.rb +15 -5
  33. data/lib/kiosk/server/executor/repeat.rb +13 -12
  34. data/lib/kiosk/server/executor/spending_cap.rb +25 -26
  35. data/lib/kiosk/server/executor.rb +82 -101
  36. data/lib/kiosk/server/identity_resolution.rb +12 -3
  37. data/lib/kiosk/server/jwk.rb +83 -0
  38. data/lib/kiosk/server/jwks.rb +6 -0
  39. data/lib/kiosk/server/jwt_issuer.rb +7 -13
  40. data/lib/kiosk/server/kyc_verifier.rb +2 -2
  41. data/lib/kiosk/server/link_code.rb +9 -18
  42. data/lib/kiosk/server/linked_assistants.rb +1 -1
  43. data/lib/kiosk/server/mandate_verifier.rb +97 -139
  44. data/lib/kiosk/server/oauth_answers.rb +30 -0
  45. data/lib/kiosk/server/oauth_error.rb +30 -0
  46. data/lib/kiosk/server/open_api.rb +22 -20
  47. data/lib/kiosk/server/payment_claim.rb +44 -75
  48. data/lib/kiosk/server/receipts.rb +29 -0
  49. data/lib/kiosk/server/request_validation.rb +1 -2
  50. data/lib/kiosk/server/revocation_stores.rb +8 -0
  51. data/lib/kiosk/server/schema_definitions.rb +33 -64
  52. data/lib/kiosk/server/schemas/auth.schema.json +368 -57
  53. data/lib/kiosk/server/schemas/binding.schema.json +23 -58
  54. data/lib/kiosk/server/schemas/kyc.schema.json +19 -76
  55. data/lib/kiosk/server/schemas/mandates.schema.json +122 -38
  56. data/lib/kiosk/server/sd_jwt.rb +104 -0
  57. data/lib/kiosk/server/signing_key.rb +18 -52
  58. data/lib/kiosk/server/verb_wire.rb +1 -1
  59. data/lib/kiosk/server/version.rb +1 -1
  60. data/lib/kiosk/server/well_known.rb +268 -204
  61. data/lib/kiosk/server/wire_rendering.rb +1 -1
  62. data/lib/kiosk/server.rb +2 -0
  63. data/lib/kiosk/skill_link.rb +6 -2
  64. data/listen.py +38 -8
  65. metadata +26 -17
  66. data/app/models/kiosk/cart_mandate.rb +0 -21
  67. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
  68. data/lib/kiosk/server/agent_login.rb +0 -49
  69. data/lib/kiosk/server/agent_registration.rb +0 -88
  70. data/lib/kiosk/server/auth_challenge.rb +0 -33
  71. data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
  72. 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 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.
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
- REQUIRED_CLAIMS = %w[id user_id agent_id iss iat exp].freeze
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 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
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
- # `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)
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(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\""
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
- def blank_currency_hint(value)
104
- "currency was #{value.inspect} — send the three-letter currency code of this mandate, such as \"eur\""
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 each mandate in the chain must agree with the one it follows.
54
+ # How the payment mandate must agree with the checkout it pays.
114
55
  module Binding
115
56
  module_function
116
57
 
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
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
- raise Errors::Forbidden, 'payment amount/currency does not match cart'
72
+ payee!(content['payee'])
73
+ amount!(content['payment_amount'], checkout)
122
74
  end
123
75
 
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)
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
- # A cap is meaningless across currencies.
134
- def cart_currency!(cart, intent)
135
- return if cart.currency == intent.currency
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('cart currency does not match intent cap currency',
138
- hint: "cart #{cart.currency.inspect} != intent #{intent.currency.inspect}")
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
- def cart_cap!(cart, intent)
142
- return unless cart.total_amount_cents.to_i > intent.cap_amount_cents.to_i
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('cart total exceeds intent cap',
145
- hint: "cart #{cart.total_amount_cents} > cap #{intent.cap_amount_cents}")
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 signed JWS: its signature, issuer, principal and lifetime.
101
+ # The SD-JWT: its signature, its type and its lifetime.
150
102
  module Token
151
103
  module_function
152
104
 
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 }
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 named claims, nil when absent.
160
- def claims(payload, *names) = names.index_with { |name| payload[name] }
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
- 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
119
+ check_lifetime!(content)
120
+ content
169
121
  end
170
122
 
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(', ')}")
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', hint: "a mandate is a compact RS256 JWS signed with the agent's payment key")
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 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
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
- raise Errors::Forbidden.new('mandate principal mismatch', hint: 'mandate must be signed for the authenticated agent/user')
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!(payload, field)
200
- return if payload[field].is_a?(Numeric)
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 #{payload[field].class} — send #{field} as a NumericDate integer"
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!(payload)
210
- return if payload[:exp].to_i - Time.now.to_i <= MAX_MANDATE_LIFETIME_SECONDS
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: [{ bearerAuth: [] }],
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 = 'Settle an AP2 cart: submit the signed intent -> cart -> payment ' \
293
- 'mandate chain. Answers 402 `payment_setup_required` when the ' \
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: { bearerAuth: Parameters::BEARER_SCHEME },
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 `bearerAuth`.
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 mandate chain',
456
- properties: %w[intent cart payment].to_h do |what|
457
- [:"#{what}_mandate_jws", { type: 'string', description: "Compact RS256 JWS: the signed #{what} mandate." }]
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[intent_mandate_jws cart_mandate_jws payment_mandate_jws],
459
+ required: %w[checkout_mandate payment_mandate],
460
460
  additionalProperties: false
461
461
  },
462
462
  'pay.response' => {
463
- type: 'object', title: 'Settlement receipt',
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 bearer scheme.
477
+ # The reserved query parameters, the shared response headers and the DPoP scheme.
477
478
  module Parameters
478
- BEARER_SCHEME = {
479
+ DPOP_SCHEME = {
479
480
  type: 'http',
480
- scheme: 'bearer',
481
- bearerFormat: 'JWT',
482
- description: 'An access token from the kiosk-pop auth plane ' \
483
- '(`POST <endpoint>/auth/register` or `/auth/login`), ' \
484
- 'verifiable against `<endpoint>/.well-known/jwks.json`.'
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>", method="ap2"` for payment setup.',
514
+ '`Kiosk-Payment-Setup realm="<issuer>"` for payment setup.',
513
515
  schema: { type: 'string' }
514
516
  }
515
517
  }.freeze