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
@@ -2,13 +2,14 @@
2
2
 
3
3
  module Kiosk
4
4
  module Server
5
- # The claim ceremony on the RFC 8628 device-grant wire: binds an agent key
6
- # to an existing human account once the human approves.
5
+ # The claim ceremony on the RFC 8628 device-grant wire: binds a registered
6
+ # client to an existing human account once the human approves.
7
7
  #
8
- # .start — POST /oauth/device_authorization
9
- # .exchange — POST /oauth/token (grant_type=device_code)
8
+ # .start — POST /oauth/device_authorization
9
+ # .pay_toll! — POST /oauth/token (grant_type=device_code), before the client authenticates
10
+ # .exchange — POST /oauth/token (grant_type=device_code), after it authenticates
10
11
  module DeviceCodeGrant
11
- GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'
12
+ GRANT_TYPE = Client::DEVICE_CODE
12
13
 
13
14
  # Seconds; a faster poll gets `slow_down` (RFC 8628 §3.5).
14
15
  DEFAULT_POLL_INTERVAL = 5
@@ -18,9 +19,6 @@ module Kiosk
18
19
  @poll_registry = {}
19
20
  @poll_mutex = Mutex.new
20
21
 
21
- PROOF_REQUIRED = 'signed proof-of-possession required ' \
22
- '(GET /auth/challenge?public_key=… then sign {aud, nonce, jti})'
23
-
24
22
  STATUS_FAILURES = {
25
23
  pending: [:authorization_pending, 'the account holder has not yet approved'],
26
24
  denied: [:access_denied, 'the account holder denied the request'],
@@ -30,14 +28,13 @@ module Kiosk
30
28
 
31
29
  module_function
32
30
 
33
- # No role parameter on purpose: the row takes the approving human's role.
34
- def start(client_id:,
35
- public_key_pem:,
31
+ # No role parameter on purpose: the client takes the approving human's role.
32
+ def start(client:,
36
33
  store: Kiosk.configuration.device_authorization_store,
37
34
  expires_in: DeviceAuthorization::DEFAULT_EXPIRES_IN,
38
35
  now: Time.current)
39
36
  plain_device_code, plain_user_code, da = DeviceAuthorization.generate(
40
- client_id: client_id, kind: :claim, public_key_pem: public_key_pem.to_s.strip,
37
+ client_id: client.id, kind: :claim, public_key_pem: client.public_key_pem,
41
38
  expires_in: expires_in, now: now
42
39
  )
43
40
  store.create(da)
@@ -45,26 +42,30 @@ module Kiosk
45
42
  expires_in: expires_in, interval: DEFAULT_POLL_INTERVAL, da: da }
46
43
  end
47
44
 
48
- # Returns `{ok: true, access_token:, …}` or `{ok: false, error:, description:}`
49
- # with an RFC 8628 §3.5 error code. The auth toll raises its own errors.
50
- def exchange(device_code:, # rubocop:disable Metrics/ParameterLists -- the token controller and the specs pass these by name
51
- signed: nil,
52
- pow: nil,
45
+ # The first poll of each device code value pays the auth toll; later polls
46
+ # of that value are free until any code could have expired. The store is
47
+ # not read first, so an unknown code costs the same work as a live one.
48
+ def pay_toll!(device_code, pow, now: Time.current)
49
+ config = Kiosk.configuration
50
+ paid = "device_code:#{DeviceAuthorization.hash_device_code(device_code)}"
51
+ return if !AuthToll.on?(config) || config.pow_spent_store.spent?(paid)
52
+
53
+ AuthToll.gate(verb: 'oauth/token', body: { device_code: device_code.to_s }, pow: pow, config: config)
54
+ config.pow_spent_store.mark_spent(paid, now.to_i + DeviceAuthorization::DEFAULT_EXPIRES_IN)
55
+ end
56
+
57
+ # @return [Client] the client under the approving human's account
58
+ # @raise [OauthError] with an RFC 8628 §3.5 error code
59
+ def exchange(device_code:, client:,
53
60
  store: Kiosk.configuration.device_authorization_store,
54
61
  interval: DEFAULT_POLL_INTERVAL,
55
62
  now: Time.current)
56
- return failure(:invalid_request, 'device_code parameter required') if device_code.to_s.empty?
57
-
58
63
  hash = DeviceAuthorization.hash_device_code(device_code)
59
- pay_toll_once!(device_code, hash, pow, now)
60
- da = store.lookup_by_device_code_hash(hash)
61
- return failure(:invalid_grant, 'unknown device_code') if da.nil?
62
- return failure(:slow_down, 'polling faster than the advertised interval') if polled_too_fast?(hash, interval, now)
63
-
64
- da = expire_if_due(da, store, now)
65
- return bind_and_mint(authorization: da, signed: signed, store: store, now: now) if da.approved?
64
+ da = authorization_of(client, store.lookup_by_device_code_hash(hash))
65
+ fail!(:slow_down, 'polling faster than the advertised interval') if polled_too_fast?(hash, interval, now)
66
66
 
67
- failure(*STATUS_FAILURES.fetch(da.status))
67
+ consume!(expire_if_due(da, store, now), store, now)
68
+ AccountBinding.bind!(agent_id: client.id, user_id: da.user_id, requested_role: da.requested_role)
68
69
  end
69
70
 
70
71
  def reset_poll_registry!
@@ -74,16 +75,11 @@ module Kiosk
74
75
  class << self
75
76
  private
76
77
 
77
- # The first poll of each device code value pays the auth toll; later polls
78
- # of that value are free until any code could have expired. The store is
79
- # not read first, so an unknown code costs the same work as a live one.
80
- def pay_toll_once!(device_code, hash, pow, now)
81
- config = Kiosk.configuration
82
- paid = "device_code:#{hash}"
83
- return if !AuthToll.on?(config) || config.pow_spent_store.spent?(paid)
78
+ # A claim row that this client opened; a row of another client is unknown to it.
79
+ def authorization_of(client, authorization)
80
+ return authorization if authorization&.claim? && authorization.client_id == client.id
84
81
 
85
- AuthToll.gate(verb: 'oauth/token', body: { device_code: device_code.to_s }, pow: pow, config: config)
86
- config.pow_spent_store.mark_spent(paid, now.to_i + DeviceAuthorization::DEFAULT_EXPIRES_IN)
82
+ fail!(:invalid_grant, 'unknown device_code')
87
83
  end
88
84
 
89
85
  def expire_if_due(authorization, store, now)
@@ -93,41 +89,14 @@ module Kiosk
93
89
  store.update(authorization.expire)
94
90
  end
95
91
 
96
- # Possession of the row's key is proven before binding; a failed proof
97
- # is `invalid_client` and leaves the row approved for a retry.
98
- def bind_and_mint(authorization:, signed:, store:, now:)
99
- refusal = possession_refusal(authorization.public_key_pem, signed)
100
- return refusal if refusal
101
- # Atomic, so two concurrent polls mint one token.
102
- return failure(:invalid_grant, 'device_code already used') if store.claim_consume(authorization, now: now).nil?
103
-
104
- token_response(authorization)
105
- end
106
-
107
- def possession_refusal(public_key_pem, signed)
108
- return failure(:invalid_client, PROOF_REQUIRED) if signed.nil? || signed.to_s.empty?
109
- return failure(:invalid_client, 'no public key bound to this authorization') if public_key_pem.blank?
110
-
111
- payload = PopVerifier.verify!(public_key_pem: public_key_pem, signed: signed)
112
- AuthChallenge.consume!(public_key_pem: public_key_pem, nonce: payload.fetch(:nonce))
113
- nil
114
- rescue Errors::Base => e
115
- failure(:invalid_client, e.message)
116
- end
117
-
118
- def token_response(authorization)
119
- role = authorization.requested_role
120
- result = AccountBinding.bind!(public_key_pem: authorization.public_key_pem,
121
- user_id: authorization.user_id, requested_role: role)
122
- response = { ok: true, access_token: result[:access_token], token_type: 'Bearer',
123
- expires_in: Kiosk.configuration.access_token_ttl }
124
- # RFC 6749 §5.1: the granted scope is the approving human's role.
125
- response[:scope] = role if role
126
- response
92
+ # Atomic, so two concurrent polls bind once.
93
+ def consume!(authorization, store, now)
94
+ fail!(*STATUS_FAILURES.fetch(authorization.status)) unless authorization.approved?
95
+ fail!(:invalid_grant, 'device_code already used') if store.claim_consume(authorization, now: now).nil?
127
96
  end
128
97
 
129
- def failure(error, description)
130
- { ok: false, error: error.to_s, description: description }
98
+ def fail!(error, description)
99
+ raise OauthError.new(error, description)
131
100
  end
132
101
 
133
102
  # Per process; a multi-process origin rate-limits at its edge.
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'digest'
5
+ require 'jwt'
6
+ require 'uri'
7
+
8
+ module Kiosk
9
+ module Server
10
+ # RFC 9449 (spec Section 5.6): a token request, and a request that carries
11
+ # an access token, come with a proof that the registered key signed for
12
+ # this request. The proof's iat window is the replay bound; no nonce is
13
+ # issued and no jti is kept.
14
+ module Dpop
15
+ SCHEME = 'DPoP'
16
+ TYPE = 'dpop+jwt'
17
+ WINDOW = 60
18
+ CLAIMS = %w[jti htm htu iat].freeze
19
+
20
+ # A proof that does not hold.
21
+ class Invalid < StandardError; end
22
+
23
+ module_function
24
+
25
+ # The `WWW-Authenticate` value of a 401 on a request that needs a token.
26
+ def challenge = %(#{SCHEME} algs="#{AssistantKey.algorithms.join(' ')}")
27
+
28
+ def hint
29
+ 'send `Authorization: DPoP <access_token>` and one `DPoP` header: a JWS with typ dpop+jwt, ' \
30
+ "alg #{AssistantKey.algorithms.join(' or ')} and the public jwk of your registered key, signed by " \
31
+ 'that key, whose payload carries jti, htm (the method), htu (the URL with no query), iat (now) ' \
32
+ 'and ath (base64url SHA-256 of the access token)'
33
+ end
34
+
35
+ def token_request_hint
36
+ 'send one `DPoP` header: a JWS with typ dpop+jwt, ' \
37
+ "alg #{AssistantKey.algorithms.join(' or ')} and the public jwk of your registered " \
38
+ 'key, signed by that key, whose payload carries jti, htm POST, htu (the token URL) ' \
39
+ 'and iat (now)'
40
+ end
41
+
42
+ # The access token of an `Authorization: DPoP <token>` header, else nil.
43
+ def token(authorization)
44
+ authorization.to_s[/\A#{SCHEME}\s+(\S+)\z/io, 1]
45
+ end
46
+
47
+ # Raises Errors::AccessTokenRefused unless `proof` is the key `jkt`'s proof
48
+ # for this method, URL and token, signed within {WINDOW} seconds of now.
49
+ def verify!(proof, token:, jkt:, method:, url:)
50
+ raise Invalid, 'the access token is bound to no key' if jkt.to_s.empty?
51
+
52
+ claims = check!(proof, jkt:, method:, url:, key: 'the key the token is bound to')
53
+ raise Invalid, 'the DPoP proof ath is not the hash of this token' unless claims['ath'] == hash_of(token)
54
+ rescue Invalid => e
55
+ raise Errors::AccessTokenRefused, e.message
56
+ end
57
+
58
+ # Raises OauthError `invalid_dpop_proof` unless `proof` is the proof of
59
+ # the registered key `jkt` for this token request. It carries no `ath`:
60
+ # there is no token yet.
61
+ def verify_token_request!(proof, jkt:, url:)
62
+ check!(proof, jkt:, method: 'POST', url:, key: 'your registered key')
63
+ rescue Invalid => e
64
+ raise OauthError.new(:invalid_dpop_proof, "#{e.message}: #{token_request_hint}")
65
+ end
66
+
67
+ # The base64url SHA-256 of the token: the `ath` a proof carries.
68
+ def hash_of(token) = Base64.urlsafe_encode64(Digest::SHA256.digest(token.to_s), padding: false)
69
+
70
+ # `key` names the key `jkt` is the thumbprint of.
71
+ def check!(proof, jkt:, method:, url:, key:)
72
+ raise Invalid, 'one DPoP header is required' if proof.to_s.empty? || proof.include?(',')
73
+
74
+ signer, claims = decode(proof)
75
+ raise Invalid, "the DPoP proof key is not #{key}" unless AssistantKey.thumbprint(signer) == jkt
76
+
77
+ check_request!(claims, method, url)
78
+ claims
79
+ end
80
+
81
+ def check_request!(claims, method, url)
82
+ raise Invalid, 'the DPoP proof htm is not this request method' unless claims['htm'] == method
83
+ raise Invalid, 'the DPoP proof htu is not this request URL' unless same_url?(claims['htu'], url)
84
+ raise Invalid, 'the DPoP proof iat is not within 60 seconds of now' unless (claims['iat'].to_i - Time.now.to_i).abs <= WINDOW
85
+ end
86
+
87
+ # The key is the proof's own `jwk`, and its type names the one algorithm.
88
+ def decode(proof)
89
+ key = header_key(::JWT.decode(proof, nil, false).last)
90
+ algorithm = AssistantKey.algorithm(key)
91
+ claims, = ::JWT.decode(proof, key, true, algorithms: [algorithm], required_claims: CLAIMS)
92
+ [key, claims]
93
+ rescue ::JWT::IncorrectAlgorithm
94
+ raise Invalid, "the DPoP proof alg is not #{algorithm}, the algorithm of its jwk"
95
+ rescue ::JWT::DecodeError
96
+ raise Invalid, 'the DPoP proof is not a JWS that its jwk signed, with the claims of RFC 9449'
97
+ end
98
+
99
+ def header_key(header)
100
+ raise Invalid, 'the DPoP proof typ is not dpop+jwt' unless header['typ'] == TYPE
101
+
102
+ jwk = header['jwk']
103
+ raise Invalid, 'the DPoP proof carries no public jwk' unless jwk.is_a?(Hash) && !jwk.keys.intersect?(AssistantKey::PRIVATE_MEMBERS)
104
+
105
+ AssistantKey.from_jwk(jwk)
106
+ rescue Errors::BadRequest => e
107
+ raise Invalid, "the DPoP proof jwk is refused: #{e.message}"
108
+ end
109
+
110
+ # Scheme and host without case, a default port dropped (RFC 9449 Section 4.3).
111
+ def same_url?(htu, url)
112
+ Kiosk::Configuration.normalize_origin(htu) == Kiosk::Configuration.normalize_origin(url) &&
113
+ URI.parse(htu.to_s).path == URI.parse(url).path
114
+ rescue URI::InvalidURIError
115
+ false
116
+ end
117
+
118
+ private_class_method :check!, :check_request!, :decode, :header_key, :same_url?
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+ require 'openssl'
5
+ require_relative 'jwk'
6
+
7
+ module Kiosk
8
+ module Server
9
+ # The JOSE algorithm EdDSA (RFC 8037) over the Ed25519 keys of OpenSSL.
10
+ # Loading this file registers `EdDSA` with ruby-jwt, so `JWT.encode` and
11
+ # `JWT.decode` accept the name.
12
+ class Eddsa
13
+ include ::JWT::JWA::SigningAlgorithm
14
+
15
+ NAME = 'EdDSA'
16
+
17
+ def initialize
18
+ @alg = NAME
19
+ end
20
+
21
+ def sign(data:, signing_key:)
22
+ raise_sign_error!("an EdDSA key is an Ed25519 key, got #{signing_key.class}") unless Jwk.ed25519?(signing_key)
23
+
24
+ signing_key.sign(nil, data)
25
+ end
26
+
27
+ def verify(data:, signature:, verification_key:)
28
+ raise_verify_error!("an EdDSA key is an Ed25519 key, got #{verification_key.class}") unless Jwk.ed25519?(verification_key)
29
+
30
+ verification_key.verify(nil, signature, data)
31
+ rescue OpenSSL::PKey::PKeyError
32
+ false
33
+ end
34
+
35
+ register_algorithm(new)
36
+ end
37
+ end
38
+ end
@@ -32,8 +32,8 @@ module Kiosk
32
32
  # Credential-bearing wire fields, kept out of the `Parameters:` log line.
33
33
  # Whole-key matches, so an operator's own `promo_code` is left alone.
34
34
  FILTERED_PARAMETERS = %w[
35
- signed code device_code kyc_jws
36
- intent_mandate_jws cart_mandate_jws payment_mandate_jws
35
+ client_assertion new_client_assertion code device_code kyc_jws
36
+ checkout_mandate payment_mandate
37
37
  ].map { |field| /\A#{field}\z/ }.freeze
38
38
 
39
39
  initializer 'kiosk-server.filter_parameters' do |app|
@@ -61,7 +61,7 @@ module Kiosk
61
61
  ::Rails.logger ? ::Rails.logger.warn(message) : warn(message)
62
62
  end
63
63
 
64
- # A wrong `issuer` refuses every proof with "proof audience mismatch".
64
+ # A wrong `issuer` refuses every client assertion with "client assertion audience mismatch".
65
65
  # Warned, not raised: an operator seeds before the public origin exists.
66
66
  LOOPBACK_ISSUER =
67
67
  %r{\A(?:https?://)?(?:localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\])(?::\d+)?/?\z}i
@@ -69,8 +69,8 @@ module Kiosk
69
69
  # The boot-time configuration checks. Each answers a message, or nil when the configuration is sound.
70
70
  module BootChecks
71
71
  ISSUER_UNSET =
72
- '[kiosk-server] `c.issuer` is not set, so every authenticated request is refused ' \
73
- 'with "proof audience mismatch". Set it to the origin assistants dial: ' \
72
+ '[kiosk-server] `c.issuer` is not set, so every client assertion is refused ' \
73
+ 'with "client assertion audience mismatch". Set it to the origin assistants dial: ' \
74
74
  'Kiosk.configure { |c| c.issuer = "https://api.example.com" }.'
75
75
 
76
76
  ISSUER_LOOPBACK =
@@ -144,9 +144,13 @@ module Kiosk
144
144
  get '/agents.txt', to: 'kiosk/server/discovery#agents_txt'
145
145
  get '/agents.json', to: 'kiosk/server/discovery#agents_json'
146
146
  get '/auth.md', to: 'kiosk/server/discovery#auth_md'
147
- get '/.well-known/agent-configuration', to: 'kiosk/server/discovery#agent_configuration'
148
147
  get '/.well-known/kiosk.json', to: 'kiosk/server/discovery#kiosk_json'
149
148
  get '/.well-known/api-catalog', to: 'kiosk/server/discovery#api_catalog'
149
+ get '/.well-known/oauth-authorization-server', to: 'kiosk/server/discovery#oauth_authorization_server'
150
+ get '/.well-known/agent-skills/index.json', to: 'kiosk/server/discovery#agent_skills_index'
151
+ get '/.well-known/ard.json', to: 'kiosk/server/discovery#ard'
152
+ get '/.well-known/ai-catalog.json', to: 'kiosk/server/discovery#ard'
153
+ get '/.well-known/oauth-protected-resource', to: 'kiosk/server/discovery#oauth_protected_resource'
150
154
  end
151
155
  end
152
156
 
@@ -165,10 +169,10 @@ module Kiosk
165
169
  # `events` is also in {HandlerMixin::RESERVED_NAMES}; keep the two in step.
166
170
  mount Kiosk::Server::EventsCable::RACK_APP => 'events', internal: true, anchor: true, as: :kiosk_events
167
171
 
168
- get 'auth/challenge', to: 'auth#challenge'
169
- post 'auth/register', to: 'auth#register'
170
- post 'auth/login', to: 'auth#login'
171
- post 'auth/revoke', to: 'auth#revoke'
172
+ # RFC 7591 registration and the token endpoint (RFC 6749) are core.
173
+ post 'oauth/register', to: 'oauth_registration#create'
174
+ post 'oauth/token', to: 'oauth_token#create'
175
+ post 'auth/rotate', to: 'auth#rotate'
172
176
 
173
177
  # JWKS sits under the mount, not the origin root.
174
178
  get '.well-known/jwks.json', to: 'jwks#show'
@@ -183,7 +187,6 @@ module Kiosk
183
187
 
184
188
  # RFC 8628 device flow.
185
189
  post 'oauth/device_authorization', to: 'oauth_device_authorization#create'
186
- post 'oauth/token', to: 'oauth_token#create'
187
190
  get 'oauth/device/verify', to: 'device_verify#show'
188
191
  post 'oauth/device/verify', to: 'device_verify#create'
189
192
 
@@ -231,6 +231,13 @@ module Kiosk
231
231
  HTTP_STATUS = 401
232
232
  end
233
233
 
234
+ # The 401 of a request that needs an access token: it names the DPoP scheme (RFC 9449 §7.1).
235
+ class AccessTokenRefused < Unauthenticated
236
+ def initialize(message = 'a valid access token with its DPoP proof is required', hint: Dpop.hint) = super
237
+
238
+ def response_headers = { 'WWW-Authenticate' => Dpop.challenge }
239
+ end
240
+
234
241
  class Forbidden < Base
235
242
  CODE = 'forbidden'
236
243
  HTTP_STATUS = 403
@@ -103,7 +103,7 @@ module Kiosk
103
103
 
104
104
  # Closes with `token_expired` or `revoked` once the credential no longer resolves (§8.5.6).
105
105
  def kiosk_credential_holds?
106
- return true if resolve_identity
106
+ return true if token_holds?
107
107
 
108
108
  if kiosk_credential_expired?
109
109
  close(reason: 'token_expired', reconnect: true)
@@ -156,13 +156,23 @@ module Kiosk
156
156
  end
157
157
  end
158
158
 
159
+ # The token and the DPoP proof of the upgrade request.
160
+ def resolve_identity
161
+ under_issuer { Kiosk::Server::IdentityResolution.resolve(request) }
162
+ end
163
+
164
+ # §8.5.6: the proof was checked once, at the upgrade, and is past its
165
+ # 60-second window by now; a re-check reads the token alone.
166
+ def token_holds?
167
+ under_issuer { Kiosk::Server::IdentityResolution.agent_idp.verify(request) }
168
+ end
169
+
159
170
  # Under the upgrade's own issuer: this runs outside {IssuerMiddleware}.
160
171
  # A raising IdP refuses the upgrade rather than failing the socket.
161
- def resolve_identity
162
- issuer = Kiosk.configuration.issuer_for(request.base_url)
163
- Kiosk.with_issuer(issuer) { Kiosk::Server::IdentityResolution.resolve(request) }
172
+ def under_issuer(&)
173
+ Kiosk.with_issuer(Kiosk.configuration.issuer_for(request.base_url), &)
164
174
  rescue StandardError => e
165
- logger&.error("[kiosk] events connection identity resolution failed: #{e.class}")
175
+ logger&.error("[kiosk] events connection identity resolution failed: #{e.class}: #{e.message}")
166
176
  nil
167
177
  end
168
178
  end
@@ -3,38 +3,39 @@
3
3
  module Kiosk
4
4
  module Server
5
5
  class Executor
6
- # A `pay` whose chain was presented before (§11.6).
6
+ # A `pay` whose mandates, or another live pair for the same checkout, came before (§11.6).
7
7
  module Repeat
8
8
  private
9
9
 
10
- # A chain presented before. Settled: its settlement. Neither settled nor
11
- # declined: the chain, to capture again. Anything else is a conflict.
10
+ # The identical pair, presented before. Settled: its settlement. Neither
11
+ # settled nor declined: the pair, to capture again. Anything else is a conflict.
12
12
  def repeat(chain)
13
13
  row = recorded_chain(chain)
14
14
  raise Errors::Conflict.new('mandate already processed', hint: Pay::CONFLICT_HINT) if row.nil? || row['declined_at']
15
- return stored_settlement(row) if row['settlement_id']
15
+ return stored_settlement(chain, row) if row['settlement_id']
16
16
 
17
- chain.cart_row = row.fetch('cart_row')
18
- chain.repeat = true
17
+ chain.checkout_row = row.fetch('checkout_row')
18
+ chain.repeat = true
19
19
  chain
20
20
  end
21
21
 
22
22
  def recorded_chain(chain)
23
- return nil if chain.intent.nil? || chain.cart.nil? || chain.payment.nil?
23
+ return nil if chain.checkout.nil? || chain.payment.nil?
24
24
 
25
25
  row = nil
26
26
  SessionContext.open(connection: connection, identity: identity) { row = chain_record(chain) }
27
27
  row
28
28
  end
29
29
 
30
- def stored_settlement(row)
31
- settlement_result(row.fetch('settlement_id'), row.fetch('psp_reference'),
32
- row.fetch('settled_amount_cents').to_i, row.fetch('currency'))
30
+ def stored_settlement(chain, row)
31
+ settled = { psp_reference: row.fetch('psp_reference'), settled_amount_cents: row.fetch('settled_amount_cents').to_i,
32
+ network_confirmation_id: row['network_confirmation_id'] }
33
+ settlement_result(chain, row.fetch('settlement_id'), settled, row.fetch('currency'))
33
34
  end
34
35
 
35
- # A refusal under which no money moved: the cart stops counting against the spending cap.
36
+ # A refusal under which no money moved: the checkout stops counting against the spending cap.
36
37
  def decline(chain)
37
- SessionContext.open(connection: connection, identity: identity) { decline_cart(chain.cart_row) }
38
+ SessionContext.open(connection: connection, identity: identity) { decline_checkout(chain.checkout_row) }
38
39
  end
39
40
  end
40
41
  end
@@ -8,50 +8,49 @@ module Kiosk
8
8
  private
9
9
 
10
10
  # A nil seam or a nil cap means uncapped. A cap allows no payment in
11
- # another currency. Runs in the transaction that persists the cart,
12
- # under a per-assistant lock, so two concurrent pays see each other: the
13
- # second counts the first's cart as in flight. A cart already recorded
14
- # is a repeat and adds nothing.
15
- def enforce_spending_cap!(cart)
11
+ # another currency. Runs in the transaction that persists the checkout
12
+ # mandate, under a per-assistant lock, so two concurrent pays see each
13
+ # other: the second counts the first's checkout as in flight. A checkout
14
+ # mandate already recorded is a repeat and adds nothing.
15
+ def enforce_spending_cap!(checkout)
16
16
  cap = Kiosk.configuration.spending_cap&.call(agent_id: identity.agent_id)
17
17
  return if cap.nil? # this assistant is uncapped
18
18
 
19
19
  lock_assistant_payments!(identity.agent_id)
20
- return if cart_recorded?(cart)
20
+ return if checkout_recorded?(checkout)
21
21
 
22
- refuse_other_currency!(cap.fetch(:currency), cart)
23
- refuse_over_cap!(cap.fetch(:amount_cents).to_i, cart)
22
+ refuse_other_currency!(cap.fetch(:currency), checkout)
23
+ refuse_over_cap!(cap.fetch(:amount_cents).to_i, checkout)
24
24
  end
25
25
 
26
- def refuse_other_currency!(cap_currency, cart)
26
+ def refuse_other_currency!(cap_currency, checkout)
27
27
  cap_code = MandateVerifier.canonical_currency(cap_currency)
28
- cart_code = MandateVerifier.canonical_currency(cart.currency)
29
- return if cap_code == cart_code
28
+ return if cap_code == checkout.currency
30
29
 
31
30
  raise Errors::SpendingCapExceeded.new('assistant spending cap is in another currency',
32
- hint: "the cap is in #{cap_code.upcase}; this charge is in #{cart_code.upcase}")
31
+ hint: "the cap is in #{cap_code.upcase}; this charge is in #{checkout.currency.upcase}")
33
32
  end
34
33
 
35
- def refuse_over_cap!(cap, cart)
34
+ def refuse_over_cap!(cap, checkout)
36
35
  window_days = Kiosk.configuration.spending_cap_window_days
37
- spent = spent_in_window(cart, window_days)
38
- return if spent + cart.total_amount_cents.to_i <= cap
36
+ spent = spent_in_window(checkout, window_days)
37
+ return if spent + checkout.total_amount_cents <= cap
39
38
 
40
- raise Errors::SpendingCapExceeded.new('assistant spending cap exceeded', hint: cap_hint(cap, spent, window_days, cart))
39
+ raise Errors::SpendingCapExceeded.new('assistant spending cap exceeded', hint: cap_hint(cap, spent, window_days, checkout))
41
40
  end
42
41
 
43
42
  # One tally per currency: cents are not fungible across currencies. One
44
43
  # statement, so a settlement committed between two reads is counted once.
45
- def spent_in_window(cart, window_days)
46
- binds = spent_binds(identity.agent_id, cart.currency, window_days)
44
+ def spent_in_window(checkout, window_days)
45
+ binds = spent_binds(identity.agent_id, checkout.currency, window_days)
47
46
  sql = "SELECT (#{settled_sql(window_days)}) + (#{in_flight_sql}) AS total"
48
47
  connection.exec_query(sql, 'Kiosk spent total', binds).to_a.first.fetch('total').to_i
49
48
  end
50
49
 
51
- def cap_hint(cap, spent, window_days, cart)
50
+ def cap_hint(cap, spent, window_days, checkout)
52
51
  window_note = window_days ? " in the last #{window_days.to_i} day(s)" : ''
53
52
  "cap #{cap} cents; #{spent} already settled or in flight#{window_note}; " \
54
- "this charge is #{cart.total_amount_cents.to_i}"
53
+ "this charge is #{checkout.total_amount_cents}"
55
54
  end
56
55
 
57
56
  # Settled cents for this agent in one currency (see SettledSpend),
@@ -67,16 +66,16 @@ module Kiosk
67
66
  SQL
68
67
  end
69
68
 
70
- # Carts of this agent whose mandate is live, that have no settlement and
69
+ # Checkout mandates of this agent that are live, have no settlement and
71
70
  # were not declined: a capture may still run for each.
72
71
  def in_flight_sql
73
72
  schema = Kiosk.configuration.schema
74
73
  <<~SQL.squish
75
74
  SELECT COALESCE(SUM(c.total_amount_cents), 0) AS total
76
- FROM #{schema}.cart_mandates c
75
+ FROM #{schema}.checkout_mandates c
77
76
  WHERE c.agent_id = $1 AND lower(btrim(c.currency)) = $2 AND c.expires_at > now()
78
77
  AND c.declined_at IS NULL
79
- AND NOT EXISTS (SELECT 1 FROM #{schema}.settlements s WHERE s.cart_mandate_id = c.id)
78
+ AND NOT EXISTS (SELECT 1 FROM #{schema}.settlements s WHERE s.checkout_mandate_id = c.id)
80
79
  SQL
81
80
  end
82
81
 
@@ -86,9 +85,9 @@ module Kiosk
86
85
  window_days ? binds << window_days.to_i : binds
87
86
  end
88
87
 
89
- def cart_recorded?(cart)
90
- sql = "SELECT 1 FROM #{Kiosk.configuration.schema}.cart_mandates WHERE user_id = $1 AND mandate_id = $2"
91
- connection.exec_query(sql, 'Kiosk cart recorded', [cart.user_id, cart.id]).any?
88
+ def checkout_recorded?(checkout)
89
+ sql = "SELECT 1 FROM #{Kiosk.configuration.schema}.checkout_mandates WHERE user_id = $1 AND reference = $2"
90
+ connection.exec_query(sql, 'Kiosk checkout mandate recorded', [checkout.user_id, checkout.reference]).any?
92
91
  end
93
92
 
94
93
  # Held to the end of the transaction; an advisory lock needs no grant on any table.