kiosk-server 0.5.20 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -0
- data/README.md +63 -41
- data/app/controllers/kiosk/server/auth_controller.rb +18 -62
- data/app/controllers/kiosk/server/discovery_controller.rb +25 -3
- data/app/controllers/kiosk/server/jwks_controller.rb +2 -2
- data/app/controllers/kiosk/server/kyc_attestation_controller.rb +1 -4
- data/app/controllers/kiosk/server/oauth_device_authorization_controller.rb +11 -29
- data/app/controllers/kiosk/server/oauth_registration_controller.rb +34 -0
- data/app/controllers/kiosk/server/oauth_token_controller.rb +33 -32
- data/app/models/kiosk/checkout_mandate.rb +22 -0
- data/app/models/kiosk/settlement.rb +1 -1
- data/lib/generators/kiosk/install/install_generator.rb +0 -5
- data/lib/generators/kiosk/install/templates/create_kiosk_mandates.rb.tt +4 -5
- data/lib/kiosk/payable.rb +19 -25
- data/lib/kiosk/server/account_binding.rb +19 -58
- data/lib/kiosk/server/agent_identity_providers/default_agent_idp.rb +18 -16
- data/lib/kiosk/server/agent_rotation.rb +62 -0
- data/lib/kiosk/server/assistant_key.rb +86 -0
- data/lib/kiosk/server/auth_toll.rb +1 -1
- data/lib/kiosk/server/binding_module_gate.rb +1 -1
- data/lib/kiosk/server/checkout.rb +57 -0
- data/lib/kiosk/server/client.rb +61 -0
- data/lib/kiosk/server/client_assertion.rb +153 -0
- data/lib/kiosk/server/client_registration.rb +94 -0
- data/lib/kiosk/server/configuration_extension.rb +56 -44
- data/lib/kiosk/server/device_code_grant.rb +38 -69
- data/lib/kiosk/server/dpop.rb +121 -0
- data/lib/kiosk/server/eddsa.rb +38 -0
- data/lib/kiosk/server/engine.rb +14 -11
- data/lib/kiosk/server/errors.rb +7 -0
- data/lib/kiosk/server/events_connection.rb +15 -5
- data/lib/kiosk/server/executor/repeat.rb +13 -12
- data/lib/kiosk/server/executor/spending_cap.rb +25 -26
- data/lib/kiosk/server/executor.rb +82 -101
- data/lib/kiosk/server/identity_resolution.rb +12 -3
- data/lib/kiosk/server/jwk.rb +83 -0
- data/lib/kiosk/server/jwks.rb +6 -0
- data/lib/kiosk/server/jwt_issuer.rb +7 -13
- data/lib/kiosk/server/kyc_verifier.rb +2 -2
- data/lib/kiosk/server/link_code.rb +9 -18
- data/lib/kiosk/server/linked_assistants.rb +1 -1
- data/lib/kiosk/server/mandate_verifier.rb +97 -139
- data/lib/kiosk/server/oauth_answers.rb +30 -0
- data/lib/kiosk/server/oauth_error.rb +30 -0
- data/lib/kiosk/server/open_api.rb +22 -20
- data/lib/kiosk/server/payment_claim.rb +44 -75
- data/lib/kiosk/server/receipts.rb +29 -0
- data/lib/kiosk/server/request_validation.rb +1 -2
- data/lib/kiosk/server/revocation_stores.rb +8 -0
- data/lib/kiosk/server/schema_definitions.rb +33 -64
- data/lib/kiosk/server/schemas/auth.schema.json +368 -57
- data/lib/kiosk/server/schemas/binding.schema.json +23 -58
- data/lib/kiosk/server/schemas/kyc.schema.json +19 -76
- data/lib/kiosk/server/schemas/mandates.schema.json +122 -38
- data/lib/kiosk/server/sd_jwt.rb +104 -0
- data/lib/kiosk/server/signing_key.rb +18 -52
- data/lib/kiosk/server/verb_wire.rb +1 -1
- data/lib/kiosk/server/version.rb +1 -1
- data/lib/kiosk/server/well_known.rb +268 -204
- data/lib/kiosk/server/wire_rendering.rb +1 -1
- data/lib/kiosk/server.rb +2 -0
- data/lib/kiosk/skill_link.rb +6 -2
- data/listen.py +38 -8
- metadata +26 -17
- data/app/models/kiosk/cart_mandate.rb +0 -21
- data/lib/generators/kiosk/install/templates/create_kiosk_auth_challenges.rb.tt +0 -16
- data/lib/kiosk/server/agent_login.rb +0 -49
- data/lib/kiosk/server/agent_registration.rb +0 -88
- data/lib/kiosk/server/auth_challenge.rb +0 -33
- data/lib/kiosk/server/auth_challenge_stores.rb +0 -74
- data/lib/kiosk/server/pop_verifier.rb +0 -87
|
@@ -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
|
|
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
|
|
9
|
-
# .
|
|
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 =
|
|
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
|
|
34
|
-
def start(
|
|
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:
|
|
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
|
-
#
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
130
|
-
|
|
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
|
data/lib/kiosk/server/engine.rb
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
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
|
|
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
|
|
73
|
-
'with "
|
|
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
|
-
|
|
169
|
-
post '
|
|
170
|
-
post '
|
|
171
|
-
post 'auth/
|
|
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
|
|
data/lib/kiosk/server/errors.rb
CHANGED
|
@@ -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
|
|
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
|
|
162
|
-
|
|
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
|
|
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
|
-
#
|
|
11
|
-
# declined: the
|
|
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.
|
|
18
|
-
chain.repeat
|
|
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.
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
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) {
|
|
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
|
|
12
|
-
# under a per-assistant lock, so two concurrent pays see each
|
|
13
|
-
# second counts the first's
|
|
14
|
-
# is a repeat and adds nothing.
|
|
15
|
-
def enforce_spending_cap!(
|
|
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
|
|
20
|
+
return if checkout_recorded?(checkout)
|
|
21
21
|
|
|
22
|
-
refuse_other_currency!(cap.fetch(:currency),
|
|
23
|
-
refuse_over_cap!(cap.fetch(:amount_cents).to_i,
|
|
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,
|
|
26
|
+
def refuse_other_currency!(cap_currency, checkout)
|
|
27
27
|
cap_code = MandateVerifier.canonical_currency(cap_currency)
|
|
28
|
-
|
|
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 #{
|
|
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,
|
|
34
|
+
def refuse_over_cap!(cap, checkout)
|
|
36
35
|
window_days = Kiosk.configuration.spending_cap_window_days
|
|
37
|
-
spent = spent_in_window(
|
|
38
|
-
return if spent +
|
|
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,
|
|
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(
|
|
46
|
-
binds = spent_binds(identity.agent_id,
|
|
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,
|
|
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 #{
|
|
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
|
-
#
|
|
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}.
|
|
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.
|
|
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
|
|
90
|
-
sql = "SELECT 1 FROM #{Kiosk.configuration.schema}.
|
|
91
|
-
connection.exec_query(sql, 'Kiosk
|
|
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.
|