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 +4 -4
- data/README.md +57 -16
- data/lib/clowk/authenticable.rb +154 -17
- data/lib/clowk/configuration.rb +33 -18
- data/lib/clowk/controllers/callbacks_controller.rb +3 -0
- data/lib/clowk/sdk/session_token.rb +54 -0
- data/lib/clowk/token_refresher.rb +127 -0
- data/lib/clowk/version.rb +1 -1
- data/lib/clowk.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4b724ef711942092b90d87857fea2d8d439f759813d9312cb8e20d30dcd7201e
|
|
4
|
+
data.tar.gz: 7c79da7ff63485cd70cc62fac3669a1e0f96041e52ac6782c8c229ffee28972a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
278
|
-
|
|
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 =
|
|
284
|
-
config.token_store = :app
|
|
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
|
|
288
|
-
bytes to its name, and in production, where tokens
|
|
289
|
-
of what a session weighs. Hand a browser more than
|
|
290
|
-
the whole cookie in silence — no error server-side,
|
|
291
|
-
previous cookie simply stays. Everything written on that
|
|
292
|
-
flash message, a selected tenant, a CSRF rotation. What a
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
data/lib/clowk/authenticable.rb
CHANGED
|
@@ -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
|
|
102
|
-
# written before the
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
283
|
-
|
|
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 == :
|
|
457
|
+
(Clowk.config.token_store == :app) ? claims.merge(token:) : claims
|
|
325
458
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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,
|
|
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
|
|
475
|
+
# after the change.
|
|
339
476
|
def drop_mirrored_token!
|
|
340
|
-
return
|
|
477
|
+
return if Clowk.config.token_store == :app
|
|
341
478
|
|
|
342
479
|
store = clowk_session_store
|
|
343
480
|
held = stored_session
|
data/lib/clowk/configuration.rb
CHANGED
|
@@ -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
|
-
#
|
|
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.
|
|
79
|
-
#
|
|
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
|
-
#
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
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
|
-
#
|
|
90
|
-
#
|
|
91
|
-
|
|
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
|
|
@@ -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
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.
|
|
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:
|