standard_id 0.43.1 → 0.43.2

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: 8003e8ad18d3a88d9684bb3877d88bbc1d9d207a897776e6ef3a6cd01b0f1191
4
- data.tar.gz: 3fb388482c096695f36d79ee02e122e1cbede9fc1d11b14c6236a4d423a82a0b
3
+ metadata.gz: 4bb59a29db3ed35482b44a1a0755a3827d7e02a6ad3a78bad5e5bd22e309d048
4
+ data.tar.gz: 93491ec90c91e2fdc6cf900bc62f9475ee786c723f4591dc40446964fbac1492
5
5
  SHA512:
6
- metadata.gz: 2aade7f1bba3cb85827120d4ac19e55b0a2a039955097cd43af6ef75a84f1516080c4e6c0fc36a87d15731bb332603eab987c93130e69e2e2f5701a5ac929053
7
- data.tar.gz: c0a8bc153ac8db1de5f5186fb014e4ed89e0b5db06ef7e295ee276f032fc59ec39a591eb6e54e440263a22d43faf8b426f6e1acf4ab09fed72870e96c4e17f85
6
+ metadata.gz: 0daceb3e839301ec3b294b8aa36af72441ec5b52ea82bcdcb7066fbc1bfef97cb5efaa11d62f729995c4a7ad22b22afd946e3ed1ff4999fa694a3dc2446b8a50
7
+ data.tar.gz: ab971a917f3ea552ca3c54d6928a88086bfef17066131cb727a12df721d6bfd2988fb348a33ded3fd529d09102ea053b904ff5146129e9b5008e10e64d7b2f15
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.43.2] - 2026-09-25
11
+
12
+ ### Fixed
13
+
14
+ - **`CleanupExpiredSessionsJob` failed on every run once an expired session had a refresh token** (sidekick-web SIDEKICK-WEB-3K / cron monitor SIDEKICK-WEB-3M: `PG::ForeignKeyViolation … violates foreign key constraint "fk_rails_db44ba6f6e" on table "standard_id_refresh_tokens"`). `standard_id_refresh_tokens.session_id` references `standard_id_sessions` with no `ON DELETE` action, and `Session.where(...).delete_all` skipped the model's `dependent: :nullify`. This affected every host that schedules the job, directly or through `CleanupAllJob`, and nothing was ever cleaned up. Now, per batch of 1,000 (`batch_size:`), in one transaction, with the candidate rows re-checked under `FOR UPDATE SKIP LOCKED`:
15
+ - **An expired session that still has a live refresh token (unrevoked, unexpired) is kept.** Refresh tokens deliberately outlive their session's expiry: `RefreshTokenFlow#validate_parent_session!` checks revocation, not expiry, since 0.35.x, so `refresh_token_lifetime` alone decides how long a client stays signed in (jumpdrive-web runs 1-day browser sessions against 30-day refresh tokens). Deleting such a session would detach its token and lose "revoking this session ends its access". Revoking the token, as `Session#destroy` does, would sign the client out early. The session is collected on a later run, once its tokens are dead, so at most `refresh_token_lifetime` after it would otherwise have gone.
16
+ - **Dead refresh tokens (revoked or expired) of the sessions being deleted are detached** (`session_id` → `NULL`, as `dependent: :nullify` does) rather than deleted. `CleanupExpiredRefreshTokensJob` removes them on its own window, and until then a replayed revoked token still triggers reuse detection.
17
+ - A host whose copy of the migration added `on_delete: :nullify` to that FK (sidekick-web's `db/schema.rb` shows one, although its production constraint evidently has none) no longer has live tokens silently detached from their sessions either.
18
+ - The other cleanup jobs had no such hazard. Nothing references `standard_id_authorization_codes` or `standard_id_code_challenges`, and the only foreign key into `standard_id_refresh_tokens` is its own `previous_token_id`, which is `ON DELETE SET NULL`. `standard_id_refresh_tokens.session_id` is the only foreign key into `standard_id_sessions`, in the gem, standard_id-provider and all five apps.
19
+
20
+ ### Changed
21
+
22
+ - **Requires Rails 8.1** (`rails >= 8.1`, was `>= 8.0`; #344, merged since 0.43.1). All five consumer apps already run Rails 8.1.3.1, and 8.0 was never tested in CI.
23
+
24
+ ### Upgrade notes
25
+
26
+ Bump only. The first run after upgrading deletes, in batches, the backlog of expired sessions the job never managed to remove.
27
+
10
28
  ## [0.43.1] - 2026-09-24
11
29
 
12
30
  Fixes found while shipping 0.43.0. **Take this instead of 0.43.0** — hosts on `c.passwordless.delivery = :built_in` with the WebEngine mounted (jumpdrive-web, nutripod-web) would otherwise email email-verification codes as "your sign-in code".
@@ -2,14 +2,70 @@ module StandardId
2
2
  class CleanupExpiredSessionsJob < ApplicationJob
3
3
  queue_as :default
4
4
 
5
+ DEFAULT_BATCH_SIZE = 1_000
6
+
5
7
  # Delete sessions that expired more than `grace_period_seconds` ago.
6
8
  # A grace period avoids deleting sessions that just expired and might
7
9
  # still be referenced in in-flight requests.
8
10
  # Accepts integer seconds for reliable ActiveJob serialization across all queue adapters.
9
- def perform(grace_period_seconds: 7.days.to_i)
11
+ #
12
+ # Refresh tokens reference their session (standard_id_refresh_tokens.session_id,
13
+ # a foreign key with no ON DELETE action in the gem's migration), and
14
+ # `delete_all` skips the model's `dependent: :nullify`, so a bare delete
15
+ # failed the whole run once any expired session had a refresh token
16
+ # (sidekick-web SIDEKICK-WEB-3K). Per batch, in one transaction:
17
+ #
18
+ # * An expired session that still has a LIVE refresh token (unrevoked,
19
+ # unexpired) is kept. Refresh tokens deliberately outlive their session's
20
+ # expiry — RefreshTokenFlow#validate_parent_session! checks revocation,
21
+ # not expiry, so refresh_token_lifetime alone governs how long a client
22
+ # stays signed in. Deleting the session would detach the token from it and
23
+ # lose "revoking this session ends its access"; revoking the token (what
24
+ # Session#destroy does) would sign the client out early. The session is
25
+ # collected on a later run, once its tokens are dead — at most
26
+ # refresh_token_lifetime later.
27
+ # * Dead refresh tokens (revoked or expired) of the sessions being deleted
28
+ # are detached (`session_id` → NULL, the model's `dependent: :nullify`),
29
+ # not deleted: CleanupExpiredRefreshTokensJob removes them on its own
30
+ # window, and until then a replayed revoked token still triggers reuse
31
+ # detection.
32
+ #
33
+ # Batches (`in_batches`) keep each transaction and its locks small.
34
+ def perform(grace_period_seconds: 7.days.to_i, batch_size: DEFAULT_BATCH_SIZE)
10
35
  cutoff = grace_period_seconds.seconds.ago
11
- deleted = StandardId::Session.where("expires_at < ?", cutoff).delete_all
36
+ deleted = 0
37
+
38
+ StandardId::Session.where("expires_at < ?", cutoff).in_batches(of: batch_size) do |batch|
39
+ deleted += delete_batch(batch.pluck(:id), cutoff)
40
+ end
41
+
12
42
  Rails.logger.info("[StandardId] Cleaned up #{deleted} expired sessions older than #{cutoff}")
13
43
  end
44
+
45
+ private
46
+
47
+ def delete_batch(candidate_ids, cutoff)
48
+ StandardId::Session.transaction do
49
+ # Re-check under a row lock: a sign-in may have revived one of these
50
+ # rows (OauthSessionPersistence reuses an expired, unrevoked device
51
+ # session and bumps expires_at) or issued it a refresh token since the
52
+ # batch was read. SKIP LOCKED leaves a row another transaction holds for
53
+ # the next run. (SQLite ignores the lock clause; it serialises writers.)
54
+ ids = StandardId::Session
55
+ .where(id: candidate_ids)
56
+ .where("expires_at < ?", cutoff)
57
+ .where.not(id: live_refresh_tokens.select(:session_id))
58
+ .lock("FOR UPDATE SKIP LOCKED")
59
+ .pluck(:id)
60
+ next 0 if ids.empty?
61
+
62
+ StandardId::RefreshToken.where(session_id: ids).update_all(session_id: nil)
63
+ StandardId::Session.where(id: ids).delete_all
64
+ end
65
+ end
66
+
67
+ def live_refresh_tokens
68
+ StandardId::RefreshToken.active.where.not(session_id: nil)
69
+ end
14
70
  end
15
71
  end
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.43.1"
2
+ VERSION = "0.43.2"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.43.1
4
+ version: 0.43.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '8.0'
18
+ version: '8.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '8.0'
25
+ version: '8.1'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: bcrypt
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -345,7 +345,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
345
345
  - !ruby/object:Gem::Version
346
346
  version: '0'
347
347
  requirements: []
348
- rubygems_version: 4.0.3
348
+ rubygems_version: 4.0.10
349
349
  specification_version: 4
350
350
  summary: A comprehensive authentication engine for Rails, built on the security primitives
351
351
  introduced in Rails 8.