cent 3.0.0 → 5.0.0
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 +93 -0
- data/MIGRATING.md +305 -0
- data/README.md +695 -169
- data/lib/cent/auto_batch.rb +305 -0
- data/lib/cent/batch.rb +209 -0
- data/lib/cent/broadcast.rb +117 -0
- data/lib/cent/client.rb +604 -230
- data/lib/cent/command.rb +64 -0
- data/lib/cent/endpoints.rb +191 -0
- data/lib/cent/errors.rb +392 -0
- data/lib/cent/generated/methods.rb +4226 -0
- data/lib/cent/generated/proxy_types.rb +1834 -0
- data/lib/cent/generated/types.rb +2844 -0
- data/lib/cent/handler.rb +179 -0
- data/lib/cent/http.rb +236 -32
- data/lib/cent/message.rb +52 -0
- data/lib/cent/pages.rb +165 -0
- data/lib/cent/payload.rb +93 -0
- data/lib/cent/proxy.rb +254 -0
- data/lib/cent/reply.rb +109 -0
- data/lib/cent/retry.rb +53 -0
- data/lib/cent/shared_poll.rb +61 -0
- data/lib/cent/token.rb +560 -0
- data/lib/cent/version.rb +2 -1
- data/lib/cent/wire.rb +222 -0
- data/lib/cent.rb +33 -5
- metadata +38 -56
- data/.github/workflows/main.yml +0 -20
- data/.github/workflows/release.yml +0 -37
- data/.gitignore +0 -20
- data/.rspec +0 -3
- data/.rubocop.yml +0 -13
- data/Gemfile +0 -17
- data/Rakefile +0 -12
- data/bin/console +0 -15
- data/bin/setup +0 -8
- data/cent.gemspec +0 -35
- data/lib/cent/error.rb +0 -9
- data/lib/cent/notary.rb +0 -96
data/lib/cent/token.rb
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Cent
|
|
6
|
+
# Tokens: connection and subscription tokens for client SDKs, the JSON Web
|
|
7
|
+
# Key Set Centrifugo verifies them with, and Centrifugo PRO's server API
|
|
8
|
+
# tokens. See https://centrifugal.dev/docs/server/authentication.
|
|
9
|
+
#
|
|
10
|
+
# key = Cent::Token::Key.hmac(ENV.fetch("CENTRIFUGO_TOKEN_SECRET")) # client.token.hmac_secret_key
|
|
11
|
+
# Cent::Token.connection(key, user: "42", expires_at: Time.now + 15 * 60)
|
|
12
|
+
#
|
|
13
|
+
# Every expiry is chosen explicitly: a Time, or +:never+.
|
|
14
|
+
module Token
|
|
15
|
+
EC_ALGS = {
|
|
16
|
+
"prime256v1" => ["ES256", "P-256", 32, "SHA256"],
|
|
17
|
+
"secp384r1" => ["ES384", "P-384", 48, "SHA384"],
|
|
18
|
+
"secp521r1" => ["ES512", "P-521", 66, "SHA512"]
|
|
19
|
+
}.freeze
|
|
20
|
+
private_constant :EC_ALGS
|
|
21
|
+
|
|
22
|
+
# The smallest RSA key signing tokens.
|
|
23
|
+
MIN_RSA_BITS = 2048
|
|
24
|
+
private_constant :MIN_RSA_BITS
|
|
25
|
+
|
|
26
|
+
# The error of a token or key which cannot be made: the message says why.
|
|
27
|
+
class Error < Cent::Error; end
|
|
28
|
+
|
|
29
|
+
# A key signing tokens, matching the one Centrifugo verifies with. Make one
|
|
30
|
+
# with {.hmac}, {.rsa}, {.ecdsa}, {.ed25519} or {.from_pem}.
|
|
31
|
+
class Key
|
|
32
|
+
# @return [String] the JWS algorithm: HS256, RS256, ES256, ES384, ES512 or
|
|
33
|
+
# EdDSA
|
|
34
|
+
attr_reader :alg
|
|
35
|
+
# @return [String, nil] the key ID written as the tokens' "kid", if set
|
|
36
|
+
attr_reader :kid
|
|
37
|
+
|
|
38
|
+
# @api private
|
|
39
|
+
attr_reader :jwk
|
|
40
|
+
|
|
41
|
+
# @api private
|
|
42
|
+
def initialize(alg, jwk, kid = nil, &sign)
|
|
43
|
+
@alg = alg
|
|
44
|
+
@jwk = jwk
|
|
45
|
+
@kid = kid
|
|
46
|
+
@sign = sign
|
|
47
|
+
freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The key, with +kid+ as the "kid" of the tokens it signs: the ID of its
|
|
51
|
+
# public key in the JWKS Centrifugo verifies with. Never give the ID of a
|
|
52
|
+
# key to another: Centrifugo caches keys by ID, and would refuse the other
|
|
53
|
+
# key's tokens until its cache expires.
|
|
54
|
+
#
|
|
55
|
+
# @param kid [String]
|
|
56
|
+
# @return [Key]
|
|
57
|
+
def with_key_id(kid)
|
|
58
|
+
raise Error, "a key ID must be a non-empty String" unless kid.is_a?(String) && !kid.empty?
|
|
59
|
+
|
|
60
|
+
Key.new(@alg, @jwk, kid, &@sign)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @api private
|
|
64
|
+
def sign(data) = @sign.call(data)
|
|
65
|
+
|
|
66
|
+
# @return [String] the algorithm and key ID, never the key
|
|
67
|
+
def inspect = "#<Cent::Token::Key #{alg}#{" kid=#{kid.inspect}" if kid}>"
|
|
68
|
+
|
|
69
|
+
# A key signing with HS256 and +secret+: Centrifugo's
|
|
70
|
+
# client.token.hmac_secret_key.
|
|
71
|
+
#
|
|
72
|
+
# @param secret [String]
|
|
73
|
+
# @return [Key]
|
|
74
|
+
def self.hmac(secret)
|
|
75
|
+
raise Error, "the HMAC secret must be a non-empty String" unless secret.is_a?(String) && !secret.empty?
|
|
76
|
+
|
|
77
|
+
s = secret.b.freeze
|
|
78
|
+
new("HS256", nil) { OpenSSL::HMAC.digest("SHA256", s, it) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A key signing with RS256, whose public key is Centrifugo's
|
|
82
|
+
# client.token.rsa_public_key. A key under 2048 bits is refused, as an
|
|
83
|
+
# ECDSA key on another curve is: 2048 is the floor of NIST SP 800-131A.
|
|
84
|
+
#
|
|
85
|
+
# @param pkey [OpenSSL::PKey::RSA] a private key of 2048 bits or more
|
|
86
|
+
# @return [Key]
|
|
87
|
+
def self.rsa(pkey)
|
|
88
|
+
unless pkey.is_a?(OpenSSL::PKey::RSA) && pkey.private?
|
|
89
|
+
raise Error,
|
|
90
|
+
"Key.rsa takes an OpenSSL::PKey::RSA private key"
|
|
91
|
+
end
|
|
92
|
+
bits = pkey.n.num_bits
|
|
93
|
+
raise Error, "RSA key of #{bits} bits: want #{MIN_RSA_BITS} or more" if bits < MIN_RSA_BITS
|
|
94
|
+
|
|
95
|
+
jwk = { "kty" => "RSA", "n" => Token.b64(pkey.n.to_s(2)), "e" => Token.b64(pkey.e.to_s(2)) }
|
|
96
|
+
new("RS256", jwk) { pkey.sign("SHA256", it) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A key signing with ES256, ES384 or ES512 - for a P-256, P-384 or P-521
|
|
100
|
+
# key - whose public key is Centrifugo's client.token.ecdsa_public_key.
|
|
101
|
+
#
|
|
102
|
+
# @param pkey [OpenSSL::PKey::EC] a private key
|
|
103
|
+
# @return [Key]
|
|
104
|
+
def self.ecdsa(pkey)
|
|
105
|
+
unless pkey.is_a?(OpenSSL::PKey::EC) && pkey.private?
|
|
106
|
+
raise Error,
|
|
107
|
+
"Key.ecdsa takes an OpenSSL::PKey::EC private key"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
curve = pkey.group.curve_name
|
|
111
|
+
alg, crv, size, digest = EC_ALGS[curve]
|
|
112
|
+
raise Error, "ECDSA curve #{curve}: want P-256, P-384 or P-521" unless alg
|
|
113
|
+
|
|
114
|
+
point = pkey.public_key.to_bn(:uncompressed).to_s(2)
|
|
115
|
+
jwk = { "kty" => "EC", "crv" => crv, "x" => Token.b64(point[1, size]), "y" => Token.b64(point[1 + size, size]) }
|
|
116
|
+
new(alg, jwk) do |data|
|
|
117
|
+
# JWS wants r and s as fixed-size big-endian halves, not DER.
|
|
118
|
+
r, s = OpenSSL::ASN1.decode(pkey.sign(digest, data)).value.map { it.value.to_s(2).rjust(size, "\0") }
|
|
119
|
+
r + s
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A key signing with EdDSA, whose public key is Centrifugo's
|
|
124
|
+
# client.token.ed25519_public_key (Centrifugo v6.10.2+), or is published
|
|
125
|
+
# in its JWKS with {Token.jwks}.
|
|
126
|
+
#
|
|
127
|
+
# @param pkey [OpenSSL::PKey::PKey] an Ed25519 private key
|
|
128
|
+
# @return [Key]
|
|
129
|
+
def self.ed25519(pkey)
|
|
130
|
+
unless pkey.is_a?(OpenSSL::PKey::PKey) && pkey.oid == "ED25519" && private?(pkey)
|
|
131
|
+
raise Error, "Key.ed25519 takes an Ed25519 private key: OpenSSL::PKey.generate_key(\"ED25519\"), say"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
jwk = { "kty" => "OKP", "crv" => "Ed25519", "x" => Token.b64(pkey.raw_public_key) }
|
|
135
|
+
new("EdDSA", jwk) { pkey.sign(nil, it) }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The key of a PEM-encoded private key - RSA, ECDSA or Ed25519 - as
|
|
139
|
+
# {.rsa}, {.ecdsa} or {.ed25519} make it.
|
|
140
|
+
#
|
|
141
|
+
# @param pem [String]
|
|
142
|
+
# @param password [String, nil]
|
|
143
|
+
# @return [Key]
|
|
144
|
+
def self.from_pem(pem, password = nil)
|
|
145
|
+
pkey = OpenSSL::PKey.read(pem, password)
|
|
146
|
+
case pkey
|
|
147
|
+
when OpenSSL::PKey::RSA then rsa(pkey)
|
|
148
|
+
when OpenSSL::PKey::EC then ecdsa(pkey)
|
|
149
|
+
else
|
|
150
|
+
raise Error, "unsupported private key type #{pkey.oid}" unless pkey.oid == "ED25519"
|
|
151
|
+
|
|
152
|
+
ed25519(pkey)
|
|
153
|
+
end
|
|
154
|
+
rescue OpenSSL::PKey::PKeyError => e
|
|
155
|
+
raise Error, "not a PEM private key: #{e.message}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.private?(pkey)
|
|
159
|
+
pkey.raw_private_key
|
|
160
|
+
true
|
|
161
|
+
rescue OpenSSL::PKey::PKeyError
|
|
162
|
+
false
|
|
163
|
+
end
|
|
164
|
+
private_class_method :private?
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The options of a server-side subscription in a connection token's +subs+.
|
|
168
|
+
#
|
|
169
|
+
# @!attribute [r] info
|
|
170
|
+
# @return [Object] channel-specific information about the connection
|
|
171
|
+
# @!attribute [r] b64info
|
|
172
|
+
# @return [String, nil] binary info in base64, instead of info
|
|
173
|
+
# @!attribute [r] data
|
|
174
|
+
# @return [Object] sent to the client with the subscription
|
|
175
|
+
# @!attribute [r] b64data
|
|
176
|
+
# @return [String, nil] binary data in base64, instead of data
|
|
177
|
+
# @!attribute [r] override
|
|
178
|
+
# @return [SubscribeOptionOverride, Hash, nil] channel options of the
|
|
179
|
+
# namespace overridden for this subscription
|
|
180
|
+
# @!attribute [r] server_tags_filter
|
|
181
|
+
# @return [FilterNode, Hash, nil] only publications whose tags match it
|
|
182
|
+
# are delivered. Centrifugo PRO only.
|
|
183
|
+
class SubscribeOptions < Data.define(:info, :b64info, :data, :b64data, :override, :server_tags_filter)
|
|
184
|
+
def initialize(info: nil, b64info: nil, data: nil, b64data: nil, override: nil, server_tags_filter: nil) = super
|
|
185
|
+
|
|
186
|
+
# @api private
|
|
187
|
+
def to_wire(where = "")
|
|
188
|
+
w = {}
|
|
189
|
+
Wire.put(w, "info", info, where, :json)
|
|
190
|
+
Wire.put(w, "b64info", b64info, where, :str)
|
|
191
|
+
Wire.put(w, "data", data, where, :json)
|
|
192
|
+
Wire.put(w, "b64data", b64data, where, :str)
|
|
193
|
+
Wire.put(w, "override", override, where, SubscribeOptionOverride)
|
|
194
|
+
Wire.put(w, "server_tags_filter", server_tags_filter, where, FilterNode)
|
|
195
|
+
w
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Operations granted in channels: a connection may do an operation in a
|
|
200
|
+
# channel when any of its capabilities allows the operation and matches the
|
|
201
|
+
# channel. Centrifugo PRO only.
|
|
202
|
+
#
|
|
203
|
+
# @!attribute [r] channels
|
|
204
|
+
# @return [Array<String>] the channels, or channel patterns, it applies to
|
|
205
|
+
# @!attribute [r] allow
|
|
206
|
+
# @return [Array<String>] the operations: "sub" (subscribe), "pub"
|
|
207
|
+
# (publish), "hst" (history), "prs" (presence)
|
|
208
|
+
# @!attribute [r] match
|
|
209
|
+
# @return [String, nil] how channels are matched: nil for exact names,
|
|
210
|
+
# "wildcard" or "regex"
|
|
211
|
+
class ChannelsCapability < Data.define(:channels, :allow, :match)
|
|
212
|
+
def initialize(channels: [], allow: [], match: nil) = super
|
|
213
|
+
|
|
214
|
+
# @api private
|
|
215
|
+
def to_wire(where = "")
|
|
216
|
+
w = {}
|
|
217
|
+
Wire.put(w, "channels", channels, where, :str, :list)
|
|
218
|
+
Wire.put(w, "allow", allow, where, :str, :list)
|
|
219
|
+
Wire.put(w, "match", match, where, :str)
|
|
220
|
+
w
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
module_function
|
|
225
|
+
|
|
226
|
+
# A connection token, signed with +key+. See
|
|
227
|
+
# https://centrifugal.dev/docs/server/authentication.
|
|
228
|
+
#
|
|
229
|
+
# @param key [Key]
|
|
230
|
+
# @param expires_at [Time, :never] until when the token is accepted: the
|
|
231
|
+
# "exp" claim. +:never+ for a token accepted forever - and forever once
|
|
232
|
+
# leaked, for a trusted service's own connection, say. The connection
|
|
233
|
+
# expires then too, unless connection_expires_at says otherwise:
|
|
234
|
+
# Centrifugo asks the client for a new token, and closes the connection
|
|
235
|
+
# without one.
|
|
236
|
+
# @param user [String] the ID of the connection's user: the "sub" claim.
|
|
237
|
+
# Empty for an anonymous connection.
|
|
238
|
+
# @param connection_expires_at [Time, :never, nil] when set, when the
|
|
239
|
+
# connection expires instead of at expires_at: the "expire_at" claim.
|
|
240
|
+
# +:never+ makes the connection never expire - Centrifugo never asks for a
|
|
241
|
+
# new token - while expires_at still bounds when the token is accepted, on
|
|
242
|
+
# reconnects too. Most applications leave it unset.
|
|
243
|
+
# @param issued_at [Time, nil] the "iat" claim; nil for now
|
|
244
|
+
# @param id [String, nil] the "jti" claim, for Centrifugo PRO's token
|
|
245
|
+
# revocation
|
|
246
|
+
# @param audience [String, nil] the "aud" claim: client.token.audience
|
|
247
|
+
# @param issuer [String, nil] the "iss" claim: client.token.issuer
|
|
248
|
+
# @param info [Object] information about the connection: in presence, join
|
|
249
|
+
# and leave events, and the client's publications
|
|
250
|
+
# @param b64info [String, nil] binary info in base64, instead of info
|
|
251
|
+
# @param channels [Array<String>] server-side channels to subscribe the
|
|
252
|
+
# connection to
|
|
253
|
+
# @param subs [Hash{String => SubscribeOptions, Hash}] server-side channels
|
|
254
|
+
# to subscribe the connection to, each with its options. Set channels or
|
|
255
|
+
# subs, not both: Centrifugo ignores channels when subs is set.
|
|
256
|
+
# @param meta [Object] custom data attached to the connection. Not visible
|
|
257
|
+
# to other clients - Centrifugo never sends it - but the token carries it,
|
|
258
|
+
# visible to the user owning the token: put nothing in it that user must
|
|
259
|
+
# not see.
|
|
260
|
+
# @param caps [Array<ChannelsCapability, Hash>] capabilities granting the
|
|
261
|
+
# connection operations in channels. Centrifugo PRO only.
|
|
262
|
+
# @param labels [Hash{String => String}] the connection's client labels:
|
|
263
|
+
# for metrics, targeted server API calls and CEL expressions. Centrifugo
|
|
264
|
+
# PRO only.
|
|
265
|
+
# @param profile [String, nil] the dictionary compression profile of the
|
|
266
|
+
# connection, overriding the one its client declared. Centrifugo PRO only.
|
|
267
|
+
# @return [String]
|
|
268
|
+
# @raise [Error, InvalidRequestError]
|
|
269
|
+
def connection(key, expires_at:, user: "", connection_expires_at: nil, issued_at: nil, id: nil, audience: nil,
|
|
270
|
+
issuer: nil, info: nil, b64info: nil, channels: [], subs: {}, meta: nil, caps: [], labels: {},
|
|
271
|
+
profile: nil)
|
|
272
|
+
exp = expiry(expires_at, "expires_at")
|
|
273
|
+
unless channels.nil? || channels.empty? || subs.nil? || subs.empty?
|
|
274
|
+
raise Error, "set channels or subs, not both: Centrifugo ignores channels when subs is set"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
w = registered(user, exp, issued_at, id, audience, issuer)
|
|
278
|
+
at = session_expiry(connection_expires_at, "connection_expires_at")
|
|
279
|
+
w["expire_at"] = at unless at.nil?
|
|
280
|
+
claims(w) do
|
|
281
|
+
Wire.put(w, "info", info, "", :json)
|
|
282
|
+
Wire.put(w, "b64info", b64info, "", :str)
|
|
283
|
+
Wire.put(w, "channels", channels, "", :str, :list)
|
|
284
|
+
Wire.put(w, "subs", subs, "", SubscribeOptions, :map)
|
|
285
|
+
Wire.put(w, "meta", meta, "", :json)
|
|
286
|
+
Wire.put(w, "caps", caps, "", ChannelsCapability, :list)
|
|
287
|
+
Wire.put(w, "labels", labels, "", :str, :map)
|
|
288
|
+
Wire.put(w, "profile", profile, "", :str)
|
|
289
|
+
end
|
|
290
|
+
sign(key, w)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# A subscription token, signed with +key+. See
|
|
294
|
+
# https://centrifugal.dev/docs/server/channel_token_auth.
|
|
295
|
+
#
|
|
296
|
+
# @param key [Key]
|
|
297
|
+
# @param channel [String] the channel the token allows to subscribe to
|
|
298
|
+
# @param expires_at [Time, :never] until when the token is accepted: the
|
|
299
|
+
# "exp" claim. The subscription expires then too, unless
|
|
300
|
+
# subscription_expires_at says otherwise.
|
|
301
|
+
# @param user [String] the ID of the connection's user: the "sub" claim
|
|
302
|
+
# @param subscription_expires_at [Time, :never, nil] when set, when the
|
|
303
|
+
# subscription expires instead of at expires_at: the "expire_at" claim.
|
|
304
|
+
# +:never+ makes it never expire, while expires_at still bounds when the
|
|
305
|
+
# token is accepted: with a short expires_at, a token which subscribes
|
|
306
|
+
# once.
|
|
307
|
+
# @param issued_at [Time, nil] the "iat" claim; nil for now
|
|
308
|
+
# @param id [String, nil] the "jti" claim, for Centrifugo PRO's token
|
|
309
|
+
# revocation
|
|
310
|
+
# @param audience [String, nil] the "aud" claim
|
|
311
|
+
# @param issuer [String, nil] the "iss" claim
|
|
312
|
+
# @param info [Object] channel-specific information about the connection
|
|
313
|
+
# @param b64info [String, nil] binary info in base64, instead of info
|
|
314
|
+
# @param data [Object] sent to the client in the subscribe reply
|
|
315
|
+
# @param b64data [String, nil] binary data in base64, instead of data
|
|
316
|
+
# @param override [SubscribeOptionOverride, Hash, nil] channel options of
|
|
317
|
+
# the namespace overridden for this subscription
|
|
318
|
+
# @param allow [Array<String>] capabilities in the channel besides
|
|
319
|
+
# subscribing: "pub", "hst", "prs". Centrifugo PRO only.
|
|
320
|
+
# @param server_tags_filter [FilterNode, Hash, nil] only publications whose
|
|
321
|
+
# tags match it are delivered. Centrifugo PRO only.
|
|
322
|
+
# @return [String]
|
|
323
|
+
# @raise [Error, InvalidRequestError]
|
|
324
|
+
def subscription(key, channel:, expires_at:, user: "", subscription_expires_at: nil, issued_at: nil, id: nil,
|
|
325
|
+
audience: nil, issuer: nil, info: nil, b64info: nil, data: nil, b64data: nil, override: nil,
|
|
326
|
+
allow: [], server_tags_filter: nil)
|
|
327
|
+
raise Error, "a subscription token needs a channel" unless channel.is_a?(String) && !channel.empty?
|
|
328
|
+
|
|
329
|
+
w = registered(user, expiry(expires_at, "expires_at"), issued_at, id, audience, issuer)
|
|
330
|
+
w["channel"] = channel
|
|
331
|
+
at = session_expiry(subscription_expires_at, "subscription_expires_at")
|
|
332
|
+
w["expire_at"] = at unless at.nil?
|
|
333
|
+
claims(w) do
|
|
334
|
+
Wire.put(w, "info", info, "", :json)
|
|
335
|
+
Wire.put(w, "b64info", b64info, "", :str)
|
|
336
|
+
Wire.put(w, "data", data, "", :json)
|
|
337
|
+
Wire.put(w, "b64data", b64data, "", :str)
|
|
338
|
+
Wire.put(w, "override", override, "", SubscribeOptionOverride)
|
|
339
|
+
Wire.put(w, "allow", allow, "", :str, :list)
|
|
340
|
+
Wire.put(w, "server_tags_filter", server_tags_filter, "", FilterNode)
|
|
341
|
+
end
|
|
342
|
+
sign(key, w)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# A token for Centrifugo PRO's server API, signed with +key+ - an asymmetric
|
|
346
|
+
# key with an ID, published by {jwks}: Centrifugo PRO finds it by the
|
|
347
|
+
# "kid". See https://centrifugal.dev/docs/pro/server_api_enhancements.
|
|
348
|
+
#
|
|
349
|
+
# A token passing Centrifugo PRO's checks can call every API method, and it
|
|
350
|
+
# checks only what it is configured to: the signature by any key of the
|
|
351
|
+
# JWKS endpoint, and audience, issuer and scope only when http_api.jwks
|
|
352
|
+
# sets them. So the token must carry a claim no other token carries - above
|
|
353
|
+
# all the connection and subscription tokens users hold:
|
|
354
|
+
#
|
|
355
|
+
# - sign API tokens with keys of their own, published at their own JWKS
|
|
356
|
+
# endpoint - never the one of client.token.jwks_public_endpoint;
|
|
357
|
+
# - set audience, scope or both, to values only API tokens carry, and
|
|
358
|
+
# configure http_api.jwks.audience and scope to require them. api refuses
|
|
359
|
+
# claims with neither.
|
|
360
|
+
#
|
|
361
|
+
# See {ApiTokenSource} for one to send with every call.
|
|
362
|
+
#
|
|
363
|
+
# @param key [Key]
|
|
364
|
+
# @param expires_at [Time] when the token expires: best minutes ahead.
|
|
365
|
+
# Centrifugo PRO would accept a token without "exp" for as long as its key
|
|
366
|
+
# is published, so api never signs one.
|
|
367
|
+
# @param audience [String, nil] the "aud" claim: http_api.jwks.audience
|
|
368
|
+
# @param scope [Array<String>] the "scope" claim, its scopes joined by
|
|
369
|
+
# spaces: http_api.jwks.scope is one of them
|
|
370
|
+
# @param issuer [String, nil] the "iss" claim: http_api.jwks.issuer
|
|
371
|
+
# @param subject [String] the "sub" claim: who calls the API, for the
|
|
372
|
+
# application's own logs
|
|
373
|
+
# @param issued_at [Time, nil] the "iat" claim; nil for now
|
|
374
|
+
# @param id [String, nil] the "jti" claim
|
|
375
|
+
# @return [String]
|
|
376
|
+
# @raise [Error]
|
|
377
|
+
def api(key, expires_at:, audience: nil, scope: [], issuer: nil, subject: "", issued_at: nil, id: nil)
|
|
378
|
+
raise Error, "key must be a Cent::Token::Key" unless key.is_a?(Key)
|
|
379
|
+
if key.jwk.nil?
|
|
380
|
+
raise Error, "an API token needs an RSA, ECDSA or Ed25519 key: " \
|
|
381
|
+
"Centrifugo PRO verifies it with the public keys of its JWKS endpoint"
|
|
382
|
+
end
|
|
383
|
+
raise Error, "an API token needs a key ID, by which Centrifugo PRO finds the key: use with_key_id" unless key.kid
|
|
384
|
+
if (audience.nil? || audience.empty?) && scope.empty?
|
|
385
|
+
raise Error, "set audience or scope, required by http_api.jwks, " \
|
|
386
|
+
"so that client tokens signed with the same keys can't call the server API"
|
|
387
|
+
end
|
|
388
|
+
if expires_at.nil? || expires_at == :never
|
|
389
|
+
raise Error, "set expires_at: Centrifugo PRO accepts an API token without one while its key is published"
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
scope.each do |s|
|
|
393
|
+
raise Error, "a scope is empty or has a space" if !s.is_a?(String) || s.empty? || s.match?(/\s/)
|
|
394
|
+
end
|
|
395
|
+
w = registered(subject, unix(expires_at, "expires_at"), issued_at, id, audience, issuer)
|
|
396
|
+
w["scope"] = scope.join(" ") unless scope.empty?
|
|
397
|
+
sign(key, w)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# The public keys of +keys+ as a JSON Web Key Set, for Centrifugo's
|
|
401
|
+
# client.token.jwks_public_endpoint, which verifies tokens by their "kid".
|
|
402
|
+
# Each key needs an ID - see {Key#with_key_id} - unique in the set. HMAC
|
|
403
|
+
# keys have no public key to publish, and are refused, as is a set without
|
|
404
|
+
# keys. Serve it with {Endpoints::JWKS}.
|
|
405
|
+
#
|
|
406
|
+
# A set is how keys rotate without restarting Centrifugo: publish the new
|
|
407
|
+
# key next to the old one; once every instance of the application
|
|
408
|
+
# publishes it, sign with it - from a time set ahead, say; drop the old key
|
|
409
|
+
# once the tokens it signed have expired. Never give a key ID to another
|
|
410
|
+
# key: Centrifugo caches keys by ID.
|
|
411
|
+
#
|
|
412
|
+
# Publish the keys of client tokens and of Centrifugo PRO's server API
|
|
413
|
+
# tokens at separate endpoints: a token signed with any key of a set passes
|
|
414
|
+
# Centrifugo's signature check.
|
|
415
|
+
#
|
|
416
|
+
# @param keys [Array<Key>]
|
|
417
|
+
# @return [String] the JSON
|
|
418
|
+
# @raise [Error]
|
|
419
|
+
def jwks(*keys)
|
|
420
|
+
raise Error, "a JWKS without keys" if keys.empty?
|
|
421
|
+
|
|
422
|
+
seen = {}
|
|
423
|
+
out = keys.each_with_index.map do |k, i|
|
|
424
|
+
raise Error, "JWKS key ##{i} is not a Cent::Token::Key" unless k.is_a?(Key)
|
|
425
|
+
raise Error, "JWKS key ##{i} has no public key to publish: an HMAC key" if k.jwk.nil?
|
|
426
|
+
raise Error, "JWKS key ##{i} has no key ID: set one with with_key_id" unless k.kid
|
|
427
|
+
raise Error, "JWKS key ID #{k.kid.inspect} used twice" if seen[k.kid]
|
|
428
|
+
|
|
429
|
+
seen[k.kid] = true
|
|
430
|
+
{ "kty" => k.jwk["kty"], "kid" => k.kid, "use" => "sig", "alg" => k.alg, **k.jwk.except("kty") }
|
|
431
|
+
end
|
|
432
|
+
JSON.generate({ "keys" => out })
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# @api private
|
|
436
|
+
def b64(bytes) = [bytes].pack("m0").tr("+/", "-_").delete("=")
|
|
437
|
+
|
|
438
|
+
def unix(t, name)
|
|
439
|
+
t = t.to_time if !t.is_a?(Time) && t.respond_to?(:to_time)
|
|
440
|
+
raise Error, "#{name} must be a Time, not #{t.class}" unless t.is_a?(Time)
|
|
441
|
+
|
|
442
|
+
t.to_i
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# The "exp" claim of expires_at: nil to leave it out, for :never.
|
|
446
|
+
def expiry(t, name)
|
|
447
|
+
return nil if t == :never
|
|
448
|
+
raise Error, "#{name} is required: a Time, or :never for a token accepted forever" if t.nil?
|
|
449
|
+
|
|
450
|
+
unix(t, name)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# The "expire_at" claim: left out (nil), 0 for never, or a time.
|
|
454
|
+
def session_expiry(t, name)
|
|
455
|
+
return nil if t.nil?
|
|
456
|
+
return 0 if t == :never
|
|
457
|
+
|
|
458
|
+
unix(t, name)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# The RFC 7519 claims Centrifugo reads, in the order the spec's vectors
|
|
462
|
+
# use: sub, exp, iat, jti, aud, iss.
|
|
463
|
+
def registered(user, exp, issued_at, id, audience, issuer)
|
|
464
|
+
raise Error, "user must be a String" unless user.is_a?(String)
|
|
465
|
+
|
|
466
|
+
w = { "sub" => user }
|
|
467
|
+
w["exp"] = exp unless exp.nil?
|
|
468
|
+
w["iat"] = issued_at.nil? ? Time.now.to_i : unix(issued_at, "issued_at")
|
|
469
|
+
w["jti"] = id unless id.nil? || id.empty?
|
|
470
|
+
w["aud"] = audience unless audience.nil? || audience.empty?
|
|
471
|
+
w["iss"] = issuer unless issuer.nil? || issuer.empty?
|
|
472
|
+
w
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def claims(_w)
|
|
476
|
+
yield
|
|
477
|
+
rescue InvalidRequestError => e
|
|
478
|
+
raise InvalidRequestError, "token: #{e.message}"
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def sign(key, claims)
|
|
482
|
+
raise Error, "key must be a Cent::Token::Key" unless key.is_a?(Key)
|
|
483
|
+
|
|
484
|
+
header = { "alg" => key.alg, "typ" => "JWT" }
|
|
485
|
+
header["kid"] = key.kid if key.kid
|
|
486
|
+
# A payload which is not JSON fails here, not in Centrifugo.
|
|
487
|
+
signed = "#{b64(JSON.generate(header))}.#{b64(Client.encode(claims))}"
|
|
488
|
+
"#{signed}.#{b64(key.sign(signed))}"
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
private_class_method :unix, :expiry, :session_expiry, :registered, :claims, :sign
|
|
492
|
+
|
|
493
|
+
# Issues the tokens a Client sends to Centrifugo PRO: its {#token} is the
|
|
494
|
+
# client's bearer_token_func. It signs a token with {Token.api} and reuses
|
|
495
|
+
# it for half its lifetime, so a token sent has at least half of it left.
|
|
496
|
+
# Safe for concurrent use.
|
|
497
|
+
#
|
|
498
|
+
# source = Cent::Token::ApiTokenSource.new(key: api_key, # its own key, not the client tokens'
|
|
499
|
+
# audience: "centrifugo-api", scope: ["centrifugo:api"])
|
|
500
|
+
# client = Cent::Client.new(api_endpoint: "https://centrifugo.example.com/api",
|
|
501
|
+
# bearer_token_func: source.method(:token))
|
|
502
|
+
#
|
|
503
|
+
# +current_key+, when set, returns the key to sign each new token with
|
|
504
|
+
# instead of +key+ - the key active now, when keys rotate. A key must be
|
|
505
|
+
# published in the JWKS before it signs: Centrifugo PRO refuses a token
|
|
506
|
+
# whose key it can't find. The tokens are issued when needed and expire
|
|
507
|
+
# +ttl+ seconds later (300 by default).
|
|
508
|
+
class ApiTokenSource
|
|
509
|
+
# @param key [Key, nil]
|
|
510
|
+
# @param current_key [#call, nil]
|
|
511
|
+
# @param ttl [Numeric] seconds
|
|
512
|
+
# @param claims the claims of {Token.api} but expires_at and issued_at
|
|
513
|
+
# @raise [Error]
|
|
514
|
+
def initialize(key: nil, current_key: nil, ttl: 300, **claims)
|
|
515
|
+
raise Error, "ApiTokenSource takes key or current_key, one of them" if key.nil? == current_key.nil?
|
|
516
|
+
if claims.key?(:expires_at) || claims.key?(:issued_at)
|
|
517
|
+
raise Error, "ApiTokenSource sets expires_at and issued_at itself: leave them unset, and set ttl"
|
|
518
|
+
end
|
|
519
|
+
unless ttl.is_a?(Numeric) && ttl.positive?
|
|
520
|
+
raise Error,
|
|
521
|
+
"ApiTokenSource ttl must be a positive number of seconds"
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
unknown = claims.keys - %i[audience scope issuer subject id]
|
|
525
|
+
raise Error, "ApiTokenSource: unknown claim #{unknown.first}" unless unknown.empty?
|
|
526
|
+
|
|
527
|
+
@key = key
|
|
528
|
+
@current_key = current_key
|
|
529
|
+
@ttl = ttl.to_f
|
|
530
|
+
@claims = claims
|
|
531
|
+
@lock = Mutex.new
|
|
532
|
+
@token = nil
|
|
533
|
+
@refresh_at = 0.0
|
|
534
|
+
# A fixed key is checked now, by its first token, not on the first call.
|
|
535
|
+
token if key
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Without the token or the key.
|
|
539
|
+
#
|
|
540
|
+
# @return [String]
|
|
541
|
+
def inspect = "#<#{self.class} ttl=#{@ttl} claims=#{@claims.inspect}>"
|
|
542
|
+
|
|
543
|
+
# A token valid for at least half of ttl, signing a new one when the
|
|
544
|
+
# previous one has less left.
|
|
545
|
+
#
|
|
546
|
+
# @return [String]
|
|
547
|
+
def token
|
|
548
|
+
@lock.synchronize do
|
|
549
|
+
now = Time.now.to_f
|
|
550
|
+
return @token if @token && now < @refresh_at
|
|
551
|
+
|
|
552
|
+
key = @current_key ? @current_key.call : @key
|
|
553
|
+
@token = Token.api(key, **@claims, issued_at: Time.at(now.floor), expires_at: Time.at(now + @ttl))
|
|
554
|
+
@refresh_at = now + (@ttl / 2)
|
|
555
|
+
@token
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
end
|