clowk 0.9.1 → 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 28abd896caa982a35fa6efd1f1412ce35c65e16c097ae6bca4106f7947eb235b
4
- data.tar.gz: 6f34617088b87ac8c7e7a1b9792e772a62134e78726ee750ec4dc878e87f5cf7
3
+ metadata.gz: 4b724ef711942092b90d87857fea2d8d439f759813d9312cb8e20d30dcd7201e
4
+ data.tar.gz: 7c79da7ff63485cd70cc62fac3669a1e0f96041e52ac6782c8c229ffee28972a
5
5
  SHA512:
6
- metadata.gz: 9b35e25266da57b3c408ef66b3386d2fd85cc1c5aa6f0aafe9df0cac2b52e365b68dc79219f6f6857845043725e35b3e1c1939a37ee57d38206bc63fca7629d2
7
- data.tar.gz: afc0e6ed3b78bcc9f7caed60cbda3131ad3869e141ed999a8588e95525ea52f8f5bea32624c2982e2d76a40206618d707978a8830f235b1a906bdc85271aa93e
6
+ metadata.gz: c26df4690279d52da9f035e0636991a135d731b8472ce9071d65003507eb0272a80e7f909249f0e159c313f44fd6a4970d5568ea635517b9278abe1cfda84480
7
+ data.tar.gz: e8a253974a0b9dcb0610a47b3e0e26b9c24aca10799291b9abaa059399fa8b11a8ff68c1f870d11bbe6d97e715d61019994b2648a9d7b0a8fc5de690e2c36ed5
data/README.md CHANGED
@@ -274,30 +274,71 @@ Both ends — the broker said inactive, the ceiling passed — go through
274
274
 
275
275
  ### Where the token is kept
276
276
 
277
- Clowk writes the token to its own cookie, and — by default, as every version
278
- before 0.9 did — mirrors a copy into the app's Rails session.
279
-
280
- Both are cookies, so the setting names the owner rather than the mechanism:
277
+ Clowk writes the token to its own cookie. The setting decides one thing: whether
278
+ a copy is **also** mirrored into the host app's Rails session.
281
279
 
282
280
  ```ruby
283
- config.token_store = :clowk # Clowk's own cookie, and nowhere else
284
- config.token_store = :app # also mirrored into the Rails session (the default)
281
+ config.token_store = nil # Clowk's own cookie, and nowhere else (the default)
282
+ config.token_store = :app # also mirrored into the app's Rails session
285
283
  ```
286
284
 
287
- The copy is not free. A Rails session lives in **one** cookie with about 4096
288
- bytes to its name, and in production, where tokens are RS256, the token is most
289
- of what a session weighs. Hand a browser more than it will hold and it discards
290
- the whole cookie in silence — no error server-side, none in the console, the
291
- previous cookie simply stays. Everything written on that request goes with it: a
292
- flash message, a selected tenant, a CSRF rotation. What a person sees is a button
293
- that does nothing.
285
+ The copy is not free, which is why `nil` is the default. A Rails session lives in
286
+ **one** cookie with about 4096 bytes to its name, and in production, where tokens
287
+ are RS256, the token is most of what a session weighs. Hand a browser more than
288
+ it will hold and it discards the whole cookie in silence — no error server-side,
289
+ none in the console, the previous cookie simply stays. Everything written on that
290
+ request goes with it: a flash message, a selected tenant, a CSRF rotation. What a
291
+ person sees is a button that does nothing. An app pays that risk on every request
292
+ to keep a copy of a token it already has.
294
293
 
295
- Under `:clowk` the session keeps the claims and the sign-in time, nothing else.
294
+ Under `nil` the session keeps the claims and the sign-in time, nothing else.
296
295
  `current_token` reads Clowk's cookie instead, which is where an API-only app has
297
- always read it from. Sessions written before the switch are pruned on their next
296
+ always read it from. Sessions written before the upgrade are pruned on their next
298
297
  request, so an app does not have to wait for everyone to sign out.
299
298
 
300
- The default stays `:app` so an upgrade changes nothing until you ask.
299
+ Ask for `:app` if the app reads the token out of `session[...]` itself rather
300
+ than through `current_token`.
301
+
302
+ ### How long a session lasts
303
+
304
+ The Clowk instance decides. Set the session lifetime in the dashboard (7 days,
305
+ 30 days) and the app keeps the user signed in for exactly that long, also in an
306
+ installed PWA that the OS stops and starts again.
307
+
308
+ ```ruby
309
+ config.publishable_key = ENV["CLOWK_PUBLISHABLE_KEY"] # required for refresh tokens
310
+ config.refresh_tokens = true # default
311
+ config.refresh_cookie_key = "clowk_refresh_token" # default
312
+ config.refresh_skew = 60 # seconds, default
313
+ ```
314
+
315
+ 1. The callback trades the sign-in token for an access token and a refresh
316
+ token (`POST /sessions/exchange`).
317
+ 2. Both go into httponly cookies that expire when the Clowk session ends. A
318
+ cookie with no expiry is deleted when the browser closes, and a PWA closes
319
+ each time the OS stops it. That was why a PWA lost its session in hours.
320
+ 3. When the access token expires (or the app session cookie is gone), the next
321
+ request renews it with the refresh cookie. There is no redirect.
322
+ 4. When Clowk refuses the refresh (revoked, past the lifetime, idle timeout),
323
+ the local session ends and the user goes to sign-in. When Clowk cannot be
324
+ reached, the cookies stay and the next request tries again.
325
+ 5. `clowk_sign_out!` revokes the session on Clowk too.
326
+
327
+ Refresh tokens rotate: each one works once. A page and its Turbo frames can
328
+ arrive together with the same expired token, so one request rotates and the
329
+ others take its result from `config.session_status_cache` (`Rails.cache` by
330
+ default). Use a cache all app processes share (Solid Cache, Redis, Memcached).
331
+
332
+ Also give the app's own session cookie an expiry, so the app does not need a
333
+ refresh each time the PWA opens:
334
+
335
+ ```ruby
336
+ # config/initializers/session_store.rb
337
+ Rails.application.config.session_store :cookie_store, key: "_myapp_session", expire_after: 30.days
338
+ ```
339
+
340
+ Without a publishable key, or with `config.refresh_tokens = false`, the gem keeps
341
+ only the sign-in token, in a cookie that lives as long as the token.
301
342
 
302
343
  ### Token verification
303
344
 
@@ -12,6 +12,10 @@ module Clowk
12
12
  # must not be swallowed into "the session is probably fine".
13
13
  BROKER_UNAVAILABLE = [SystemCallError, Timeout::Error, IOError, SocketError, EOFError].freeze
14
14
 
15
+ # How long the cookies live when Clowk does not say (a server older than
16
+ # refresh_expires_in). Clowk's own default session lifetime.
17
+ FALLBACK_SESSION_LIFETIME = 7 * 24 * 60 * 60
18
+
15
19
  def self.install_dynamic_methods(base)
16
20
  scope = Clowk.config.prefix_by.to_s
17
21
  current_method = :"current_#{scope}"
@@ -89,6 +93,8 @@ module Clowk
89
93
 
90
94
  def clowk_current_resource
91
95
  @clowk_current_resource ||= begin
96
+ clowk_end_lapsed_session!
97
+
92
98
  payload = stored_user_payload || verified_request_payload
93
99
 
94
100
  drop_mirrored_token!
@@ -98,11 +104,16 @@ module Clowk
98
104
  end
99
105
 
100
106
  # The session first, which holds a copy only under token_store :app —
101
- # and, for one request after an app switches to :clowk, in a session
102
- # written before the switch. drop_mirrored_token! clears those. Clowk's own
107
+ # and, for one request after an app drops that setting, in a session
108
+ # written before the change. drop_mirrored_token! clears those. Clowk's own
103
109
  # cookie is the source either way.
110
+ #
111
+ # An expired token is renewed with the refresh cookie before it is handed
112
+ # out, so an API call or a session check never carries a dead token.
104
113
  def current_token
105
- stored_session&.dig("token") || extracted_token
114
+ token = stored_session&.dig("token") || extracted_token
115
+
116
+ clowk_refresh_due?(token) ? clowk_fresh_token : token
106
117
  end
107
118
 
108
119
  def clowk_signed_in?
@@ -175,9 +186,14 @@ module Clowk
175
186
  clowk_current_resource
176
187
  end
177
188
 
178
- def clowk_sign_out!
189
+ # @param revoke [Boolean] also end the session on Clowk, so the refresh
190
+ # token cannot sign anyone in again
191
+ def clowk_sign_out!(revoke: true)
192
+ Clowk::TokenRefresher.new.revoke(clowk_refresh_cookie) if revoke
193
+
179
194
  clowk_session_store&.delete(Clowk.config.session_key)
180
195
  clowk_cookie_jar&.delete(Clowk.config.cookie_key)
196
+ clowk_cookie_jar&.delete(Clowk.config.refresh_cookie_key)
181
197
 
182
198
  @clowk_current_resource = nil
183
199
  end
@@ -277,16 +293,132 @@ module Clowk
277
293
  end
278
294
 
279
295
  def verified_request_payload
280
- return unless extracted_token
296
+ token = clowk_refresh_due?(extracted_token) ? clowk_fresh_token : extracted_token
297
+
298
+ return unless token
281
299
 
282
- payload = Clowk::JwtVerifier.new.verify(extracted_token)
283
- persist_clowk_session(extracted_token, payload)
300
+ payload = Clowk::JwtVerifier.new.verify(token)
301
+
302
+ persist_clowk_session(token, payload)
284
303
 
285
304
  payload
286
305
  rescue Clowk::InvalidTokenError
287
306
  nil
288
307
  end
289
308
 
309
+ # Trades the sign-in token for an access + refresh pair, so the session
310
+ # lasts what the Clowk instance configures. Falls back to the sign-in
311
+ # token alone when there is no publishable key, the token has no session,
312
+ # or Clowk cannot be reached.
313
+ def clowk_exchange_sign_in_token(token)
314
+ return token unless Clowk.config.refresh_tokens && clowk_cookie_jar
315
+
316
+ pair = Clowk::TokenRefresher.new.exchange(token)
317
+
318
+ return token unless pair
319
+
320
+ clowk_adopt_token_pair!(pair)
321
+
322
+ pair[:access_token]
323
+ rescue Clowk::InvalidTokenError, *BROKER_UNAVAILABLE => e
324
+ Clowk.config.http_logger&.warn("[Clowk] token exchange skipped: #{e.class}: #{e.message}")
325
+
326
+ token
327
+ end
328
+
329
+ def clowk_refresh_due?(token)
330
+ return false unless Clowk.config.refresh_tokens
331
+ return false if clowk_refresh_cookie.blank?
332
+ return true if token.blank?
333
+
334
+ expires_at = clowk_token_expiry(token)
335
+
336
+ expires_at.nil? || expires_at - Clowk.config.refresh_skew.to_i <= Time.now.to_i
337
+ end
338
+
339
+ # A refused refresh means the session is over on Clowk (revoked, past its
340
+ # lifetime, idle), so the local one ends too. An unreachable Clowk is not
341
+ # an answer: the cookies stay for the next request.
342
+ def clowk_fresh_token
343
+ return @clowk_fresh_token if defined?(@clowk_fresh_token)
344
+
345
+ @clowk_fresh_token = begin
346
+ pair = Clowk::TokenRefresher.new.refresh(clowk_refresh_cookie)
347
+
348
+ clowk_adopt_token_pair!(pair)
349
+
350
+ pair[:access_token]
351
+ rescue Clowk::InvalidTokenError
352
+ clowk_sign_out!(revoke: false)
353
+
354
+ nil
355
+ rescue *BROKER_UNAVAILABLE => e
356
+ Clowk.config.http_logger&.warn("[Clowk] token refresh unavailable: #{e.class}: #{e.message}")
357
+
358
+ extracted_token
359
+ end
360
+ end
361
+
362
+ # Both cookies end when the Clowk session ends. A cookie with no expiry is
363
+ # deleted when the browser closes, and an installed PWA closes each time
364
+ # the OS stops it.
365
+ def clowk_adopt_token_pair!(pair)
366
+ expires_at = Time.now + (pair[:refresh_expires_in].presence || FALLBACK_SESSION_LIFETIME).to_i
367
+
368
+ @clowk_session_expires_at = expires_at
369
+
370
+ clowk_write_cookie(Clowk.config.cookie_key, pair[:access_token], expires_at)
371
+ clowk_write_cookie(Clowk.config.refresh_cookie_key, pair[:refresh_token], expires_at) if pair[:refresh_token].present?
372
+
373
+ held = stored_session
374
+ store = clowk_session_store
375
+
376
+ return if held.nil? || store.nil?
377
+
378
+ updated = held.merge("expires_at" => expires_at.to_i)
379
+ updated["token"] = pair[:access_token] if Clowk.config.token_store == :app
380
+
381
+ store[Clowk.config.session_key] = updated
382
+ end
383
+
384
+ def clowk_write_cookie(key, value, expires_at)
385
+ clowk_cookie_jar&.[]=(key, {
386
+ value: value,
387
+ expires: expires_at,
388
+ httponly: true,
389
+ same_site: :lax,
390
+ secure: clowk_secure_request?
391
+ })
392
+ end
393
+
394
+ def clowk_secure_request?
395
+ request.respond_to?(:ssl?) && request.ssl?
396
+ end
397
+
398
+ def clowk_refresh_cookie
399
+ clowk_cookie_jar&.[](Clowk.config.refresh_cookie_key).presence
400
+ end
401
+
402
+ def clowk_token_expiry(token)
403
+ JWT.decode(token, nil, false).first["exp"]&.to_i
404
+ rescue JWT::DecodeError
405
+ nil
406
+ end
407
+
408
+ # The stored session carries the end of the Clowk session. Past it, the
409
+ # local session ends too, even when the host app's session cookie lives on.
410
+ def clowk_end_lapsed_session!
411
+ held = stored_session
412
+
413
+ return if held.nil?
414
+
415
+ expires_at = (held["expires_at"] || held[:expires_at]).to_i
416
+
417
+ return unless expires_at.positive? && expires_at <= Time.now.to_i
418
+
419
+ clowk_sign_out!(revoke: false)
420
+ end
421
+
290
422
  # Bearer header or cookie only — never the query string. A valid token in a
291
423
  # URL would otherwise sign the visitor in on ANY path, bypassing the state
292
424
  # check that makes the OAuth callback safe, and would stay replayable for the
@@ -319,25 +451,30 @@ module Clowk
319
451
  return if store.nil?
320
452
 
321
453
  claims = {user: payload, signed_in_at: Time.now.to_i}
454
+ claims[:expires_at] = @clowk_session_expires_at.to_i if @clowk_session_expires_at
322
455
 
323
456
  store[Clowk.config.session_key] =
324
- (Clowk.config.token_store == :clowk) ? claims : claims.merge(token:)
457
+ (Clowk.config.token_store == :app) ? claims.merge(token:) : claims
325
458
 
326
- clowk_cookie_jar&.[]=(Clowk.config.cookie_key, {
327
- value: token,
328
- httponly: true,
329
- same_site: :lax,
330
- secure: request.ssl?
331
- })
459
+ return if @clowk_session_expires_at
460
+
461
+ clowk_write_cookie(Clowk.config.cookie_key, token, clowk_token_cookie_expiry(token))
462
+ end
463
+
464
+ # Without a refresh token the cookie lives as long as the token does.
465
+ def clowk_token_cookie_expiry(token)
466
+ expires_at = clowk_token_expiry(token)
467
+
468
+ expires_at ? Time.at(expires_at) : nil
332
469
  end
333
470
 
334
471
  # A session written under :app still carries the token, and
335
472
  # persist_clowk_session does not run again while that session stands — so
336
- # without this, switching an app to :clowk would shrink nothing until every
473
+ # without this, an app that drops :app would shrink nothing until every
337
474
  # person signed out and back in. Drops the copy once, on the first request
338
- # after the switch.
475
+ # after the change.
339
476
  def drop_mirrored_token!
340
- return unless Clowk.config.token_store == :clowk
477
+ return if Clowk.config.token_store == :app
341
478
 
342
479
  store = clowk_session_store
343
480
  held = stored_session
@@ -28,6 +28,9 @@ module Clowk
28
28
  attr_accessor :max_session_age
29
29
  attr_accessor :fail_open_on_broker_error
30
30
  attr_accessor :token_store
31
+ attr_accessor :refresh_tokens
32
+ attr_accessor :refresh_cookie_key
33
+ attr_accessor :refresh_skew
31
34
  attr_writer :session_status_cache
32
35
 
33
36
  def initialize
@@ -66,29 +69,41 @@ module Clowk
66
69
  # the next request; max_session_age is what bounds that.
67
70
  @fail_open_on_broker_error = true
68
71
 
69
- # Whose cookie keeps the token for the next request: :app or :clowk.
70
- #
71
- # Both are cookies — that is why the setting names the OWNER rather than
72
- # the mechanism. :clowk is this gem's own cookie; :app is the Rails
73
- # session, which is itself one cookie carrying everything the app puts in
74
- # `session[...]`.
72
+ # Whether the token is ALSO mirrored into the host app's Rails session:
73
+ # nil for no, :app for yes.
75
74
  #
76
75
  # Clowk's own cookie is written either way — it is what `current_token`
77
76
  # reads when the session has none, and the only place an API-only app has
78
- # ever had. The setting decides whether a COPY is also mirrored into the
79
- # app's Rails session, as every version before 0.9 did.
77
+ # ever had. That is why nil is not "nowhere": it is Clowk's cookie alone.
78
+ #
79
+ # The copy is not free, which is why nil is the default. A Rails session
80
+ # lives in ONE cookie with about 4096 bytes to its name, and in
81
+ # production, where tokens are RS256, the token is most of what a session
82
+ # weighs. Hand a browser more than it will hold and it discards the whole
83
+ # cookie in silence: no error server-side, none in the console, the
84
+ # previous cookie simply stays. Everything written on that request goes
85
+ # with it — a flash message, a selected tenant — which reads as a button
86
+ # that does nothing. An app pays that risk on every request to keep a copy
87
+ # of a token it already has.
80
88
  #
81
- # That copy is not free. A Rails session lives in ONE cookie with about
82
- # 4096 bytes to its name, and in production, where tokens are RS256, the
83
- # token is most of what a session weighs. Hand a browser more than it will
84
- # hold and it discards the whole cookie in silence: no error server-side,
85
- # none in the console, the previous cookie simply stays. Everything written
86
- # on that request goes with it — a flash message, a selected tenant — which
87
- # reads as a button that does nothing.
89
+ # :app is there for an app that reads the token out of `session[...]`
90
+ # itself, rather than through `current_token`.
91
+ @token_store = nil
92
+
93
+ # Keeps the user signed in for the session lifetime the Clowk instance
94
+ # configures (7 days, 30 days, whatever the dashboard says), not for the
95
+ # one hour an access token lives.
88
96
  #
89
- # :session stays the default so an upgrade changes nothing until an app
90
- # asks for :cookie.
91
- @token_store = :app
97
+ # On sign-in the callback trades the token for an access + refresh pair.
98
+ # Both go into cookies that expire when the Clowk session ends, so they
99
+ # survive a closed browser or a stopped PWA. An expired access token is
100
+ # renewed on the next request, without a redirect. Needs the publishable
101
+ # key; without one the gem keeps only the sign-in token, as before.
102
+ @refresh_tokens = true
103
+ @refresh_cookie_key = "clowk_refresh_token"
104
+
105
+ # Renew the access token this many seconds before it expires.
106
+ @refresh_skew = 60
92
107
  end
93
108
 
94
109
  # Where API-only apps cache session status, since they have no Rails session
@@ -13,6 +13,9 @@ module Clowk
13
13
  return_to = flow["return_to"]
14
14
 
15
15
  reset_clowk_session!
16
+
17
+ token = clowk_exchange_sign_in_token(token)
18
+
16
19
  persist_clowk_session(token, payload)
17
20
 
18
21
  redirect_back_or(Clowk.config.after_sign_in_path, return_to:)
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clowk
4
+ module SDK
5
+ # The refresh token lifecycle. Authenticated with the publishable key: the
6
+ # refresh token is the credential.
7
+ #
8
+ # pair = client.session_tokens.exchange(token: sign_in_token)
9
+ # pair = client.session_tokens.refresh(refresh_token: pair[:refresh_token])
10
+ #
11
+ # Each pair is a Hash with :access_token, :refresh_token, :expires_in,
12
+ # :refresh_expires_in and :session_id. A refused call raises
13
+ # Clowk::InvalidTokenError.
14
+ class SessionToken < Resource
15
+ def self.resource_path
16
+ "sessions"
17
+ end
18
+
19
+ # Trades the sign-in token from the callback for an access + refresh pair.
20
+ def exchange(token:)
21
+ pair_from(client.post("#{self.class.resource_path}/exchange", {token: token}))
22
+ end
23
+
24
+ # Spends the refresh token and returns its successor.
25
+ def refresh(refresh_token:)
26
+ pair_from(client.post("#{self.class.resource_path}/refresh", {refresh_token: refresh_token}))
27
+ end
28
+
29
+ # Ends the session the refresh token belongs to.
30
+ def revoke(refresh_token:)
31
+ client.post("#{self.class.resource_path}/revoke", {refresh_token: refresh_token})
32
+ end
33
+
34
+ private
35
+
36
+ def pair_from(response)
37
+ body = response.body_parsed
38
+
39
+ unless response.success?
40
+ message = body.is_a?(Hash) ? body["error"] : nil
41
+
42
+ raise Clowk::InvalidTokenError, message || "session token request failed"
43
+ end
44
+
45
+ data = (body.is_a?(Hash) && body["data"].is_a?(Hash)) ? body["data"] : body
46
+ pair = data.to_h.deep_symbolize_keys
47
+
48
+ raise Clowk::InvalidTokenError, "session token response has no access_token" if pair[:access_token].blank?
49
+
50
+ pair
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Clowk
6
+ # Exchanges and refreshes session tokens, one refresh per refresh token.
7
+ #
8
+ # Refresh tokens rotate: each one works once. A page and its Turbo frames
9
+ # can arrive together with the same expired access token, and each would
10
+ # spend the same refresh token. Only the first wins, and the rest would sign
11
+ # the user out. The first request takes a lock in the cache and shares the
12
+ # new pair; the others wait for it and use the same pair.
13
+ #
14
+ # The lock works across processes only with a shared cache (Solid Cache,
15
+ # Redis, Memcached). With a per-process cache, Clowk's grace window for a
16
+ # just-rotated token keeps the session alive, and the late request is
17
+ # signed in again on the next request.
18
+ class TokenRefresher
19
+ LOCK_TTL = 10
20
+ RESULT_TTL = 30
21
+ WAIT_TIMEOUT = 5.0
22
+ WAIT_INTERVAL = 0.05
23
+
24
+ def initialize(cache: Clowk.config.session_status_cache, client: nil)
25
+ @cache = cache
26
+ @client = client
27
+ end
28
+
29
+ # @return [Hash, nil] the pair, or nil when no publishable key is set
30
+ # @raise [Clowk::InvalidTokenError] when Clowk refuses the token
31
+ def exchange(token)
32
+ return unless publishable_key?
33
+
34
+ sdk.session_tokens.exchange(token: token)
35
+ end
36
+
37
+ # @return [Hash] the new pair
38
+ # @raise [Clowk::InvalidTokenError] when Clowk refuses the refresh token
39
+ def refresh(refresh_token)
40
+ key = Digest::SHA256.hexdigest(refresh_token.to_s)
41
+
42
+ shared = read_shared(key)
43
+
44
+ return shared if shared
45
+
46
+ return rotate(refresh_token, key) if acquire_lock(key)
47
+
48
+ wait_for_shared(key) || raise(Clowk::InvalidTokenError, "concurrent refresh did not finish")
49
+ end
50
+
51
+ # Ends the session on Clowk. Best effort: a sign-out must not fail because
52
+ # the broker is down.
53
+ def revoke(refresh_token)
54
+ return if refresh_token.blank? || !publishable_key?
55
+
56
+ sdk.session_tokens.revoke(refresh_token: refresh_token)
57
+ rescue Clowk::Error, *Clowk::Authenticable::BROKER_UNAVAILABLE
58
+ nil
59
+ end
60
+
61
+ private
62
+
63
+ attr_reader :cache
64
+
65
+ def rotate(refresh_token, key)
66
+ pair = sdk.session_tokens.refresh(refresh_token: refresh_token)
67
+
68
+ cache&.write(result_key(key), pair, expires_in: RESULT_TTL)
69
+
70
+ pair
71
+ rescue Clowk::InvalidTokenError
72
+ cache&.write(result_key(key), {refused: true}, expires_in: RESULT_TTL)
73
+
74
+ raise
75
+ ensure
76
+ cache&.delete(lock_key(key))
77
+ end
78
+
79
+ def acquire_lock(key)
80
+ return true if cache.nil?
81
+
82
+ cache.write(lock_key(key), 1, unless_exist: true, expires_in: LOCK_TTL)
83
+ end
84
+
85
+ def wait_for_shared(key)
86
+ deadline = monotonic_now + WAIT_TIMEOUT
87
+
88
+ while monotonic_now < deadline
89
+ sleep(WAIT_INTERVAL)
90
+
91
+ shared = read_shared(key)
92
+
93
+ return shared if shared
94
+ end
95
+
96
+ nil
97
+ end
98
+
99
+ def read_shared(key)
100
+ shared = cache&.read(result_key(key))&.deep_symbolize_keys
101
+
102
+ raise Clowk::InvalidTokenError, "refresh token was refused" if shared&.dig(:refused)
103
+
104
+ shared
105
+ end
106
+
107
+ def result_key(key)
108
+ "clowk:refresh:result:#{key}"
109
+ end
110
+
111
+ def lock_key(key)
112
+ "clowk:refresh:lock:#{key}"
113
+ end
114
+
115
+ def monotonic_now
116
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
117
+ end
118
+
119
+ def publishable_key?
120
+ Clowk.credentials.publishable_key.present?
121
+ end
122
+
123
+ def sdk
124
+ @client ||= Clowk::SDK::Client.new
125
+ end
126
+ end
127
+ end
data/lib/clowk/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clowk
4
- VERSION = "0.9.1"
4
+ VERSION = "0.11.0"
5
5
  end
data/lib/clowk.rb CHANGED
@@ -51,6 +51,7 @@ require_relative "clowk/sdk/session"
51
51
  require_relative "clowk/sdk/subdomain"
52
52
  require_relative "clowk/sdk/token"
53
53
  require_relative "clowk/sdk/session_config"
54
+ require_relative "clowk/sdk/session_token"
54
55
  require_relative "clowk/sdk/client"
55
56
  require_relative "clowk/subdomain"
56
57
  require_relative "clowk/jwks"
@@ -58,6 +59,7 @@ require_relative "clowk/jwt_verifier"
58
59
  require_relative "clowk/helpers/url_helpers"
59
60
  require_relative "clowk/middleware/token_extractor"
60
61
  require_relative "clowk/authenticable"
62
+ require_relative "clowk/token_refresher"
61
63
  require_relative "clowk/controllers/base_controller"
62
64
  require_relative "clowk/controllers/callbacks_controller"
63
65
  require_relative "clowk/controllers/sessions_controller"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: clowk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Clowk
@@ -123,10 +123,12 @@ files:
123
123
  - lib/clowk/sdk/resource.rb
124
124
  - lib/clowk/sdk/session.rb
125
125
  - lib/clowk/sdk/session_config.rb
126
+ - lib/clowk/sdk/session_token.rb
126
127
  - lib/clowk/sdk/subdomain.rb
127
128
  - lib/clowk/sdk/token.rb
128
129
  - lib/clowk/sdk/user.rb
129
130
  - lib/clowk/subdomain.rb
131
+ - lib/clowk/token_refresher.rb
130
132
  - lib/clowk/version.rb
131
133
  homepage: https://clowk.in
132
134
  licenses: