clowk 0.6.1 → 0.7.1

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: 1aad1a920ab61f2ff4a89d52ceede9e30edf63a92f2fa5cb60e99eb3032f5a39
4
- data.tar.gz: a701bfc46f4c2ae7c2bc57d731ab9a9ff4dde4dc454fc0634e8b1d6a08603b80
3
+ metadata.gz: 33d1a92eb50a3515f9c312ccd3ba71dbc9bedfd11935797f70497aa076dbb55b
4
+ data.tar.gz: 38aa689ff1303246487acaad73cf02c34955bafa9a44bc44544b6ce289e758a3
5
5
  SHA512:
6
- metadata.gz: bd7bb4bc6f95eb20c9cdabbb5f7e1dd1664f5d2566b895bd0e7466c46821ee7262b25bea0ced1aaf21e4ae8fa858ada4b28161d5517d7241f8ec31607ef5cae8
7
- data.tar.gz: e0d6c2d830d16cc85c003c114240e0b2270f7de8288d85f1eb4b5d564613d8681c1d484a37fcf36a76908f44f6e9d3b9579d2890d4e3abf4cc84917375c2d03e
6
+ metadata.gz: b334fda452268ee1ae58a897ddbd07dfe6c0f715f4a2693ddadffbeb882af3d906bf4e76ce655160eb5c204c2de6d175c596ecc182b4261ef9edfc21c31f6b51
7
+ data.tar.gz: 4516f8fdc4dd79291fa895c2a203d7920b3d44ee69bf89f1a8c3f11877f65e36d1e8dd70b67b5a99c49462e15c07d33f25b42d85466fd87f44e2a3244745248e
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,53 @@ 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
+ # Nothing to enforce against a request that carries no session. Reached
157
+ # through clowk_authenticate! this is already true, but the method is also
158
+ # a before_action in its own right — and called that way with no session it
159
+ # read "not active" and ended one that never existed. On a page that skips
160
+ # the identity gate on purpose (an invite link, a public page that shows
161
+ # more when signed in) that threw away whatever the redirect was carrying.
162
+ return unless clowk_signed_in?
163
+
164
+ return clowk_expire_session!(nil) if clowk_session_beyond_max_age?
165
+ return if clowk_session_active?(force: force)
166
+
167
+ clowk_expire_session!(clowk_session_status)
114
168
  end
115
169
 
116
170
  def clowk_authenticate!
@@ -158,6 +212,37 @@ module Clowk
158
212
  end
159
213
  end
160
214
 
215
+ # One route out of a session that must end, whatever ended it — the broker
216
+ # said inactive, or the local ceiling passed. Apps hook it with
217
+ # config.on_session_expired; the default answers 401 or redirects.
218
+ def clowk_expire_session!(session_info)
219
+ callback = Clowk.config.on_session_expired
220
+
221
+ if callback.respond_to?(:call)
222
+ callback.call(self, session_info)
223
+
224
+ return
225
+ end
226
+
227
+ clowk_handle_expired_session(session_info)
228
+ end
229
+
230
+ # A ceiling Clowk plays no part in. Without it, failing open on an
231
+ # unreachable broker would mean a session that never ends.
232
+ #
233
+ # signed_in_at is stamped once, when the session is established:
234
+ # clowk_current_resource prefers the stored payload, so persist_clowk_session
235
+ # does not run again while the session stands.
236
+ def clowk_session_beyond_max_age?
237
+ max = Clowk.config.max_session_age.to_i
238
+
239
+ return false unless max.positive?
240
+
241
+ started = (stored_session&.dig("signed_in_at") || stored_session&.dig(:signed_in_at)).to_i
242
+
243
+ started.positive? && (Time.now.to_i - started) > max
244
+ end
245
+
161
246
  def clowk_handle_expired_session(_session_info)
162
247
  if clowk_api_request?
163
248
  render json: {error: "Session expired or inactive"}, status: :unauthorized
@@ -253,8 +338,9 @@ module Clowk
253
338
  })
254
339
  end
255
340
 
256
- def resolve_session_status
257
- cached = clowk_read_cached_session_status
341
+ def resolve_session_status(force: false)
342
+ @clowk_session_check_unavailable = false
343
+ cached = force ? nil : clowk_read_cached_session_status
258
344
 
259
345
  return cached if cached
260
346
 
@@ -273,6 +359,14 @@ module Clowk
273
359
 
274
360
  status
275
361
  rescue Clowk::InvalidTokenError
362
+ nil
363
+ rescue *BROKER_UNAVAILABLE => e
364
+ # Never cached: "we could not ask" is not an answer worth keeping, and the
365
+ # next request should try again rather than inherit this one's bad luck.
366
+ @clowk_session_check_unavailable = true
367
+
368
+ Clowk.config.http_logger&.warn("[Clowk] session check unavailable: #{e.class}: #{e.message}")
369
+
276
370
  nil
277
371
  end
278
372
 
@@ -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.1"
4
+ VERSION = "0.7.1"
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.1
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Clowk