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.
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
data/lib/cent/version.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cent
4
- VERSION = '3.0.0'
4
+ # The gem's version: a release's tag, without its "v".
5
+ VERSION = "5.0.0"
5
6
  end