clowk 0.6.0 → 0.7.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: 1593747e0040a0056565a40b2bf1699d8242af1d253751ce3ea5c86949ff1f7a
4
- data.tar.gz: 33147a1e6f88c7970d52bf33403566c09309315c53b8e080416aaece4f1ecc2a
3
+ metadata.gz: 66d644459bae62c4a73b869fadc50402bfbe6fb060c0c6fc5ee521361a852be7
4
+ data.tar.gz: 1e81f740b072795cb9a84c2013f406ee5ea34a9bf5c1c92797cea9efd1c22d4e
5
5
  SHA512:
6
- metadata.gz: 0cf89e539def821193dd451c8902e581423f103acd8881ce67d7eadb9cfd9e2e69302a265769679eb5a906593a89253f4781cf625546ee3bbd3d8d012d15584a
7
- data.tar.gz: f4993722c5349f80a7a10f39cff94d3b76b4909b700272f23458bca7d227fa263258c78b01cf511e2cb5d30640dae84a991fd686939fc4fbfcd2cee7b2e5e757
6
+ metadata.gz: 824a01a8708d3b4f1805c83acc20d2161c9e72f3be7c47ae9e1c2d9d80bbff624292d20e65cf03b53ca5384874caef3f1d038482cd4d4593cc688537b63a2d31
7
+ data.tar.gz: d4703e671a924025452c08686d6134f85563108d0edeb18bb049f9f9e4d119228d404ba3eccddae21840b2b63267c416aebac1b4a7f925689a9e37cd642cbf95
data/README.md CHANGED
@@ -227,6 +227,48 @@ What changes in that mode:
227
227
  session, keyed by a digest of the token. Without it, `enforce_active_session`
228
228
  would cost a round trip to Clowk on every authenticated request.
229
229
 
230
+ ### Keeping a session honest
231
+
232
+ A valid token proves who signed in, not that the session still stands —
233
+ revocation lives server-side. `config.enforce_active_session = true` checks it,
234
+ and `config.session_status_ttl` decides how often that costs a round trip.
235
+
236
+ Some actions cannot take a cached answer. Rotating a secret, deleting an
237
+ account, removing a member: a status from fourteen minutes ago is a hole. Name
238
+ those actions and they get a live one:
239
+
240
+ ```ruby
241
+ class ApiKeysController < ApplicationController
242
+ clowk_require_fresh_session only: [:create, :update, :destroy]
243
+ end
244
+ ```
245
+
246
+ It takes the same options as `before_action`. Everything not named keeps the
247
+ cached check, so an app pays for the round trip on the few actions it cannot
248
+ undo and nowhere else. `clowk_enforce_fresh_session!` is the same thing as a
249
+ method, and `clowk_session_active?(force: true)` returns the answer instead of
250
+ enforcing it.
251
+
252
+ Two settings decide what happens when Clowk itself cannot be reached:
253
+
254
+ ```ruby
255
+ config.fail_open_on_broker_error = true # default
256
+ config.max_session_age = 12.hours # default nil
257
+ ```
258
+
259
+ Failing open leaves the session standing and checks again on the next request —
260
+ a blip on the way to a single droplet must not sign everyone out. Only network
261
+ failures count; anything else still raises, because a bug must not read as "the
262
+ session is probably fine".
263
+
264
+ `max_session_age` is the other half of that, and failing open is not safe
265
+ without it: a local ceiling the broker plays no part in, so a permanently
266
+ unreachable Clowk cannot keep a session alive forever. Past it the session ends
267
+ with no round trip at all.
268
+
269
+ Both ends — the broker said inactive, the ceiling passed — go through
270
+ `config.on_session_expired` when you set one.
271
+
230
272
  ### Token verification
231
273
 
232
274
  Tokens signed with `RS256` are verified against Clowk's public key set, fetched
@@ -7,6 +7,11 @@ module Clowk
7
7
  module Authenticable
8
8
  extend ActiveSupport::Concern
9
9
 
10
+ # What a liveness check can raise once Clowk::Http's retries are spent, plus
11
+ # connection-refused, which retries never cover. Anything else is a bug and
12
+ # must not be swallowed into "the session is probably fine".
13
+ BROKER_UNAVAILABLE = [SystemCallError, Timeout::Error, IOError, SocketError, EOFError].freeze
14
+
10
15
  def self.install_dynamic_methods(base)
11
16
  scope = Clowk.config.prefix_by.to_s
12
17
  current_method = :"current_#{scope}"
@@ -14,6 +19,7 @@ module Clowk
14
19
  signed_in_method = :"#{scope}_signed_in?"
15
20
 
16
21
  enforce_session_method = :"#{scope}_enforce_session!"
22
+ enforce_fresh_method = :"#{scope}_enforce_fresh_session!"
17
23
  sign_out_method = :"#{scope}_sign_out!"
18
24
 
19
25
  base.class_eval do
@@ -41,6 +47,12 @@ module Clowk
41
47
  end
42
48
  end
43
49
 
50
+ unless enforce_fresh_method == :clowk_enforce_fresh_session!
51
+ define_method(enforce_fresh_method) do
52
+ clowk_enforce_fresh_session!
53
+ end
54
+ end
55
+
44
56
  unless sign_out_method == :clowk_sign_out!
45
57
  define_method(sign_out_method) do
46
58
  clowk_sign_out!
@@ -55,6 +67,22 @@ module Clowk
55
67
  Clowk::Authenticable.install_dynamic_methods(self)
56
68
  end
57
69
 
70
+ class_methods do
71
+ # Demand a live answer from Clowk before these actions, whatever a cached
72
+ # status says.
73
+ #
74
+ # class ApiKeysController < ApplicationController
75
+ # clowk_require_fresh_session only: [:create, :update, :destroy]
76
+ # end
77
+ #
78
+ # Takes the same options as before_action. Everything NOT listed keeps the
79
+ # cached check, which is the point: an app pays for a round trip on the few
80
+ # actions that cannot be undone, and nowhere else.
81
+ def clowk_require_fresh_session(**options)
82
+ before_action(**options) { clowk_enforce_fresh_session! }
83
+ end
84
+ end
85
+
58
86
  # Per-request credentials — for apps whose keys are not a boot constant:
59
87
  # an operator pastes a publishable key into a settings screen, or one
60
88
  # process serves several tenants.
@@ -90,27 +118,45 @@ module Clowk
90
118
  clowk_current_resource.present?
91
119
  end
92
120
 
93
- def clowk_session_status
94
- @clowk_session_status ||= resolve_session_status
95
- end
121
+ # @param force [Boolean] ignore any cached status and ask Clowk now
122
+ def clowk_session_status(force: false)
123
+ return @clowk_session_status if defined?(@clowk_session_status) && !force
96
124
 
97
- def clowk_session_active?
98
- clowk_session_status&.dig(:status) == "active"
125
+ @clowk_session_status = resolve_session_status(force: force)
99
126
  end
100
127
 
101
- def clowk_enforce_session!
102
- return if clowk_session_active?
128
+ # @param force [Boolean] see {#clowk_session_status}
129
+ def clowk_session_active?(force: false)
130
+ status = clowk_session_status(force: force)
103
131
 
104
- session_info = clowk_session_status
105
- callback = Clowk.config.on_session_expired
132
+ # "Could not ask" rather than "not active": a blip on the way to a single
133
+ # droplet must not sign everyone out. max_session_age is the bound on how
134
+ # long that can carry a session Clowk would have refused.
135
+ return true if @clowk_session_check_unavailable && Clowk.config.fail_open_on_broker_error
106
136
 
107
- if callback.respond_to?(:call)
108
- callback.call(self, session_info)
137
+ status&.dig(:status) == "active"
138
+ end
109
139
 
110
- return
111
- end
140
+ # Ends the session unless Clowk says, right now, that it still stands.
141
+ #
142
+ # For the handful of actions where a cached "active" is not good enough:
143
+ # rotating a secret, deleting an account, removing a member. Everything else
144
+ # should take the cached check — this is a round trip, on purpose.
145
+ #
146
+ # clowk_require_fresh_session only: [:destroy, :rotate_secret]
147
+ #
148
+ # Before 0.7 the only way to get this was `session_status_ttl = 0`, which
149
+ # bought freshness here by paying a round trip on every page instead.
150
+ def clowk_enforce_fresh_session!
151
+ clowk_enforce_session!(force: true)
152
+ end
112
153
 
113
- clowk_handle_expired_session(session_info)
154
+ # @param force [Boolean] see {#clowk_session_status}
155
+ def clowk_enforce_session!(force: false)
156
+ return clowk_expire_session!(nil) if clowk_session_beyond_max_age?
157
+ return if clowk_session_active?(force: force)
158
+
159
+ clowk_expire_session!(clowk_session_status)
114
160
  end
115
161
 
116
162
  def clowk_authenticate!
@@ -158,6 +204,37 @@ module Clowk
158
204
  end
159
205
  end
160
206
 
207
+ # One route out of a session that must end, whatever ended it — the broker
208
+ # said inactive, or the local ceiling passed. Apps hook it with
209
+ # config.on_session_expired; the default answers 401 or redirects.
210
+ def clowk_expire_session!(session_info)
211
+ callback = Clowk.config.on_session_expired
212
+
213
+ if callback.respond_to?(:call)
214
+ callback.call(self, session_info)
215
+
216
+ return
217
+ end
218
+
219
+ clowk_handle_expired_session(session_info)
220
+ end
221
+
222
+ # A ceiling Clowk plays no part in. Without it, failing open on an
223
+ # unreachable broker would mean a session that never ends.
224
+ #
225
+ # signed_in_at is stamped once, when the session is established:
226
+ # clowk_current_resource prefers the stored payload, so persist_clowk_session
227
+ # does not run again while the session stands.
228
+ def clowk_session_beyond_max_age?
229
+ max = Clowk.config.max_session_age.to_i
230
+
231
+ return false unless max.positive?
232
+
233
+ started = (stored_session&.dig("signed_in_at") || stored_session&.dig(:signed_in_at)).to_i
234
+
235
+ started.positive? && (Time.now.to_i - started) > max
236
+ end
237
+
161
238
  def clowk_handle_expired_session(_session_info)
162
239
  if clowk_api_request?
163
240
  render json: {error: "Session expired or inactive"}, status: :unauthorized
@@ -253,8 +330,9 @@ module Clowk
253
330
  })
254
331
  end
255
332
 
256
- def resolve_session_status
257
- cached = clowk_read_cached_session_status
333
+ def resolve_session_status(force: false)
334
+ @clowk_session_check_unavailable = false
335
+ cached = force ? nil : clowk_read_cached_session_status
258
336
 
259
337
  return cached if cached
260
338
 
@@ -273,6 +351,14 @@ module Clowk
273
351
 
274
352
  status
275
353
  rescue Clowk::InvalidTokenError
354
+ nil
355
+ rescue *BROKER_UNAVAILABLE => e
356
+ # Never cached: "we could not ask" is not an answer worth keeping, and the
357
+ # next request should try again rather than inherit this one's bad luck.
358
+ @clowk_session_check_unavailable = true
359
+
360
+ Clowk.config.http_logger&.warn("[Clowk] session check unavailable: #{e.class}: #{e.message}")
361
+
276
362
  nil
277
363
  end
278
364
 
@@ -291,7 +377,22 @@ module Clowk
291
377
  store.read(clowk_status_cache_key)&.deep_symbolize_keys
292
378
  end
293
379
 
380
+ # A TTL of zero means "never trust a cached status", so there is nothing to
381
+ # write anywhere — the read side already discards whatever is there
382
+ # (clowk_session_status_fresh? is false without a positive TTL).
383
+ #
384
+ # The guard used to sit below the session branch and cover only the external
385
+ # cache, so an app that set the TTL to zero — to guarantee a genuinely fresh
386
+ # check before a destructive action — still had the full status payload
387
+ # written into its session on every single request, and never read back.
388
+ # That is write-only weight in a cookie with 4096 bytes to live in, and it
389
+ # ends as ActionDispatch::Cookies::CookieOverflow on whichever request
390
+ # happens to add a flash message.
294
391
  def clowk_write_cached_session_status(status)
392
+ ttl = Clowk.config.session_status_ttl.to_i
393
+
394
+ return unless ttl.positive?
395
+
295
396
  if clowk_session_store
296
397
  clowk_session_store[Clowk.config.session_key] = stored_session.merge(
297
398
  "session_status" => status,
@@ -301,10 +402,9 @@ module Clowk
301
402
  return
302
403
  end
303
404
 
304
- ttl = Clowk.config.session_status_ttl.to_i
305
405
  store = clowk_status_cache
306
406
 
307
- return unless store && ttl.positive?
407
+ return unless store
308
408
 
309
409
  store.write(clowk_status_cache_key, status, expires_in: ttl)
310
410
  end
@@ -25,6 +25,8 @@ module Clowk
25
25
  attr_accessor :enforce_active_session
26
26
  attr_accessor :on_session_expired
27
27
  attr_accessor :session_status_ttl
28
+ attr_accessor :max_session_age
29
+ attr_accessor :fail_open_on_broker_error
28
30
  attr_writer :session_status_cache
29
31
 
30
32
  def initialize
@@ -52,6 +54,16 @@ module Clowk
52
54
  # which silently turns every later enforcement call into a no-op. Set 0 to
53
55
  # check on every call.
54
56
  @session_status_ttl = 300
57
+
58
+ # A local ceiling the broker plays no part in, so a permanently
59
+ # unreachable Clowk cannot keep a session alive forever — the other half
60
+ # of failing open. nil leaves the broker as the only authority.
61
+ @max_session_age = nil
62
+
63
+ # A network blip must not sign everyone out. When the liveness check
64
+ # cannot be made at all, the session is left standing and checked again on
65
+ # the next request; max_session_age is what bounds that.
66
+ @fail_open_on_broker_error = true
55
67
  end
56
68
 
57
69
  # Where API-only apps cache session status, since they have no Rails session
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.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
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.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Clowk