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 +4 -4
- data/Appraisals +6 -0
- data/CHANGELOG.md +12 -0
- data/README.md +43 -0
- data/gemfiles/rails_7.1.gemfile +1 -0
- data/gemfiles/rails_7.2.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/lib/sessions/configuration.rb +40 -0
- data/lib/sessions/models/concerns/model.rb +91 -0
- data/lib/sessions/reauthentication.rb +109 -0
- data/lib/sessions/reauthentication_helpers.rb +49 -0
- data/lib/sessions/version.rb +1 -1
- data/lib/sessions.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b7cca6c52bb926f1a07896ba44ec9cf3abba216dd8d49793febc1a405b27e6e1
|
|
4
|
+
data.tar.gz: 00770fa40d62a4e305df2b8a8530519873368fe097359d64d92fc8a19b522897
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/gemfiles/rails_7.1.gemfile
CHANGED
data/gemfiles/rails_7.2.gemfile
CHANGED
data/gemfiles/rails_8.0.gemfile
CHANGED
|
@@ -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
|
data/lib/sessions/version.rb
CHANGED
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.
|
|
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-
|
|
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:
|