kiosk-server 0.5.20 → 0.6.28

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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +196 -0
  3. data/README.md +192 -138
  4. data/app/channels/kiosk_events.rb +30 -17
  5. data/app/controllers/kiosk/server/application_controller.rb +0 -1
  6. data/app/controllers/kiosk/server/assistants_controller.rb +13 -14
  7. data/app/controllers/kiosk/server/auth_controller.rb +21 -65
  8. data/app/controllers/kiosk/server/device_verify_controller.rb +0 -5
  9. data/app/controllers/kiosk/server/discovery_controller.rb +24 -7
  10. data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
  11. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
  12. data/app/controllers/kiosk/server/kyc_callback_controller.rb +3 -1
  13. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +17 -34
  14. data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
  15. data/app/controllers/kiosk/server/oauth_token_controller.rb +36 -32
  16. data/app/controllers/kiosk/server/payment_setup_controller.rb +3 -4
  17. data/app/controllers/kiosk/server/wire_controller.rb +2 -2
  18. data/app/models/kiosk/checkout_mandate.rb +20 -0
  19. data/app/models/kiosk/settlement.rb +1 -3
  20. data/app/models/kiosk/visit.rb +0 -2
  21. data/app/views/kiosk/server/assistants/show.html.erb +20 -24
  22. data/app/views/kiosk/server/device_verify/decided.html.erb +1 -0
  23. data/app/views/kiosk/server/device_verify/show.html.erb +1 -0
  24. data/app/views/layouts/kiosk/server/application.html.erb +14 -0
  25. data/lib/generators/kiosk/install/install_generator.rb +25 -69
  26. data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +21 -12
  27. data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +15 -17
  28. data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +39 -10
  29. data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +16 -14
  30. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +50 -15
  31. data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +7 -11
  32. data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +11 -11
  33. data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +7 -11
  34. data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +10 -11
  35. data/lib/generators/kiosk/install/templates/initializer.rb.tt +13 -4
  36. data/lib/kiosk/payable.rb +19 -25
  37. data/lib/kiosk/server/account_binding.rb +32 -62
  38. data/lib/kiosk/server/account_holder_gate.rb +3 -1
  39. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
  40. data/lib/kiosk/server/agent_rotation.rb +62 -0
  41. data/lib/kiosk/server/argument_decoder.rb +2 -2
  42. data/lib/kiosk/server/assistant_accounts.rb +38 -0
  43. data/lib/kiosk/server/assistant_key.rb +86 -0
  44. data/lib/kiosk/server/auth_toll.rb +6 -6
  45. data/lib/kiosk/server/binding_module_gate.rb +1 -1
  46. data/lib/kiosk/server/checkout.rb +57 -0
  47. data/lib/kiosk/server/client.rb +61 -0
  48. data/lib/kiosk/server/client_assertion.rb +154 -0
  49. data/lib/kiosk/server/client_registration.rb +98 -0
  50. data/lib/kiosk/server/column_spending_cap.rb +1 -1
  51. data/lib/kiosk/server/configuration_extension.rb +64 -75
  52. data/lib/kiosk/server/current_request.rb +1 -1
  53. data/lib/kiosk/server/device_authorization.rb +1 -1
  54. data/lib/kiosk/server/device_authorization_stores.rb +1 -1
  55. data/lib/kiosk/server/device_code_grant.rb +38 -69
  56. data/lib/kiosk/server/dpop.rb +126 -0
  57. data/lib/kiosk/server/eddsa.rb +38 -0
  58. data/lib/kiosk/server/engine.rb +16 -13
  59. data/lib/kiosk/server/errors.rb +28 -7
  60. data/lib/kiosk/server/event_stores.rb +2 -2
  61. data/lib/kiosk/server/events.rb +4 -3
  62. data/lib/kiosk/server/events_connection.rb +38 -10
  63. data/lib/kiosk/server/events_replay.rb +79 -0
  64. data/lib/kiosk/server/executor/repeat.rb +13 -12
  65. data/lib/kiosk/server/executor/spending_cap.rb +17 -76
  66. data/lib/kiosk/server/executor.rb +86 -105
  67. data/lib/kiosk/server/failure_log.rb +3 -3
  68. data/lib/kiosk/server/handler_mixin.rb +9 -9
  69. data/lib/kiosk/server/handler_registrations.rb +1 -1
  70. data/lib/kiosk/server/handler_serving.rb +11 -11
  71. data/lib/kiosk/server/headers.rb +3 -3
  72. data/lib/kiosk/server/identity_resolution.rb +12 -3
  73. data/lib/kiosk/server/jwk.rb +83 -0
  74. data/lib/kiosk/server/jwks.rb +6 -0
  75. data/lib/kiosk/server/jwt_issuer.rb +7 -13
  76. data/lib/kiosk/server/kyc.rb +5 -7
  77. data/lib/kiosk/server/kyc_verifier.rb +28 -29
  78. data/lib/kiosk/server/link_code.rb +9 -18
  79. data/lib/kiosk/server/linked_assistants.rb +22 -17
  80. data/lib/kiosk/server/mandate_verifier.rb +97 -139
  81. data/lib/kiosk/server/money.rb +41 -0
  82. data/lib/kiosk/server/oauth_answers.rb +31 -0
  83. data/lib/kiosk/server/oauth_error.rb +33 -0
  84. data/lib/kiosk/server/open_api.rb +31 -29
  85. data/lib/kiosk/server/page.rb +1 -1
  86. data/lib/kiosk/server/payment_claim.rb +47 -78
  87. data/lib/kiosk/server/pow_gate.rb +12 -33
  88. data/lib/kiosk/server/pow_spent_stores.rb +1 -1
  89. data/lib/kiosk/server/receipts.rb +30 -0
  90. data/lib/kiosk/server/reputation_factors.rb +12 -5
  91. data/lib/kiosk/server/request_validation.rb +3 -3
  92. data/lib/kiosk/server/result.rb +1 -1
  93. data/lib/kiosk/server/revocation_stores.rb +9 -1
  94. data/lib/kiosk/server/routing.rb +1 -1
  95. data/lib/kiosk/server/schemas/auth.schema.json +369 -58
  96. data/lib/kiosk/server/schemas/binding.schema.json +24 -59
  97. data/lib/kiosk/server/schemas/kyc.schema.json +40 -78
  98. data/lib/kiosk/server/schemas/mandates.schema.json +123 -39
  99. data/lib/kiosk/server/schemas/pow.schema.json +2 -2
  100. data/lib/kiosk/server/sd_jwt.rb +104 -0
  101. data/lib/kiosk/server/session_context.rb +2 -2
  102. data/lib/kiosk/server/signing_key.rb +18 -52
  103. data/lib/kiosk/server/tolled_calls.rb +46 -0
  104. data/lib/kiosk/server/verb_wire.rb +4 -4
  105. data/lib/kiosk/server/version.rb +1 -1
  106. data/lib/kiosk/server/well_known.rb +272 -205
  107. data/lib/kiosk/server/wire_rendering.rb +2 -2
  108. data/lib/kiosk/server.rb +8 -2
  109. data/lib/kiosk/skill_link.rb +6 -2
  110. data/listen.py +38 -8
  111. metadata +34 -24
  112. data/app/models/kiosk/cart_mandate.rb +0 -21
  113. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
  114. data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +0 -17
  115. data/lib/kiosk/server/agent_login.rb +0 -49
  116. data/lib/kiosk/server/agent_registration.rb +0 -88
  117. data/lib/kiosk/server/auth_challenge.rb +0 -33
  118. data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
  119. data/lib/kiosk/server/pop_verifier.rb +0 -87
  120. data/lib/kiosk/server/schema_definitions.rb +0 -351
  121. data/lib/kiosk/server/settled_spend.rb +0 -21
  122. data/lib/kiosk/server/wrong_door.rb +0 -42
@@ -3,7 +3,7 @@
3
3
  require 'action_cable'
4
4
 
5
5
  # Top level, unsuffixed, because Action Cable constantizes the channel named
6
- # in a subscribe frame: this class name IS the wire's channel name.
6
+ # in a subscribe frame: this class name IS the protocol's channel name.
7
7
  #
8
8
  # {"command":"subscribe",
9
9
  # "identifier":"{\"channel\":\"KioskEvents\",\"topic\":\"todo\",\"subject\":\"list_4f1e…\"}"}
@@ -28,15 +28,14 @@ class KioskEvents < ActionCable::Channel::Base
28
28
  assign(declaration)
29
29
  return reject unless reachable?(declaration)
30
30
 
31
- open_stream!
32
- transmit(subscribed_frame)
31
+ transmit(open_delivery!)
33
32
  end
34
33
 
35
34
  # Action Cable confirms once the pubsub subscription is live, so replay
36
35
  # starts here: an event emitted before that reaches the client this way.
37
36
  def transmit_subscription_confirmation
38
37
  super
39
- replay!
38
+ @replay.confirmed
40
39
  end
41
40
 
42
41
  private
@@ -59,13 +58,10 @@ class KioskEvents < ActionCable::Channel::Base
59
58
  }
60
59
  end
61
60
 
62
- # Everything after `since`, or after the head read before the stream
63
- # opened. An event may arrive twice; delivery is at-least-once and the
64
- # client ignores an `id` it has seen.
65
- def replay!
66
- store.since(identity_key, since || @head).each do |event|
67
- transmit(event) if for_this_subscription?(event)
68
- end
61
+ # Replay and live delivery both come here, through {Kiosk::Server::EventsReplay}.
62
+ # An event can arrive twice; the client ignores an `id` it has seen.
63
+ def deliver(event)
64
+ transmit(event) if for_this_subscription?(event)
69
65
  end
70
66
 
71
67
  def since = params[:since]
@@ -80,17 +76,33 @@ class KioskEvents < ActionCable::Channel::Base
80
76
  (since.nil? || cursor?(since)) && (params[:subject].nil? || params[:subject].is_a?(String))
81
77
  end
82
78
 
83
- def open_stream!
84
- # Before the stream opens, so no event falls between head and stream.
79
+ # Reads head before the stream opens, so no event falls between head and
80
+ # stream. Returns the `subscribed` frame, built before the replay group
81
+ # counts this subscription.
82
+ def open_delivery!
85
83
  @head = store.head
84
+ subscribed_frame.tap do
85
+ @replay = connection.kiosk_replay_member(since || @head) { |event| deliver(event) }
86
+ open_stream!
87
+ end
88
+ end
86
89
 
87
- # Without `coder:` the block receives the raw broadcast String.
90
+ # Without `coder:` the block receives the raw broadcast String.
91
+ def open_stream!
88
92
  stream_from Kiosk::Server::EventsCable.stream_name(identity_key, @topic),
89
93
  coder: ActiveSupport::JSON do |event|
90
- transmit(event) if for_this_subscription?(event)
94
+ @replay.live(event)
95
+ rescue StandardError => e
96
+ logger&.error("[kiosk] events delivery failed for topic #{@topic.inspect}: #{e.class}: #{e.message}")
91
97
  end
92
98
  end
93
99
 
100
+ # On the pubsub's own thread, in the order it hands events over (spec
101
+ # Section 8.5.5). Action Cable's worker pool would reorder them.
102
+ def worker_pool_stream_handler(broadcasting, user_handler, coder: nil)
103
+ stream_handler(broadcasting, user_handler, coder:)
104
+ end
105
+
94
106
  def cursor?(value) = value.is_a?(Integer) && value.between?(0, MAX_CURSOR)
95
107
 
96
108
  # Its own topic (the replayed tail holds every topic) and, when named, its
@@ -124,14 +136,15 @@ class KioskEvents < ActionCable::Channel::Base
124
136
  end
125
137
 
126
138
  # Runs every REAUTHORISE_EVERY_SECONDS. A withdrawn reach is final for this
127
- # subscription, so the declaration is dropped and the frame sent once.
139
+ # subscription: the frame is sent once, and the subscription leaves the
140
+ # connection, so a later subscribe is judged again rather than confirmed as live.
128
141
  def reauthorise!
129
142
  return if @declaration.nil? || reachable?(@declaration)
130
143
 
131
144
  @declaration = nil
132
145
  # Braces required: bare pairs would be read as `transmit`'s keywords.
133
146
  transmit({ 'type' => 'unsubscribed', 'topic' => @topic, 'reason' => 'reach_revoked' })
134
- stop_all_streams
147
+ connection.subscriptions.remove_subscription(self)
135
148
  rescue StandardError => e
136
149
  logger&.error("[kiosk] events re-authorisation failed: #{e.class}: #{e.message}")
137
150
  end
@@ -9,7 +9,6 @@ module Kiosk
9
9
  class ApplicationController < ::ActionController::Base
10
10
  include AccountHolderGate
11
11
  include BindingModuleGate
12
- include WrongDoor
13
12
 
14
13
  prepend_before_action :refuse_unserved_binding
15
14
  end
@@ -8,11 +8,8 @@ module Kiosk
8
8
  class AssistantsController < ApplicationController
9
9
  SIGN_IN_PROMPT = 'Sign in to your account first to manage linked assistants.'
10
10
  SIGN_IN_ALERT = 'Please sign in to manage your linked assistants.'
11
-
12
- WRONG_DOOR_MESSAGE = "this is the account holder's browser page, not the Kiosk wire — " \
13
- 'it needs a signed-in session and a CSRF token from its own form'
14
-
15
- layout false
11
+ CAP_NOT_AN_AMOUNT = 'Write the spending cap as an amount, such as 50 or 12.50.'
12
+ CURRENCY_NOT_A_CODE = 'Write the cap currency as a three-letter currency code, such as EUR.'
16
13
 
17
14
  def show
18
15
  return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
@@ -41,7 +38,7 @@ module Kiosk
41
38
  render_error(e)
42
39
  end
43
40
 
44
- # Edits a bound assistant's label and/or spending cap and its currency (blank cap → unlimited).
41
+ # Edits a bound assistant's label and/or spending cap and its currency (blank cap → no cap).
45
42
  def update
46
43
  return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
47
44
 
@@ -62,8 +59,9 @@ module Kiosk
62
59
 
63
60
  def render_page(status: :ok)
64
61
  @assistants = LinkedAssistants.list(@identity.user_id)
65
- # Without a `config.spending_cap` seam the saved cap binds nothing; the page says so.
66
- @spending_cap_enforced = !Kiosk.configuration.spending_cap.nil?
62
+ # The spend shows where the origin takes payments; the cap, where a `spending_cap` reads it.
63
+ @payments = !Kiosk.configuration.payment_provider.nil?
64
+ @spending_cap = !Kiosk.configuration.spending_cap.nil?
67
65
  @page_path = request.path.sub(%r{/(link|unlink|update)\z}, '')
68
66
  render :show, status: status
69
67
  end
@@ -72,24 +70,25 @@ module Kiosk
72
70
  def settings_changes
73
71
  changes = {}
74
72
  changes['human_label'] = params[:human_label].to_s if params.key?(:human_label)
75
- changes['spending_cap_cents'], changes['spending_cap_currency'] = spending_cap_param if params.key?(:spending_cap_cents)
73
+ changes['spending_cap_cents'], changes['spending_cap_currency'] = spending_cap_param if params.key?(:spending_cap)
76
74
  changes
77
75
  end
78
76
 
79
- # `[cents, currency]`, or `[nil, nil]` for no cap.
77
+ # `[cents, currency]` from the amount the person typed, or `[nil, nil]` for no cap.
80
78
  def spending_cap_param
81
- raw = params[:spending_cap_cents].to_s
79
+ raw = params[:spending_cap].to_s
82
80
  return [nil, nil] if raw.strip.empty?
83
81
 
84
- cents = Integer(raw, exception: false) || raise(Errors::BadRequest, 'spending_cap_cents must be an integer')
85
- [cents, spending_cap_currency_param]
82
+ currency = spending_cap_currency_param
83
+ cents = Money.parse(raw, currency) || raise(Errors::BadRequest, CAP_NOT_AN_AMOUNT)
84
+ [cents, currency]
86
85
  end
87
86
 
88
87
  def spending_cap_currency_param
89
88
  code = MandateVerifier.canonical_currency(params[:spending_cap_currency])
90
89
  return code if MandateVerifier::CURRENCY_CODE.match?(code)
91
90
 
92
- raise Errors::BadRequest, 'spending_cap_currency must be a three-letter ISO 4217 code, such as EUR'
91
+ raise Errors::BadRequest, CURRENCY_NOT_A_CODE
93
92
  end
94
93
  end
95
94
  end
@@ -5,8 +5,8 @@ require 'json'
5
5
 
6
6
  module Kiosk
7
7
  module Server
8
- # The spec's challenge-response auth paths (§6), drawn by the engine: challenge, register, login,
9
- # revoke, and the account-binding link, claim and unlink.
8
+ # The Kiosk auth paths that no OAuth standard covers (§5.7, §6), drawn by the
9
+ # engine: the key rotation, and the account-binding link, claim and unlink.
10
10
  class AuthController < ::ActionController::API
11
11
  # The JSON answers and RFC 9457 problem documents of the auth paths.
12
12
  module Answers
@@ -17,7 +17,7 @@ module Kiosk
17
17
  render json: payload, status: status
18
18
  end
19
19
 
20
- # The same RFC 9457 problem document as the wire (§9).
20
+ # The same RFC 9457 problem document as the protocol (§9).
21
21
  def render_error(err)
22
22
  add_problem_headers(err)
23
23
  render json: err.to_problem, status: err.http_status,
@@ -32,12 +32,12 @@ module Kiosk
32
32
  response.set_header('WWW-Authenticate', challenge) if challenge
33
33
  end
34
34
 
35
- # RFC 7235 challenge header for the 402s, as on the wire.
35
+ # RFC 7235 challenge header for the 402s, as in the protocol.
36
36
  def www_authenticate_for(err)
37
37
  issuer = Kiosk.current_issuer
38
38
  case err
39
39
  when Errors::PowRequired then %(Kiosk-PoW realm="#{issuer}")
40
- when Errors::PaymentSetupRequired then %(Payment realm="#{issuer}", method="ap2")
40
+ when Errors::PaymentSetupRequired then %(Kiosk-Payment-Setup realm="#{issuer}")
41
41
  end
42
42
  end
43
43
  end
@@ -61,52 +61,21 @@ module Kiosk
61
61
 
62
62
  prepend_before_action :refuse_unserved_binding, only: %i[link claim unlink]
63
63
 
64
- # Unauthenticated: the nonce is worthless without the matching private key.
65
- def challenge
66
- public_key = request.query_parameters['public_key']
67
- raise Errors::BadRequest, 'missing public_key query parameter' if public_key.blank?
64
+ # A refused client assertion is a 401, with the hint that says what to fix.
65
+ rescue_from(ClientAssertion::Refused) { |e| render_error(e.to_problem) }
68
66
 
69
- respond(AuthChallenge.issue(public_key_pem: public_key), :ok)
70
- rescue Errors::Base => e
71
- render_error(e)
72
- end
73
-
74
- # Registers a new public key (409 if known). The optional PoW proof rides in the `Kiosk-PoW`
75
- # header, so the signed body stays the same on retry.
76
- def register
77
- body = parse_body!('POST <endpoint>/auth/register')
78
- pow = proofs
79
- result = AgentRegistration.call(public_key_pem: body.fetch(:public_key), signed: body.fetch(:signed), pow: pow)
80
- respond(result, :created)
67
+ # Puts a new key on a registered client; both keys sign (§5.7). Answers the client information.
68
+ def rotate
69
+ body = parse_body!('POST <endpoint>/auth/rotate')
70
+ client = AgentRotation.call(type: body.fetch(:client_assertion_type), assertion: body.fetch(:client_assertion),
71
+ new_assertion: body.fetch(:new_client_assertion), jwks: body.fetch(:jwks))
72
+ respond(client.information, :ok)
81
73
  rescue KeyError => e
82
74
  render_error(Errors.missing_field(e))
83
75
  rescue Errors::Base => e
84
76
  render_error(e)
85
77
  end
86
78
 
87
- # Refresh a token for an EXISTING public key (404 if unknown → register).
88
- def login
89
- body = parse_body!('POST <endpoint>/auth/login')
90
- result = AgentLogin.call(public_key_pem: body.fetch(:public_key), signed: body.fetch(:signed))
91
- respond(result, :ok)
92
- rescue KeyError => e
93
- render_error(Errors.missing_field(e))
94
- rescue Errors::Base => e
95
- render_error(e)
96
- end
97
-
98
- # Revokes every token of the caller's agent and returns a fresh one.
99
- def revoke
100
- identity = authenticated_agent
101
- raise Errors::Unauthenticated, 'agent authentication required' if identity.nil? || identity.agent_id.nil?
102
-
103
- Kiosk.configuration.revocation_store.revoke_all(identity.agent_id, at: Time.now.to_i)
104
- token = fresh_token(identity)
105
- respond({ access_token: token }, :ok)
106
- rescue Errors::Base => e
107
- render_error(e)
108
- end
109
-
110
79
  # Mints a link code for the signed-in holder; the holder's `user_idp` role becomes the bound assistant's.
111
80
  def link
112
81
  identity = authenticated_account_holder!
@@ -116,15 +85,14 @@ module Kiosk
116
85
  render_error(e)
117
86
  end
118
87
 
119
- # §6.2's three fields only: `fresh` stays off the wire so a re-bind is indistinguishable (§6.3).
120
- CLAIM_RESPONSE_FIELDS = %i[agent_id user_id access_token].freeze
121
-
122
- # The optional PoW proof rides in the `Kiosk-PoW` header, as on register.
88
+ # Binds the client to the human who minted the code; answers 204. The
89
+ # optional toll's proofs ride in the `Kiosk-PoW` header.
123
90
  def claim
124
- body = parse_body!('POST <endpoint>/auth/claim')
125
- result = LinkCode.redeem(code: body.fetch(:code), public_key_pem: body.fetch(:public_key),
126
- signed: body.fetch(:signed), pow: proofs)
127
- respond(result.slice(*CLAIM_RESPONSE_FIELDS), :created)
91
+ body = parse_body!('POST <endpoint>/auth/claim')
92
+ LinkCode.redeem(code: body.fetch(:code), assertion_type: body.fetch(:client_assertion_type),
93
+ assertion: body.fetch(:client_assertion), pow: proofs)
94
+ Kiosk::Server::Headers.add_to(response.headers)
95
+ head :no_content
128
96
  rescue KeyError => e
129
97
  render_error(Errors.missing_field(e))
130
98
  rescue Errors::Base => e
@@ -146,29 +114,17 @@ module Kiosk
146
114
 
147
115
  private
148
116
 
149
- # Auth endpoints always mint through the bundled DefaultAgentIdp.
150
- def fresh_token(identity)
151
- AgentIdentityProviders::DefaultAgentIdp.new.issue(agent_id: identity.agent_id, role: identity.role)
152
- end
153
-
154
117
  # The human's side of binding authenticates by the provider's session, never by an agent token.
155
118
  # A page on another origin can post a form with that cookie, but cannot send JSON without a CORS preflight.
156
119
  def authenticated_account_holder!
157
120
  identity = Kiosk.configuration.user_idp&.verify(request)
158
121
  hint = "sign in to the provider first — this endpoint authenticates via the provider's own session"
159
- raise Errors::Unauthenticated.new('account session required', hint:) unless identity
122
+ raise Errors::SessionRequired.new('account session required', hint:) unless identity
160
123
  raise Errors::BadRequest, 'Content-Type must be application/json' unless request.media_type == 'application/json'
161
124
 
162
125
  identity
163
126
  end
164
127
 
165
- # nil for a missing, invalid or revoked bearer token.
166
- def authenticated_agent
167
- IdentityResolution.agent_idp.verify(request)
168
- rescue Kiosk::Server::JwtIssuer::Error, Kiosk::AgentIdentityProviders::InvalidToken
169
- nil
170
- end
171
-
172
128
  # Schema-checked before any `fetch`, so a wrong-typed member is a 400 naming it (§17).
173
129
  def parse_body!(exchange)
174
130
  raw = request.raw_post
@@ -11,14 +11,9 @@ module Kiosk
11
11
 
12
12
  CODE_NOT_RECOGNISED = 'That code was not recognised — it may have expired. Ask the assistant for a fresh one.'
13
13
 
14
- WRONG_DOOR_MESSAGE = "this is the account holder's browser consent page, not the Kiosk wire — " \
15
- 'it needs a signed-in session and a CSRF token from its own form'
16
-
17
14
  # Failed code lookups per session before a 429, against 31^8 possible codes.
18
15
  MAX_CODE_ATTEMPTS = 10
19
16
 
20
- layout false
21
-
22
17
  def show
23
18
  return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
24
19
  return render_attempts_capped if attempts_capped?
@@ -10,21 +10,33 @@ module Kiosk
10
10
  # Rails stamps `Vary: Accept` on a negotiated render; these documents are
11
11
  # one answer for everybody, so a shared cache must keep one copy.
12
12
  after_action :drop_vary
13
+ before_action :allow_cors
13
14
 
14
15
  def agents_txt
15
- allow_cors
16
16
  render plain: WellKnown.agents_txt(base_url: request.base_url),
17
17
  content_type: 'text/plain; charset=utf-8'
18
18
  end
19
19
 
20
20
  def agents_json
21
- allow_cors
22
21
  short_ttl
23
22
  render json: WellKnown.agents_json(base_url: request.base_url)
24
23
  end
25
24
 
26
- def agent_configuration
27
- render json: WellKnown.agent_configuration(base_url: request.base_url)
25
+ def oauth_authorization_server
26
+ render json: WellKnown.oauth_authorization_server(base_url: request.base_url)
27
+ end
28
+
29
+ def oauth_protected_resource
30
+ render json: WellKnown.oauth_protected_resource(base_url: request.base_url)
31
+ end
32
+
33
+ def agent_skills_index
34
+ render_or_not_found WellKnown.agent_skills_index
35
+ end
36
+
37
+ def ard
38
+ short_ttl
39
+ render json: WellKnown.ard(base_url: request.base_url)
28
40
  end
29
41
 
30
42
  def kiosk_json
@@ -33,22 +45,27 @@ module Kiosk
33
45
  end
34
46
 
35
47
  def api_catalog
36
- allow_cors
37
48
  short_ttl
49
+ # RFC 9727 §2: a HEAD request finds the catalog by this header.
50
+ response.set_header('Link', %(<#{request.base_url}/.well-known/api-catalog>; rel="api-catalog"))
38
51
  render json: WellKnown.api_catalog(base_url: request.base_url),
39
52
  content_type: 'application/linkset+json; ' \
40
53
  'profile="https://www.rfc-editor.org/info/rfc9727"'
41
54
  end
42
55
 
43
56
  def auth_md
44
- allow_cors
45
57
  render plain: WellKnown.auth_md(base_url: request.base_url),
46
58
  content_type: 'text/markdown; charset=utf-8'
47
59
  end
48
60
 
49
61
  private
50
62
 
51
- # agents.txt v1.0 requires `Access-Control-Allow-Origin: *`.
63
+ def render_or_not_found(document)
64
+ document ? render(json: document) : head(:not_found)
65
+ end
66
+
67
+ # agents.txt v1.0 and AI Catalog require `Access-Control-Allow-Origin: *`.
68
+ # Every document here answers it, so a page on any origin can read it.
52
69
  def allow_cors
53
70
  response.set_header('Access-Control-Allow-Origin', '*')
54
71
  end
@@ -4,11 +4,11 @@ require 'action_controller'
4
4
 
5
5
  module Kiosk
6
6
  module Server
7
- # GET <endpoint>/.well-known/jwks.json — the public signing key.
7
+ # GET <endpoint>/.well-known/jwks.json — the current signing key, and the previous one while it is set.
8
8
  class JwksController < ::ActionController::API
9
9
  def show
10
10
  Kiosk::Server::Headers.add_to(response.headers)
11
- render json: Kiosk::Server::Jwks.build(keys: [Kiosk.configuration.signing_key])
11
+ render json: Kiosk::Server::Jwks.build(keys: Kiosk.configuration.signing_keys)
12
12
  end
13
13
  end
14
14
  end
@@ -49,10 +49,7 @@ module Kiosk
49
49
 
50
50
  # Only an assistant submits an attestation, so only the agent IdP answers.
51
51
  def authenticate!
52
- identity = IdentityResolution.agent_idp.verify(request)
53
- raise Errors::Unauthenticated, 'missing or invalid agent token' if identity.nil?
54
-
55
- identity
52
+ IdentityResolution.resolve(request) || raise(Errors::AccessTokenRefused)
56
53
  end
57
54
 
58
55
  def render_error(err)
@@ -7,7 +7,9 @@ module Kiosk
7
7
  class KycCallbackController < WireController
8
8
  def create
9
9
  Kyc.served!
10
- Kyc.callback(parse_body!)
10
+ body = parse_body!
11
+ RequestValidation.validate_body!(body, exchange: 'POST <endpoint>/kyc/callback')
12
+ Kyc.callback(body)
11
13
  render json: { ok: true }, status: :ok
12
14
  end
13
15
  end
@@ -4,48 +4,36 @@ require 'action_controller'
4
4
 
5
5
  module Kiosk
6
6
  module Server
7
- # POST <endpoint>/oauth/device_authorization (RFC 8628 §3.1): an assistant
8
- # opens the claim half of account binding with `client_id` and `public_key`.
7
+ # POST <endpoint>/oauth/device_authorization (RFC 8628 §3.1): a registered
8
+ # client opens the claim half of account binding. It authenticates with a
9
+ # client assertion, so it sends no `client_id` of its own.
9
10
  class OauthDeviceAuthorizationController < ::ActionController::API
10
11
  include BindingModuleGate
12
+ include OauthAnswers
11
13
 
12
14
  prepend_before_action :refuse_unserved_binding
13
15
 
14
16
  def create
15
- problem = request_problem
16
- return render_oauth_error(:invalid_request, problem, status: 400) if problem
17
+ problem = role_problem
18
+ raise OauthError.new(:invalid_request, problem) if problem
17
19
 
18
- result = DeviceCodeGrant.start(client_id: params[:client_id].to_s, public_key_pem: params[:public_key].to_s)
19
- Kiosk::Server::Headers.add_to(response.headers)
20
- render json: device_authorization(result)
20
+ render_oauth(device_authorization(DeviceCodeGrant.start(client: authenticate_client!)))
21
+ rescue OauthError => e
22
+ render_oauth_error(e)
21
23
  end
22
24
 
23
25
  private
24
26
 
25
- # The first reason this request is refused, or nil.
26
- def request_problem
27
- return 'client_id parameter required' if params[:client_id].to_s.empty?
28
- return 'public_key parameter required' if params[:public_key].to_s.empty?
29
-
30
- key_problem(params[:public_key].to_s) || role_problem
31
- end
32
-
33
- def key_problem(public_key)
34
- PopVerifier.load_public_key(public_key.strip)
35
- nil
36
- rescue Errors::BadRequest => e
37
- e.message
38
- end
39
-
40
- # Refused, not ignored: the role is the approving human's, and this
41
- # request is unauthenticated.
27
+ # A `role` is refused: the role is the approving human's, and the
28
+ # assistant does not choose it. A `scope` is ignored (RFC 6749 Section
29
+ # 3.3): an assistant gets another role only when a human who holds that
30
+ # role binds it to their account, or through the identity provider.
42
31
  def role_problem
43
- offending = %i[role scope].find { |name| params.key?(name) }
44
- return unless offending
32
+ return unless params.key?(:role)
45
33
 
46
- "#{offending} is not accepted here — an assistant does not choose its own role. " \
47
- "The role of a bound assistant is the approving account holder's own role, " \
48
- "read from this provider's identity system when they approve at the verify page."
34
+ 'role is not accepted here: an assistant does not choose its own role. ' \
35
+ 'The role of a bound assistant is the role of the account holder who approves, ' \
36
+ "and this provider's identity system gives it at the verify page."
49
37
  end
50
38
 
51
39
  def device_authorization(result)
@@ -63,11 +51,6 @@ module Kiosk
63
51
  mount = Kiosk.configuration.mount_path
64
52
  "#{request.base_url}#{mount}/oauth/device/verify"
65
53
  end
66
-
67
- def render_oauth_error(code, description, status:)
68
- Kiosk::Server::Headers.add_to(response.headers)
69
- render json: { error: code.to_s, error_description: description }, status: status
70
- end
71
54
  end
72
55
  end
73
56
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_controller'
4
+ require 'json'
5
+
6
+ module Kiosk
7
+ module Server
8
+ # POST <endpoint>/oauth/register (RFC 7591 Section 3): open registration of
9
+ # an AI assistant's public key. The optional toll's proofs ride in the
10
+ # `Kiosk-PoW` header, so the body stays the same on retry.
11
+ class OauthRegistrationController < ::ActionController::API
12
+ include AuthController::Answers
13
+ include AuthController::Proofs
14
+ include OauthAnswers
15
+
16
+ def create
17
+ client = ClientRegistration.call(metadata, pow: proofs)
18
+ render_oauth(client.information, status: :created)
19
+ rescue OauthError => e
20
+ render_oauth_error(e)
21
+ rescue Errors::Base => e
22
+ render_error(e)
23
+ end
24
+
25
+ private
26
+
27
+ def metadata
28
+ JSON.parse(request.raw_post.to_s)
29
+ rescue JSON::ParserError
30
+ raise OauthError.new(:invalid_client_metadata, 'the request body must be a JSON object')
31
+ end
32
+ end
33
+ end
34
+ end