kiosk-server 0.5.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +549 -0
  3. data/LICENSE.txt +202 -0
  4. data/README.md +635 -0
  5. data/app/channels/kiosk_events.rb +138 -0
  6. data/app/controllers/kiosk/server/application_controller.rb +17 -0
  7. data/app/controllers/kiosk/server/assistants_controller.rb +96 -0
  8. data/app/controllers/kiosk/server/auth_controller.rb +187 -0
  9. data/app/controllers/kiosk/server/device_verify_controller.rb +101 -0
  10. data/app/controllers/kiosk/server/discovery_controller.rb +66 -0
  11. data/app/controllers/kiosk/server/jwks_controller.rb +15 -0
  12. data/app/controllers/kiosk/server/kyc_attestation_controller.rb +69 -0
  13. data/app/controllers/kiosk/server/kyc_callback_controller.rb +15 -0
  14. data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +73 -0
  15. data/app/controllers/kiosk/server/oauth_token_controller.rb +59 -0
  16. data/app/controllers/kiosk/server/open_api_controller.rb +20 -0
  17. data/app/controllers/kiosk/server/payment_setup_controller.rb +31 -0
  18. data/app/controllers/kiosk/server/verb_controller.rb +61 -0
  19. data/app/controllers/kiosk/server/wire_controller.rb +43 -0
  20. data/app/models/kiosk/cart_mandate.rb +21 -0
  21. data/app/models/kiosk/server/application_record.rb +10 -0
  22. data/app/models/kiosk/settlement.rb +12 -0
  23. data/app/models/kiosk/visit.rb +13 -0
  24. data/app/views/kiosk/server/assistants/show.html.erb +94 -0
  25. data/app/views/kiosk/server/device_verify/decided.html.erb +14 -0
  26. data/app/views/kiosk/server/device_verify/show.html.erb +53 -0
  27. data/lib/generators/kiosk/install/install_generator.rb +125 -0
  28. data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +16 -0
  29. data/lib/generators/kiosk/install/templates/create_kiosk_device_authorizations.rb.tt +17 -0
  30. data/lib/generators/kiosk/install/templates/create_kiosk_events.rb.tt +22 -0
  31. data/lib/generators/kiosk/install/templates/create_kiosk_identity_tables.rb.tt +16 -0
  32. data/lib/generators/kiosk/install/templates/create_kiosk_kyc_attributes.rb.tt +20 -0
  33. data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +21 -0
  34. data/lib/generators/kiosk/install/templates/create_kiosk_pow_spent.rb.tt +16 -0
  35. data/lib/generators/kiosk/install/templates/create_kiosk_refunds.rb.tt +16 -0
  36. data/lib/generators/kiosk/install/templates/create_kiosk_revocations.rb.tt +16 -0
  37. data/lib/generators/kiosk/install/templates/create_kiosk_schema.rb.tt +17 -0
  38. data/lib/generators/kiosk/install/templates/create_kiosk_visits.rb.tt +16 -0
  39. data/lib/generators/kiosk/install/templates/initializer.rb.tt +53 -0
  40. data/lib/kiosk/handler.rb +43 -0
  41. data/lib/kiosk/owned.rb +17 -0
  42. data/lib/kiosk/payable.rb +90 -0
  43. data/lib/kiosk/server/account_binding.rb +188 -0
  44. data/lib/kiosk/server/account_holder_gate.rb +58 -0
  45. data/lib/kiosk/server/action_event.rb +60 -0
  46. data/lib/kiosk/server/actions.rb +14 -0
  47. data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +89 -0
  48. data/lib/kiosk/server/agent_login.rb +49 -0
  49. data/lib/kiosk/server/agent_registration.rb +88 -0
  50. data/lib/kiosk/server/argument_decoder.rb +260 -0
  51. data/lib/kiosk/server/audit_sink.rb +42 -0
  52. data/lib/kiosk/server/auth_challenge.rb +33 -0
  53. data/lib/kiosk/server/auth_challenge_stores.rb +74 -0
  54. data/lib/kiosk/server/auth_toll.rb +44 -0
  55. data/lib/kiosk/server/binding_module_gate.rb +25 -0
  56. data/lib/kiosk/server/caller_timezone.rb +50 -0
  57. data/lib/kiosk/server/column_spending_cap.rb +26 -0
  58. data/lib/kiosk/server/configuration_extension.rb +328 -0
  59. data/lib/kiosk/server/controller_verb.rb +63 -0
  60. data/lib/kiosk/server/current_request.rb +31 -0
  61. data/lib/kiosk/server/cursor.rb +33 -0
  62. data/lib/kiosk/server/device_authorization.rb +136 -0
  63. data/lib/kiosk/server/device_authorization_stores.rb +184 -0
  64. data/lib/kiosk/server/device_code_grant.rb +145 -0
  65. data/lib/kiosk/server/device_verification.rb +46 -0
  66. data/lib/kiosk/server/engine.rb +203 -0
  67. data/lib/kiosk/server/errors.rb +339 -0
  68. data/lib/kiosk/server/event_stores.rb +124 -0
  69. data/lib/kiosk/server/events.rb +86 -0
  70. data/lib/kiosk/server/events_cable.rb +102 -0
  71. data/lib/kiosk/server/events_connection.rb +170 -0
  72. data/lib/kiosk/server/executor/repeat.rb +42 -0
  73. data/lib/kiosk/server/executor/spending_cap.rb +102 -0
  74. data/lib/kiosk/server/executor.rb +397 -0
  75. data/lib/kiosk/server/failure_log.rb +28 -0
  76. data/lib/kiosk/server/handler_mixin.rb +418 -0
  77. data/lib/kiosk/server/handler_registrations.rb +57 -0
  78. data/lib/kiosk/server/handler_serving.rb +197 -0
  79. data/lib/kiosk/server/headers.rb +82 -0
  80. data/lib/kiosk/server/headers_middleware.rb +31 -0
  81. data/lib/kiosk/server/identity_resolution.rb +20 -0
  82. data/lib/kiosk/server/issuer_middleware.rb +22 -0
  83. data/lib/kiosk/server/jwks.rb +14 -0
  84. data/lib/kiosk/server/jwt_issuer.rb +98 -0
  85. data/lib/kiosk/server/kyc.rb +261 -0
  86. data/lib/kiosk/server/kyc_verifier.rb +106 -0
  87. data/lib/kiosk/server/link_code.rb +90 -0
  88. data/lib/kiosk/server/linked_assistants.rb +116 -0
  89. data/lib/kiosk/server/mandate_verifier.rb +221 -0
  90. data/lib/kiosk/server/open_api.rb +543 -0
  91. data/lib/kiosk/server/page.rb +19 -0
  92. data/lib/kiosk/server/payment_claim.rb +293 -0
  93. data/lib/kiosk/server/payment_setup.rb +102 -0
  94. data/lib/kiosk/server/pop_verifier.rb +87 -0
  95. data/lib/kiosk/server/pow_gate.rb +121 -0
  96. data/lib/kiosk/server/pow_spent_stores.rb +96 -0
  97. data/lib/kiosk/server/pow_toll.rb +136 -0
  98. data/lib/kiosk/server/queries.rb +14 -0
  99. data/lib/kiosk/server/reputation_factors.rb +38 -0
  100. data/lib/kiosk/server/request_validation.rb +171 -0
  101. data/lib/kiosk/server/response_validation.rb +45 -0
  102. data/lib/kiosk/server/result.rb +25 -0
  103. data/lib/kiosk/server/revocation_stores.rb +57 -0
  104. data/lib/kiosk/server/routing.rb +76 -0
  105. data/lib/kiosk/server/schema_definitions.rb +351 -0
  106. data/lib/kiosk/server/schema_document.rb +116 -0
  107. data/lib/kiosk/server/schema_slots.rb +119 -0
  108. data/lib/kiosk/server/schemas/auth.schema.json +135 -0
  109. data/lib/kiosk/server/schemas/binding.schema.json +145 -0
  110. data/lib/kiosk/server/schemas/kyc.schema.json +114 -0
  111. data/lib/kiosk/server/schemas/mandates.schema.json +75 -0
  112. data/lib/kiosk/server/schemas/pow.schema.json +94 -0
  113. data/lib/kiosk/server/session_context.rb +88 -0
  114. data/lib/kiosk/server/settled_spend.rb +21 -0
  115. data/lib/kiosk/server/signing_key.rb +82 -0
  116. data/lib/kiosk/server/verb_registry.rb +77 -0
  117. data/lib/kiosk/server/verb_vocabulary.rb +163 -0
  118. data/lib/kiosk/server/verb_wire.rb +85 -0
  119. data/lib/kiosk/server/version.rb +7 -0
  120. data/lib/kiosk/server/well_known.rb +416 -0
  121. data/lib/kiosk/server/wire_rendering.rb +116 -0
  122. data/lib/kiosk/server/wrong_door.rb +42 -0
  123. data/lib/kiosk/server.rb +42 -0
  124. data/lib/kiosk/skill_link.rb +16 -0
  125. data/listen.py +278 -0
  126. data/requirements.txt +1 -0
  127. metadata +349 -0
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_cable'
4
+
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.
7
+ #
8
+ # {"command":"subscribe",
9
+ # "identifier":"{\"channel\":\"KioskEvents\",\"topic\":\"todo\",\"subject\":\"list_4f1e…\"}"}
10
+ #
11
+ class KioskEvents < ActionCable::Channel::Base
12
+ # The subject's reach is re-checked while the subscription stands (spec
13
+ # Section 8.5.6); the credential is the connection's to re-check.
14
+ REAUTHORISE_EVERY_SECONDS = 30
15
+
16
+ periodically :reauthorise!, every: REAUTHORISE_EVERY_SECONDS
17
+
18
+ # A cursor is a non-negative integer JSON carries exactly (spec Section
19
+ # 8.5.4, RFC 7493 Section 2.2).
20
+ MAX_CURSOR = (2**53) - 1
21
+
22
+ def subscribed
23
+ return reject unless connection.kiosk_credential_holds?
24
+
25
+ declaration = Kiosk::Server::Events.fetch(params[:topic].to_s)
26
+ return reject unless declaration && well_formed?
27
+
28
+ assign(declaration)
29
+ return reject unless reachable?(declaration)
30
+
31
+ open_stream!
32
+ transmit(subscribed_frame)
33
+ end
34
+
35
+ # Action Cable confirms once the pubsub subscription is live, so replay
36
+ # starts here: an event emitted before that reaches the client this way.
37
+ def transmit_subscription_confirmation
38
+ super
39
+ replay!
40
+ end
41
+
42
+ private
43
+
44
+ def identity_key = connection.kiosk_identity_key
45
+
46
+ def identity = connection.kiosk_identity
47
+
48
+ def store = Kiosk.configuration.event_store
49
+
50
+ # `head` is the cursor the client records; `truncated` says events after
51
+ # its `since` were pruned, so it re-reads state once through the verb.
52
+ def subscribed_frame
53
+ {
54
+ 'type' => 'subscribed',
55
+ 'topic' => @topic,
56
+ 'subject' => @subject,
57
+ 'head' => @head,
58
+ 'truncated' => since ? store.truncated?(identity_key, since) : false
59
+ }
60
+ end
61
+
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
69
+ end
70
+
71
+ def since = params[:since]
72
+
73
+ def assign(declaration)
74
+ @topic = params[:topic].to_s
75
+ @subject = params[:subject]
76
+ @declaration = declaration
77
+ end
78
+
79
+ def well_formed?
80
+ (since.nil? || cursor?(since)) && (params[:subject].nil? || params[:subject].is_a?(String))
81
+ end
82
+
83
+ def open_stream!
84
+ # Before the stream opens, so no event falls between head and stream.
85
+ @head = store.head
86
+
87
+ # Without `coder:` the block receives the raw broadcast String.
88
+ stream_from Kiosk::Server::EventsCable.stream_name(identity_key, @topic),
89
+ coder: ActiveSupport::JSON do |event|
90
+ transmit(event) if for_this_subscription?(event)
91
+ end
92
+ end
93
+
94
+ def cursor?(value) = value.is_a?(Integer) && value.between?(0, MAX_CURSOR)
95
+
96
+ # Its own topic (the replayed tail holds every topic) and, when named, its
97
+ # own subject (a stream name carries none).
98
+ def for_this_subscription?(event)
99
+ return false unless event['topic'] == @topic
100
+ return true if @subject.nil?
101
+
102
+ event['subject'].to_s == @subject.to_s
103
+ end
104
+
105
+ # Spec Sections 8.5.4 and 8.5.6. No subject, or a `published` topic, is
106
+ # reachable; otherwise the topic's `subject_reachable` rule answers, and a
107
+ # topic without one admits a named subject only under `principal` reach.
108
+ def reachable?(declaration)
109
+ return true if @subject.nil? || declaration[:reach] == :published
110
+
111
+ callable = declaration[:subject_reachable]
112
+ return declaration[:reach] == :principal if callable.nil?
113
+
114
+ !!callable.call(@subject, identity)
115
+ rescue StandardError => e
116
+ log_rule_failure(e)
117
+ false
118
+ end
119
+
120
+ # Logged, because the refusal alone cannot tell a broken rule from a no.
121
+ def log_rule_failure(error)
122
+ logger&.error("[kiosk] events subject rule raised for topic #{@topic.inspect}: " \
123
+ "#{error.class}: #{error.message} at #{error.backtrace&.first}")
124
+ end
125
+
126
+ # Runs every REAUTHORISE_EVERY_SECONDS. A withdrawn reach is final for this
127
+ # subscription, so the declaration is dropped and the frame sent once.
128
+ def reauthorise!
129
+ return if @declaration.nil? || reachable?(@declaration)
130
+
131
+ @declaration = nil
132
+ # Braces required: bare pairs would be read as `transmit`'s keywords.
133
+ transmit({ 'type' => 'unsubscribed', 'topic' => @topic, 'reason' => 'reach_revoked' })
134
+ stop_all_streams
135
+ rescue StandardError => e
136
+ logger&.error("[kiosk] events re-authorisation failed: #{e.class}: #{e.message}")
137
+ end
138
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_controller'
4
+
5
+ module Kiosk
6
+ module Server
7
+ # The base of the account holder's browser pages: a signed-in session, a
8
+ # CSRF token from the page's own form, and the binding module served.
9
+ class ApplicationController < ::ActionController::Base
10
+ include AccountHolderGate
11
+ include BindingModuleGate
12
+ include WrongDoor
13
+
14
+ prepend_before_action :refuse_unserved_binding
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # «Link an assistant» page: a signed-in account holder lists, labels,
6
+ # links and unlinks their assistants. Host views override
7
+ # app/views/kiosk/server/assistants/show.html.erb.
8
+ class AssistantsController < ApplicationController
9
+ SIGN_IN_PROMPT = 'Sign in to your account first to manage linked assistants.'
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
16
+
17
+ def show
18
+ return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
19
+
20
+ render_page
21
+ end
22
+
23
+ # The signed-in human's role goes on the link row, as on the JSON path;
24
+ # `bind!` validates it at redeem.
25
+ def link
26
+ return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
27
+
28
+ result = LinkCode.mint(user_id: @identity.user_id, requested_role: @identity.role)
29
+ @link_code = result[:link_code]
30
+ @expires_in = result[:expires_in]
31
+ render_page
32
+ end
33
+
34
+ def unlink
35
+ return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
36
+
37
+ AccountBinding.unlink!(agent_id: params[:agent_id].to_s, user_id: @identity.user_id)
38
+ @notice = 'Assistant unlinked — its key no longer signs in.'
39
+ render_page
40
+ rescue Errors::Base => e
41
+ render_error(e)
42
+ end
43
+
44
+ # Edits a bound assistant's label and/or spending cap and its currency (blank cap → unlimited).
45
+ def update
46
+ return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
47
+
48
+ changes = settings_changes
49
+ LinkedAssistants.save_settings(params[:agent_id].to_s, @identity.user_id, changes) if changes.any?
50
+ @notice = 'Assistant settings saved.'
51
+ render_page
52
+ rescue Errors::Base => e
53
+ render_error(e)
54
+ end
55
+
56
+ private
57
+
58
+ def render_error(error)
59
+ @error = error.message
60
+ render_page(status: error.http_status)
61
+ end
62
+
63
+ def render_page(status: :ok)
64
+ @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?
67
+ @page_path = request.path.sub(%r{/(link|unlink|update)\z}, '')
68
+ render :show, status: status
69
+ end
70
+
71
+ # Column name → new value; nil writes NULL.
72
+ def settings_changes
73
+ changes = {}
74
+ 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)
76
+ changes
77
+ end
78
+
79
+ # `[cents, currency]`, or `[nil, nil]` for no cap.
80
+ def spending_cap_param
81
+ raw = params[:spending_cap_cents].to_s
82
+ return [nil, nil] if raw.strip.empty?
83
+
84
+ cents = Integer(raw, exception: false) || raise(Errors::BadRequest, 'spending_cap_cents must be an integer')
85
+ [cents, spending_cap_currency_param]
86
+ end
87
+
88
+ def spending_cap_currency_param
89
+ code = MandateVerifier.canonical_currency(params[:spending_cap_currency])
90
+ return code if MandateVerifier::CURRENCY_CODE.match?(code)
91
+
92
+ raise Errors::BadRequest, 'spending_cap_currency must be a three-letter ISO 4217 code, such as EUR'
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_controller'
4
+ require 'json'
5
+
6
+ module Kiosk
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.
10
+ class AuthController < ::ActionController::API
11
+ # The JSON answers and RFC 9457 problem documents of the auth paths.
12
+ module Answers
13
+ private
14
+
15
+ def respond(payload, status)
16
+ Kiosk::Server::Headers.add_to(response.headers)
17
+ render json: payload, status: status
18
+ end
19
+
20
+ # The same RFC 9457 problem document as the wire (§9).
21
+ def render_error(err)
22
+ add_problem_headers(err)
23
+ render json: err.to_problem, status: err.http_status,
24
+ content_type: Errors::PROBLEM_CONTENT_TYPE
25
+ end
26
+
27
+ def add_problem_headers(err)
28
+ Kiosk::Server::Headers.add_to(response.headers)
29
+ Kiosk::Server::Headers.add_cache_policy(response.headers, status: err.http_status)
30
+ err.response_headers.each { |name, value| response.set_header(name, value) }
31
+ challenge = www_authenticate_for(err)
32
+ response.set_header('WWW-Authenticate', challenge) if challenge
33
+ end
34
+
35
+ # RFC 7235 challenge header for the 402s, as on the wire.
36
+ def www_authenticate_for(err)
37
+ issuer = Kiosk.current_issuer
38
+ case err
39
+ when Errors::PowRequired then %(Kiosk-PoW realm="#{issuer}")
40
+ when Errors::PaymentSetupRequired then %(Payment realm="#{issuer}", method="ap2")
41
+ end
42
+ end
43
+ end
44
+ include Answers
45
+
46
+ # The auth toll's proofs: the `Kiosk-PoW` header, schema-checked, or nil.
47
+ module Proofs
48
+ private
49
+
50
+ def proofs
51
+ header = request.get_header('HTTP_KIOSK_POW')
52
+ pow = PowGate.proofs_from_header(header)
53
+ RequestValidation.validate_proofs!(pow) if Kiosk.configuration.validate_requests && pow
54
+ pow
55
+ end
56
+ end
57
+ include Proofs
58
+
59
+ # Only the binding actions depend on this origin serving binding.
60
+ include BindingModuleGate
61
+
62
+ prepend_before_action :refuse_unserved_binding, only: %i[link claim unlink]
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?
68
+
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)
81
+ rescue KeyError => e
82
+ render_error(Errors.missing_field(e))
83
+ rescue Errors::Base => e
84
+ render_error(e)
85
+ end
86
+
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
+ # Mints a link code for the signed-in holder; the holder's `user_idp` role becomes the bound assistant's.
111
+ def link
112
+ identity = authenticated_account_holder!
113
+ result = LinkCode.mint(user_id: identity.user_id, requested_role: identity.role)
114
+ respond({ link_code: result[:link_code], expires_in: result[:expires_in] }, :created)
115
+ rescue Errors::Base => e
116
+ render_error(e)
117
+ end
118
+
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.
123
+ 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)
128
+ rescue KeyError => e
129
+ render_error(Errors.missing_field(e))
130
+ rescue Errors::Base => e
131
+ render_error(e)
132
+ end
133
+
134
+ # Deactivates one of the signed-in holder's assistant accounts; answers 204 with no body (§6.2).
135
+ def unlink
136
+ identity = authenticated_account_holder!
137
+ body = parse_body!('POST <endpoint>/auth/unlink')
138
+ AccountBinding.unlink!(agent_id: body.fetch(:agent_id), user_id: identity.user_id)
139
+ Kiosk::Server::Headers.add_to(response.headers)
140
+ head :no_content
141
+ rescue KeyError => e
142
+ render_error(Errors.missing_field(e))
143
+ rescue Errors::Base => e
144
+ render_error(e)
145
+ end
146
+
147
+ private
148
+
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
+ # The human's side of binding authenticates by the provider's session, never by an agent token.
155
+ # A page on another origin can post a form with that cookie, but cannot send JSON without a CORS preflight.
156
+ def authenticated_account_holder!
157
+ identity = Kiosk.configuration.user_idp&.verify(request)
158
+ 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
160
+ raise Errors::BadRequest, 'Content-Type must be application/json' unless request.media_type == 'application/json'
161
+
162
+ identity
163
+ end
164
+
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
+ # Schema-checked before any `fetch`, so a wrong-typed member is a 400 naming it (§17).
173
+ def parse_body!(exchange)
174
+ raw = request.raw_post
175
+ raise Errors::BadRequest, 'request body must be a JSON object' if raw.blank?
176
+
177
+ parsed = JSON.parse(raw, symbolize_names: true)
178
+ raise Errors::BadRequest, 'request body must be a JSON object' unless parsed.is_a?(Hash)
179
+
180
+ RequestValidation.validate_body!(parsed, exchange: exchange)
181
+ parsed
182
+ rescue JSON::ParserError
183
+ raise Errors.malformed_json
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kiosk
4
+ module Server
5
+ # The verify page where a signed-in human approves or denies an assistant
6
+ # link. The role the panel shows is the role the approval stamps: the
7
+ # human's own, never one the assistant asked for.
8
+ class DeviceVerifyController < ApplicationController
9
+ SIGN_IN_PROMPT = 'Sign in to your account first, then re-open this page to approve the assistant link.'
10
+ SIGN_IN_ALERT = 'Please sign in to approve the assistant link.'
11
+
12
+ CODE_NOT_RECOGNISED = 'That code was not recognised — it may have expired. Ask the assistant for a fresh one.'
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
+ # Failed code lookups per session before a 429, against 31^8 possible codes.
18
+ MAX_CODE_ATTEMPTS = 10
19
+
20
+ layout false
21
+
22
+ def show
23
+ return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
24
+ return render_attempts_capped if attempts_capped?
25
+
26
+ @user_code = params[:user_code].to_s
27
+ @authorization = find_authorization(@user_code)
28
+ @fingerprint = key_fingerprint(@authorization)
29
+ @role = @identity.role
30
+ render :show
31
+ end
32
+
33
+ def create
34
+ return unless require_account_holder!(prompt: SIGN_IN_PROMPT, flash_alert: SIGN_IN_ALERT)
35
+ return render_attempts_capped if attempts_capped?
36
+
37
+ @user_code = params[:user_code].to_s
38
+ @role = @identity.role
39
+ @decision = decide(params[:decision].to_s)
40
+ return render plain: 'decision must be approve or deny', status: :bad_request unless @decision
41
+
42
+ render :decided
43
+ rescue DeviceVerification::CodeNotFoundError
44
+ render_code_not_recognised
45
+ end
46
+
47
+ private
48
+
49
+ def find_authorization(user_code)
50
+ return nil if user_code.empty?
51
+
52
+ authorization = DeviceVerification.find_pending(user_code: user_code)
53
+ if authorization.nil?
54
+ record_failed_attempt
55
+ @error = CODE_NOT_RECOGNISED
56
+ end
57
+ authorization
58
+ end
59
+
60
+ def render_code_not_recognised
61
+ record_failed_attempt
62
+ @authorization = nil
63
+ @error = CODE_NOT_RECOGNISED
64
+ render :show, status: :unprocessable_content
65
+ end
66
+
67
+ def decide(decision)
68
+ case decision
69
+ when 'approve'
70
+ DeviceVerification.approve(user_code: @user_code, user_id: @identity.user_id, role: @identity.role)
71
+ :approved
72
+ when 'deny'
73
+ DeviceVerification.deny(user_code: @user_code)
74
+ :denied
75
+ end
76
+ end
77
+
78
+ def record_failed_attempt
79
+ session[:kiosk_verify_attempts] = session[:kiosk_verify_attempts].to_i + 1
80
+ end
81
+
82
+ def attempts_capped?
83
+ session[:kiosk_verify_attempts].to_i >= MAX_CODE_ATTEMPTS
84
+ end
85
+
86
+ def render_attempts_capped
87
+ render plain: 'Too many code attempts — sign out and back in to retry.',
88
+ status: :too_many_requests
89
+ end
90
+
91
+ # The key's RFC 7638 thumbprint, the `kid` the assistant prints.
92
+ def key_fingerprint(authorization)
93
+ return nil if authorization&.public_key_pem.nil?
94
+
95
+ SigningKey.from_pem(authorization.public_key_pem).kid
96
+ rescue StandardError
97
+ '(unreadable key)'
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_controller'
4
+
5
+ module Kiosk
6
+ module Server
7
+ # The public discovery documents at the host root, all rendered by
8
+ # {WellKnown} for `request.base_url`.
9
+ class DiscoveryController < ::ActionController::API
10
+ # Rails stamps `Vary: Accept` on a negotiated render; these documents are
11
+ # one answer for everybody, so a shared cache must keep one copy.
12
+ after_action :drop_vary
13
+
14
+ def agents_txt
15
+ allow_cors
16
+ render plain: WellKnown.agents_txt(base_url: request.base_url),
17
+ content_type: 'text/plain; charset=utf-8'
18
+ end
19
+
20
+ def agents_json
21
+ allow_cors
22
+ short_ttl
23
+ render json: WellKnown.agents_json(base_url: request.base_url)
24
+ end
25
+
26
+ def agent_configuration
27
+ render json: WellKnown.agent_configuration(base_url: request.base_url)
28
+ end
29
+
30
+ def kiosk_json
31
+ short_ttl
32
+ render json: WellKnown.build_json(base_url: request.base_url)
33
+ end
34
+
35
+ def api_catalog
36
+ allow_cors
37
+ short_ttl
38
+ render json: WellKnown.api_catalog(base_url: request.base_url),
39
+ content_type: 'application/linkset+json; ' \
40
+ 'profile="https://www.rfc-editor.org/info/rfc9727"'
41
+ end
42
+
43
+ def auth_md
44
+ allow_cors
45
+ render plain: WellKnown.auth_md(base_url: request.base_url),
46
+ content_type: 'text/markdown; charset=utf-8'
47
+ end
48
+
49
+ private
50
+
51
+ # agents.txt v1.0 requires `Access-Control-Allow-Origin: *`.
52
+ def allow_cors
53
+ response.set_header('Access-Control-Allow-Origin', '*')
54
+ end
55
+
56
+ # These carry the `?v=<digest>` schema link, so they must expire soon after a deploy.
57
+ def short_ttl
58
+ response.set_header('Cache-Control', Headers::PUBLIC_SHORT)
59
+ end
60
+
61
+ def drop_vary
62
+ response.headers.delete('Vary')
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_controller'
4
+
5
+ module Kiosk
6
+ module Server
7
+ # GET <endpoint>/.well-known/jwks.json — the public signing key.
8
+ class JwksController < ::ActionController::API
9
+ def show
10
+ Kiosk::Server::Headers.add_to(response.headers)
11
+ render json: Kiosk::Server::Jwks.build(keys: [Kiosk.configuration.signing_key])
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_controller'
4
+ require 'json'
5
+
6
+ module Kiosk
7
+ module Server
8
+ # POST <endpoint>/agents/kyc `{kyc_jws}`: verifies an assistant-submitted
9
+ # attestation and replaces its principal's grants ({Kyc.grant!}). Refusals
10
+ # are problem documents: 400 body, 401 token, 403 attestation.
11
+ class KycAttestationController < ::ActionController::API
12
+ def create
13
+ identity = authenticate!
14
+ body = parse_body!('POST <endpoint>/agents/kyc')
15
+ raw_jws = body[:kyc_jws] or raise Errors.missing_field('kyc_jws')
16
+
17
+ render_granted(grant!(identity, raw_jws))
18
+ rescue Errors::Base => e
19
+ render_error(e)
20
+ end
21
+
22
+ private
23
+
24
+ # Verifies the attestation for this principal and stores its grants.
25
+ def grant!(identity, raw_jws)
26
+ attributes = KycVerifier.verify(raw_jws: raw_jws, subject: identity.user_id)[:attributes]
27
+ Kyc.grant!(identity.user_id, attributes)
28
+ attributes
29
+ end
30
+
31
+ def render_granted(attributes)
32
+ Kiosk::Server::Headers.add_to(response.headers)
33
+ render json: { kyc_verified: true, attributes: attributes }, status: :ok
34
+ end
35
+
36
+ # A JSON object held to the shape §17 publishes for `exchange`, else 400.
37
+ def parse_body!(exchange)
38
+ raw = request.raw_post
39
+ raise Errors::BadRequest, 'request body must be a JSON object' if raw.blank?
40
+
41
+ parsed = JSON.parse(raw, symbolize_names: true)
42
+ raise Errors::BadRequest, 'request body must be a JSON object' unless parsed.is_a?(Hash)
43
+
44
+ RequestValidation.validate_body!(parsed, exchange: exchange)
45
+ parsed
46
+ rescue JSON::ParserError
47
+ raise Errors.malformed_json
48
+ end
49
+
50
+ # Only an assistant submits an attestation, so only the agent IdP answers.
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
56
+ end
57
+
58
+ def render_error(err)
59
+ Kiosk::Server::Headers.add_to(response.headers)
60
+ Kiosk::Server::Headers.add_cache_policy(
61
+ response.headers, status: err.http_status
62
+ )
63
+ err.response_headers.each { |name, value| response.set_header(name, value) }
64
+ render json: err.to_problem, status: err.http_status,
65
+ content_type: Errors::PROBLEM_CONTENT_TYPE
66
+ end
67
+ end
68
+ end
69
+ end