sessions 0.2.2 → 0.3.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: 6ead91af5a7b6b9818df58d3c068ce4d59e09a5b865e80373ba2e7b7c5854e75
4
- data.tar.gz: 6ad52d4780745c525de40a76cb462e5cedc87b3f54dfff7f9d803f69ad9f0033
3
+ metadata.gz: b7cca6c52bb926f1a07896ba44ec9cf3abba216dd8d49793febc1a405b27e6e1
4
+ data.tar.gz: 00770fa40d62a4e305df2b8a8530519873368fe097359d64d92fc8a19b522897
5
5
  SHA512:
6
- metadata.gz: f32fb15dbbef609551071da2de77a4b6c0f9117e496a2405548d87a5fcddad11bb14b1e7c738e0bdd70112634ad92d7b5a1ecf3fb041b4b2d3420e8d0fba3e3b
7
- data.tar.gz: fffa0d52b6c6353b1d326f0a76c7a3e49ea8bac0f3bf8b160923675c99bfa9d7a12db4f85e3120a532b677e4539fedb90b5aa80917eb88f6c87455699d52726e
6
+ metadata.gz: a3c27ee309a2d4a74c78e05b573068af4d5736cdaccf23e19f36565d58349ad550a344da601b1ce71c601e917c2c311405756b2af6cd7f314aeedc07502251c1
7
+ data.tar.gz: 4bd16a0366a8cdd01f69b426af63959d58f6e847b722e12f696ab196d3c9da3a540cec6ff6a7155040a51a823a9ba029f75828d04ffbb36f682b6bd9ed4687a8
data/Appraisals CHANGED
@@ -7,16 +7,22 @@
7
7
  # fine on 7.1 because the concern only uses 7.1-era APIs).
8
8
  appraise "rails-7.1" do
9
9
  gem "rails", "~> 7.1.0"
10
+ # json 3 removed the quirks_mode option that ActiveSupport before 8.1 passes.
11
+ gem "json", "< 3"
10
12
  end
11
13
 
12
14
  appraise "rails-7.2" do
13
15
  gem "rails", "~> 7.2.0"
16
+ # json 3 removed the quirks_mode option that ActiveSupport before 8.1 passes.
17
+ gem "json", "< 3"
14
18
  end
15
19
 
16
20
  # The version that shipped `rails generate authentication` — the substrate
17
21
  # this gem decorates.
18
22
  appraise "rails-8.0" do
19
23
  gem "rails", "~> 8.0.0"
24
+ # json 3 removed the quirks_mode option that ActiveSupport before 8.1 passes.
25
+ gem "json", "< 3"
20
26
  end
21
27
 
22
28
  # The latest Rails — also adds the rate_limit.action_controller notification
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 (2026-10-18)
4
+
5
+ Adds re-authentication assertions. This release is additive: it needs no migration, and existing installs change nothing until they call the new API.
6
+
7
+ - **Record a fresh re-authentication on the session row.** Call `session.reauthenticated!(method: :password)` after your own credential check succeeds. The gem never checks credentials. The state lives in `auth_detail`, so no migration is needed. `session.reauthenticated_within?(5.minutes)` tells whether it is recent. `session.reauthenticated_at` and `session.reauthentication_method` read it back. An ended or expired session is never recent. Both methods reload the row under a lock, so a revocation in another process counts.
8
+ - **Issue a short-lived signed assertion of it.** `session.reauthentication_assertion(audience:, purpose:)` returns a signed token for one audience and one purpose. It raises `Sessions::Reauthentication::NotRecent` when the re-authentication is not recent.
9
+ - **Verify the assertion in another service.** `Sessions::Reauthentication.verify(token, audience:, key:, subject:, purpose:, max_age:)` returns an `Assertion` or `nil`. The other service needs only the shared key and `require "sessions/reauthentication"`: no database, no cookie, no models. `verify` refuses another key, audience, subject or purpose, a changed or expired token, a timestamp more than 30 seconds in the future, and a re-authentication older than `max_age`.
10
+ - **Subjects carry the owner class.** A subject is `"<owner class>:<id>"`, so owners of two classes with the same id never match. A stored polymorphic type is resolved to its full class name, also when `store_full_class_name` is false.
11
+ - **Replay protection is possible.** Each assertion has a unique `jti` and an `expires_at`. To refuse replays, record each accepted `jti` until `expires_at`.
12
+ - **Opt-in controller helpers.** `include Sessions::ReauthenticationHelpers` gives `record_sessions_reauthentication!(owner, method:)`, `sessions_reauthenticated?(owner)` and `sessions_reauthentication_assertion(owner, audience:, purpose:)`. They act only on a current session of the named owner. Use `scope:` for apps with more than one Warden scope.
13
+ - **New configuration.** `reauthentication_key` (a String of 32 bytes or more, or a callable), `reauthentication_window` (default 5 minutes) and `reauthentication_assertion_ttl` (default 1 minute).
14
+
3
15
  ## 0.2.2 (2026-07-06)
4
16
 
5
17
  Closes the revocation-semantics gaps found auditing the 0.2.x lifecycle model against production data.
data/README.md CHANGED
@@ -365,6 +365,46 @@ rails generate sessions:madmin --user-panel
365
365
 
366
366
  That adds a reusable `Sessions::MadminUserPanel` concern and `app/views/madmin/users/sessions.html.erb`. Include the concern in `Madmin::UsersController`, add the printed member routes, and add a `member_action` link from your `UserResource`.
367
367
 
368
+ ## 🔐 Re-authentication assertions ("prove you just typed your password")
369
+
370
+ Some actions deserve a fresh password: approving a payment, changing a payout account, confirming something another service will act on. `sessions` records that fresh re-authentication on the session row and can hand out a short-lived, signed **assertion** of it that another service verifies with a shared key — no database, no cookie, no models on the other side.
371
+
372
+ ```ruby
373
+ # config/initializers/sessions.rb
374
+ Sessions.configure do |config|
375
+ config.reauthentication_key = Rails.application.credentials.reauthentication_key # >= 32 bytes, shared with the verifier
376
+ end
377
+
378
+ # Your controller, after YOUR check succeeded (the gem never checks credentials):
379
+ class ReauthenticationsController < ApplicationController
380
+ include Sessions::ReauthenticationHelpers
381
+
382
+ def create
383
+ return render(:new, status: :unprocessable_entity) unless current_user.valid_password?(params[:password])
384
+
385
+ record_sessions_reauthentication!(current_user, method: :password)
386
+ token = sessions_reauthentication_assertion(current_user, audience: "payments", purpose: "payout:42")
387
+ redirect_to payments_service_url(assertion: token), allow_other_host: true
388
+ end
389
+ end
390
+ ```
391
+
392
+ The other service needs only `require "sessions/reauthentication"` and the key:
393
+
394
+ ```ruby
395
+ assertion = Sessions::Reauthentication.verify(token, audience: "payments", key: ENV["REAUTH_KEY"],
396
+ subject: "User:#{user_id}", purpose: "payout:42", max_age: 5.minutes)
397
+ assertion&.method # => "password"
398
+ assertion&.jti # unique per assertion
399
+ assertion&.expires_at # to refuse replays, check-and-insert each accepted jti and keep it until this time
400
+ ```
401
+
402
+ The helpers take the owner whose credential you checked and act only on a current session of that owner (`scope:` picks the Warden scope when you sign in more than one). The subject of an assertion is the owner's class and id (`"User:42"`), so two owner classes with the same id never match; `verify` accepts the record or that string as `subject:`.
403
+
404
+ On the model: `session.reauthenticated!(method: :password)`, `session.reauthenticated_within?(5.minutes)`, `session.reauthenticated_at`, and `session.reauthentication_assertion(audience:, purpose:)` (raises `Sessions::Reauthentication::NotRecent` without a recent re-authentication). An ended or expired session is never recent, and both methods reload the row under a lock first, so a revocation in another process counts. The state lives in `auth_detail`, so no migration is needed.
405
+
406
+ What an assertion is not: a session or a bearer login. It expires after `reauthentication_assertion_ttl` (default one minute), it names one audience and one purpose, and `verify` returns `nil` for anything else — another key, audience, subject or purpose, a tampered or expired token, a timestamp more than 30 seconds in the future, or a re-authentication older than `max_age`.
407
+
368
408
  ## 🧹 Retention & the sweep
369
409
 
370
410
  The install generator drops a `SessionsSweepJob` into `app/jobs/` — schedule it daily:
@@ -430,6 +470,9 @@ Sessions.configure do |config|
430
470
  config.authenticate_method = :authenticate_user!
431
471
  config.layout = nil # nil, "app", or ->(controller) { ... }
432
472
  config.require_reauthentication = nil # ->(controller) { ... } sudo gate
473
+ config.reauthentication_key = nil # shared secret (>= 32 bytes) for re-authentication assertions
474
+ config.reauthentication_window = 5.minutes # how recent a re-authentication must be to assert it
475
+ config.reauthentication_assertion_ttl = 1.minute # how long an issued assertion stays valid
433
476
  config.session_class = "Session"
434
477
  config.strategy_methods = {} # { "OtpAuthenticatable" => :otp }
435
478
  end
@@ -4,6 +4,7 @@ source "https://rubygems.org"
4
4
 
5
5
  gem "rake", "~> 13.0"
6
6
  gem "rails", "~> 7.1.0"
7
+ gem "json", "< 3"
7
8
 
8
9
  group :development do
9
10
  gem "appraisal"
@@ -4,6 +4,7 @@ source "https://rubygems.org"
4
4
 
5
5
  gem "rake", "~> 13.0"
6
6
  gem "rails", "~> 7.2.0"
7
+ gem "json", "< 3"
7
8
 
8
9
  group :development do
9
10
  gem "appraisal"
@@ -4,6 +4,7 @@ source "https://rubygems.org"
4
4
 
5
5
  gem "rake", "~> 13.0"
6
6
  gem "rails", "~> 8.0.0"
7
+ gem "json", "< 3"
7
8
 
8
9
  group :development do
9
10
  gem "appraisal"
@@ -192,6 +192,12 @@ module Sessions
192
192
  # never through to the destructive action).
193
193
  attr_reader :require_reauthentication
194
194
 
195
+ # Re-authentication assertions (Sessions::Reauthentication): the shared
196
+ # secret that signs them (a String of at least 32 bytes, or a callable
197
+ # returning one; nil disables issuing), how recent a re-authentication
198
+ # must be to issue one, and how long an issued assertion stays valid.
199
+ attr_reader :reauthentication_key, :reauthentication_window, :reauthentication_assertion_ttl
200
+
195
201
  # The host's session-of-record model, as a string. "Session" matches
196
202
  # both the Rails 8 generator and the model our install generator writes
197
203
  # in Devise mode. Escape hatch for apps with a conflicting legacy
@@ -239,6 +245,9 @@ module Sessions
239
245
  @authenticate_method = :authenticate_user!
240
246
  @layout = nil
241
247
  @require_reauthentication = nil
248
+ @reauthentication_key = nil
249
+ @reauthentication_window = 5.minutes
250
+ @reauthentication_assertion_ttl = 1.minute
242
251
  @session_class = "Session"
243
252
  @strategy_methods = {}
244
253
  end
@@ -395,6 +404,31 @@ module Sessions
395
404
  @require_reauthentication = ensure_callable(value, "require_reauthentication")
396
405
  end
397
406
 
407
+ def reauthentication_key=(value)
408
+ if value.nil? || value.respond_to?(:call) || (value.is_a?(String) && value.bytesize >= 32)
409
+ @reauthentication_key = value
410
+ return
411
+ end
412
+
413
+ raise ConfigurationError, "reauthentication_key must be a String of at least 32 bytes, a callable, or nil"
414
+ end
415
+
416
+ # The signing key, resolved from a callable on use (so it can read credentials lazily).
417
+ def resolved_reauthentication_key
418
+ key = @reauthentication_key.respond_to?(:call) ? @reauthentication_key.call : @reauthentication_key
419
+ raise ConfigurationError, "config.reauthentication_key is not set" if key.to_s.empty?
420
+
421
+ key
422
+ end
423
+
424
+ def reauthentication_window=(value)
425
+ @reauthentication_window = ensure_positive_duration(value, "reauthentication_window")
426
+ end
427
+
428
+ def reauthentication_assertion_ttl=(value)
429
+ @reauthentication_assertion_ttl = ensure_positive_duration(value, "reauthentication_assertion_ttl")
430
+ end
431
+
398
432
  def session_class=(value)
399
433
  name = value.is_a?(Class) ? value.name : value.to_s
400
434
  raise ConfigurationError, "session_class can't be blank" if name.strip.empty?
@@ -440,6 +474,12 @@ module Sessions
440
474
  value
441
475
  end
442
476
 
477
+ def ensure_positive_duration(value, name)
478
+ return value if value.is_a?(Numeric) && value.positive?
479
+
480
+ raise ConfigurationError, "#{name} must be a positive duration (for example 5.minutes), got #{value.inspect}"
481
+ end
482
+
443
483
  def ensure_callable(value, name)
444
484
  unless value.respond_to?(:call)
445
485
  raise ConfigurationError, "#{name} must respond to #call (a proc/lambda), got #{value.inspect}"
@@ -144,6 +144,97 @@ module Sessions
144
144
  update!(auth_detail: detail.merge("second_factor" => kind.to_s))
145
145
  end
146
146
 
147
+ # --- Re-authentication ----------------------------------------------------
148
+
149
+ # Record that the person re-authenticated on THIS session just now (a
150
+ # password re-entry, a passkey or a second factor). Call it after your
151
+ # own check succeeded; the gem never checks credentials itself:
152
+ #
153
+ # if current_user.valid_password?(params[:password])
154
+ # Sessions.current(request)&.reauthenticated!(method: :password)
155
+ # end
156
+ #
157
+ # Stored in `auth_detail` like `second_factor!`, so no migration is needed.
158
+ def reauthenticated!(method: :password, at: Time.current)
159
+ unless self.class.column_names.include?("auth_detail")
160
+ raise Sessions::Error, "re-authentication needs the auth_detail column (rails generate sessions:install)"
161
+ end
162
+
163
+ with_lock do # reloads the row: a revocation by another process counts
164
+ raise Sessions::Error, "an ended or expired session cannot be re-authenticated" unless reauthenticatable?
165
+
166
+ detail = (try(:auth_detail) || {}).to_h
167
+ update!(auth_detail: detail.merge("reauthenticated_at" => at.utc.iso8601(6),
168
+ "reauthentication_method" => method.to_s))
169
+ end
170
+ end
171
+
172
+ # When the person last re-authenticated on this session, or nil.
173
+ def reauthenticated_at
174
+ value = (try(:auth_detail) || {}).to_h["reauthenticated_at"]
175
+ value && Time.iso8601(value)
176
+ rescue ArgumentError
177
+ nil
178
+ end
179
+
180
+ def reauthentication_method = (try(:auth_detail) || {}).to_h["reauthentication_method"]
181
+
182
+ # True when this live, unexpired session re-authenticated within the
183
+ # duration (default: config.reauthentication_window), on this row as stored.
184
+ def reauthenticated_within?(duration = Sessions.config.reauthentication_window)
185
+ at = reauthenticated_at
186
+ reauthenticatable? && at.present? && at > duration.ago && at <= Time.current
187
+ end
188
+
189
+ # A short-lived signed assertion of the last re-authentication, for one
190
+ # audience (and optionally one purpose). Another service verifies it with
191
+ # Sessions::Reauthentication.verify and the shared key. Raises
192
+ # Sessions::Reauthentication::NotRecent unless the re-authentication is
193
+ # within config.reauthentication_window. The row is reloaded under a lock
194
+ # first, so an assertion never outlives a revocation it could see.
195
+ def reauthentication_assertion(audience:, purpose: nil, within: Sessions.config.reauthentication_window)
196
+ with_lock do
197
+ unless reauthenticated_within?(within)
198
+ raise Sessions::Reauthentication::NotRecent, "no re-authentication within #{within.inspect}"
199
+ end
200
+
201
+ config = Sessions.config
202
+ Sessions::Reauthentication.issue(
203
+ subject: reauthentication_subject, session_id: id, method: reauthentication_method,
204
+ authenticated_at: reauthenticated_at, audience: audience, purpose: purpose,
205
+ key: config.resolved_reauthentication_key, expires_in: config.reauthentication_assertion_ttl
206
+ )
207
+ end
208
+ end
209
+
210
+ # The owner's canonical subject ("User:42"), with the owner class even
211
+ # when the owner record is not loaded. A stored polymorphic type is
212
+ # resolved to its class first, so the subject is the full class name
213
+ # also when `store_full_class_name` is false; it then matches
214
+ # Sessions::Reauthentication.subject_for(owner).
215
+ def reauthentication_subject
216
+ stored_type = try(:user_type).presence
217
+ owner_class = if stored_type
218
+ sessions_polymorphic_owner_class(stored_type)
219
+ else
220
+ self.class.reflect_on_association(:user)&.klass
221
+ end
222
+ owner_type = owner_class.respond_to?(:base_class) ? owner_class.base_class.name : stored_type
223
+ "#{owner_type}:#{user_id}"
224
+ end
225
+
226
+ private
227
+
228
+ def reauthenticatable? = !ended? && !sessions_expired?
229
+
230
+ def sessions_polymorphic_owner_class(stored_type)
231
+ self.class.polymorphic_class_for(stored_type)
232
+ rescue NameError, ArgumentError
233
+ nil
234
+ end
235
+
236
+ public
237
+
147
238
  # --- Revocation -----------------------------------------------------------
148
239
 
149
240
  # End this registry row without deleting it. This is the core v0.2
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+ require "active_support"
7
+ require "active_support/message_verifier"
8
+ require "active_support/core_ext/time/calculations"
9
+ require "active_support/core_ext/numeric/time" # 5.minutes for max_age: and fresh?(within:)
10
+ require "active_support/security_utils"
11
+ require_relative "errors"
12
+
13
+ module Sessions
14
+ # Short-lived, signed proof that a person re-entered their password (or
15
+ # passed another re-authentication) on a given session a moment ago.
16
+ #
17
+ # The host records the re-authentication on the session row and issues an
18
+ # assertion for one audience; another service that holds the shared key
19
+ # verifies it without a database, without a session cookie, and without
20
+ # this app's models:
21
+ #
22
+ # # The host, right after it checked the password:
23
+ # session = Sessions.current(request)
24
+ # session.reauthenticated!(method: :password)
25
+ # token = session.reauthentication_assertion(audience: "billing", purpose: "refund:42")
26
+ #
27
+ # # The other service (only `require "sessions/reauthentication"` is needed):
28
+ # assertion = Sessions::Reauthentication.verify(token, audience: "billing", key: ENV["REAUTH_KEY"],
29
+ # subject: "User:#{user_id}", purpose: "refund:42")
30
+ # assertion&.fresh?(within: 5.minutes) # => true
31
+ #
32
+ # An assertion is a signed statement, not a session: it expires quickly
33
+ # (`reauthentication_assertion_ttl`), names its audience and purpose, and
34
+ # carries a unique `jti` so a verifier that must refuse replays can keep a
35
+ # short list of the ones it accepted.
36
+ module Reauthentication
37
+ # The claims of a verified assertion. `subject` is "<owner class>:<id>".
38
+ Assertion = Data.define(:subject, :session_id, :method, :authenticated_at, :audience, :purpose, :jti, :issued_at,
39
+ :expires_at) do
40
+ # True when the re-authentication happened within the duration.
41
+ def fresh?(within:) = authenticated_at > Time.now - within
42
+ end
43
+
44
+ DIGEST = "SHA256"
45
+ CLOCK_SKEW = 30 # seconds of tolerance for the issuer's clock running ahead
46
+
47
+ # The session has no re-authentication recent enough to assert.
48
+ class NotRecent < Sessions::Error; end
49
+
50
+ module_function
51
+
52
+ # The canonical subject of an owner record ("User:42"), so owners of two
53
+ # classes with the same id never share a subject. A String passes through.
54
+ def subject_for(owner)
55
+ return owner.to_s if owner.is_a?(String) || !owner.respond_to?(:id)
56
+
57
+ klass = owner.class.respond_to?(:base_class) ? owner.class.base_class : owner.class
58
+ "#{klass.name}:#{owner.id}"
59
+ end
60
+
61
+ # A signed assertion. `key` is the shared secret (at least 32 bytes).
62
+ def issue(subject:, session_id:, method:, authenticated_at:, audience:, key:, purpose: nil, expires_in: 60)
63
+ now = Time.now.utc
64
+ claims = { "sub" => subject_for(subject), "sid" => session_id.to_s, "method" => method.to_s,
65
+ "auth_at" => authenticated_at.utc.iso8601(6), "aud" => audience.to_s, "purpose" => purpose&.to_s,
66
+ "jti" => SecureRandom.uuid, "iat" => now.iso8601(6), "exp" => (now + expires_in).iso8601(6) }
67
+ verifier(key).generate(claims, expires_in: expires_in, purpose: signing_purpose(audience))
68
+ end
69
+
70
+ # The Assertion of a token, or nil when the token is forged, expired, for
71
+ # another audience, purpose or subject, dated in the future beyond
72
+ # CLOCK_SKEW, or (with max_age) of a re-authentication older than max_age.
73
+ # A verifier that must refuse replays records each accepted `jti` until
74
+ # its `expires_at` (an atomic check-and-insert).
75
+ def verify(token, audience:, key:, subject: nil, purpose: nil, max_age: nil)
76
+ return nil if token.to_s.empty?
77
+
78
+ claims = verifier(key).verified(token.to_s, purpose: signing_purpose(audience))
79
+ return nil unless claims.is_a?(Hash) && claims["aud"] == audience.to_s
80
+ return nil if subject && !secure_equal?(claims["sub"], subject_for(subject))
81
+ return nil if purpose && !secure_equal?(claims["purpose"].to_s, purpose.to_s)
82
+
83
+ assertion = Assertion.new(subject: claims["sub"], session_id: claims["sid"], method: claims["method"],
84
+ authenticated_at: Time.iso8601(claims["auth_at"]), audience: claims["aud"],
85
+ purpose: claims["purpose"], jti: claims["jti"], issued_at: Time.iso8601(claims["iat"]),
86
+ expires_at: Time.iso8601(claims["exp"]))
87
+ latest = Time.now + CLOCK_SKEW
88
+ return nil if assertion.authenticated_at > latest || assertion.issued_at > latest
89
+ return nil if assertion.authenticated_at > assertion.issued_at
90
+ return nil if max_age && !assertion.fresh?(within: max_age)
91
+
92
+ assertion
93
+ rescue ArgumentError, TypeError, KeyError
94
+ nil
95
+ end
96
+
97
+ def verifier(key)
98
+ raise Sessions::ConfigurationError, "a reauthentication key needs at least 32 bytes" if key.to_s.bytesize < 32
99
+
100
+ ActiveSupport::MessageVerifier.new(key.to_s, digest: DIGEST, serializer: JSON)
101
+ end
102
+
103
+ def signing_purpose(audience) = "sessions/reauthentication/#{audience}"
104
+
105
+ def secure_equal?(left, right)
106
+ left.to_s.bytesize == right.to_s.bytesize && ActiveSupport::SecurityUtils.secure_compare(left.to_s, right.to_s)
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ # Controller helpers for re-authentication assertions. Opt in where you
5
+ # check a password again, and name whose password you checked:
6
+ #
7
+ # class ReauthenticationsController < ApplicationController
8
+ # include Sessions::ReauthenticationHelpers
9
+ #
10
+ # def create
11
+ # if current_user.valid_password?(params[:password])
12
+ # record_sessions_reauthentication!(current_user, method: :password)
13
+ # token = sessions_reauthentication_assertion(current_user, audience: "approvals")
14
+ # end
15
+ # end
16
+ # end
17
+ #
18
+ # Every helper takes the owner whose credential the controller checked and
19
+ # acts only on a current session of that owner (in `scope:` when the app
20
+ # signs in more than one Warden scope).
21
+ module ReauthenticationHelpers
22
+ private
23
+
24
+ # Records the re-authentication on the owner's current session. Returns the session, or nil.
25
+ def record_sessions_reauthentication!(owner, method: :password, scope: nil)
26
+ sessions_reauthentication_session(owner, scope)&.tap { |session| session.reauthenticated!(method: method) }
27
+ end
28
+
29
+ # True when the owner's current session re-authenticated within the window.
30
+ def sessions_reauthenticated?(owner, within: Sessions.config.reauthentication_window, scope: nil)
31
+ sessions_reauthentication_session(owner, scope)&.reauthenticated_within?(within) || false
32
+ end
33
+
34
+ # An assertion of the owner's recent re-authentication, or nil.
35
+ def sessions_reauthentication_assertion(owner, audience:, purpose: nil, scope: nil)
36
+ sessions_reauthentication_session(owner, scope)&.reauthentication_assertion(audience: audience, purpose: purpose)
37
+ rescue Sessions::Reauthentication::NotRecent
38
+ nil
39
+ end
40
+
41
+ def sessions_reauthentication_session(owner, scope)
42
+ session = Sessions.current(request, scope: scope)
43
+ return nil unless session && owner
44
+ return nil unless session.reauthentication_subject == Sessions::Reauthentication.subject_for(owner)
45
+
46
+ session
47
+ end
48
+ end
49
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sessions
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/sessions.rb CHANGED
@@ -13,6 +13,8 @@ require "active_support/security_utils"
13
13
  require_relative "sessions/version"
14
14
  require_relative "sessions/errors"
15
15
  require_relative "sessions/configuration"
16
+ require_relative "sessions/reauthentication"
17
+ require_relative "sessions/reauthentication_helpers"
16
18
  require_relative "sessions/current"
17
19
  require_relative "sessions/end_reason"
18
20
  require_relative "sessions/ip_address"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sessions
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-07-06 00:00:00.000000000 Z
10
+ date: 2026-10-17 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: actionpack
@@ -200,6 +200,8 @@ files:
200
200
  - lib/sessions/models/concerns/has_sessions.rb
201
201
  - lib/sessions/models/concerns/model.rb
202
202
  - lib/sessions/models/event.rb
203
+ - lib/sessions/reauthentication.rb
204
+ - lib/sessions/reauthentication_helpers.rb
203
205
  - lib/sessions/version.rb
204
206
  homepage: https://github.com/rameerez/sessions
205
207
  licenses: