standard_id 0.35.0 → 0.36.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: e5e5289f6f264b4175abb757c1423d9fc656e5b2e5057239e7486d843a3eaf7a
4
- data.tar.gz: 209d3a72e8a16049ef1e633216c38939e69518e526baea0110dd99567ac23083
3
+ metadata.gz: 7dd8850abb672f638f6db5cf4d60ae8ec64b26bdb8d5835405c72e7bebc4d800
4
+ data.tar.gz: 69575f6669362c905ed16059f35f15044f1ee2bbade449b6dc5c62bfd93caeb5
5
5
  SHA512:
6
- metadata.gz: bdc0a65782aa7ea1299b49b0eb946dc72e56391de6e7d5132b65e59e27281d9d16a5ef28e1d75fadca282768d853a95d703b1c81278b8008699ddfeb05ab02e1
7
- data.tar.gz: ed73c2092285091f034f9d0b33423e4013004a6e129c37220d8a004f94e3c9bb79b1d9d95679f4e18f126db28243201f80cc7126780225e5114b6439a74ff214
6
+ metadata.gz: 87ed7e67e16666c2c576a2e7bb55e43ff2a861ecf93c824d5995da4d8f383d09f4bb60e8b389e9821e2d85acfb849690548cc0e6fbfabaa3e73d0f02dd44015c
7
+ data.tar.gz: 9036f22d16ee08ac97a4e5d6ae46d8c6fc7c63130da4ed66a815dfef2b21ecce787f576cc2127dbd6040405e6a6dbd2f4f71c5303d1c6e803770347af6d85b9f
data/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.36.0] - 2026-08-04
11
+
12
+ ### Security
13
+
14
+ - **`SESSION_VALIDATING` now carries the `account:` its subscribers guard on, so `AccountStatus` and `AccountLocking` finally stop a LIVE authenticated request.** Both concerns subscribe to `OAUTH_TOKEN_ISSUING`, `SESSION_CREATING` and `SESSION_VALIDATING` and branch on `event[:account]&.inactive?` / `&.locked?`. Neither publisher of `SESSION_VALIDATING` ever sent an `account:` — `Web::AuthenticationGuard#emit_session_validating` and `Api::AuthenticationGuard#emit_session_validating` both published `session:` alone. `Events#enrich_payload` injects `:current_account`, a *different* key, so `event[:account]` was `nil` and that leg of both guards never fired.
15
+
16
+ That it was an omission rather than a design is plain from the sibling emitters in the same two files: `emit_session_validated` and `emit_session_expired` each pass `account:`. Only the *validating* hook — the one that runs before the request is allowed to proceed, and therefore the only one of the three that can stop it — left it out.
17
+
18
+ **Upgrade implication — expect previously-working sessions and tokens to start failing. That is the fix.** Three shapes were getting through and now do not:
19
+
20
+ 1. **API bearer tokens, the big one.** `AccountStatusSubscriber` / `AccountLockingSubscriber` revoke `account.sessions.active` on `deactivate!` / `lock!`, but an access token is a stateless JWT with no `Session` row — revoking sessions does nothing to it. Until its `exp`, `SESSION_VALIDATING` was the only thing that could refuse it, and it was inert. Any client holding an access token minted before the account went bad kept working; it now gets `AccountDeactivatedError` / `AccountLockedError` on the next request.
21
+ 2. **A status change that skips the callbacks** — `update_all`, `update_column`, a data migration, direct SQL, an admin bulk action. No `ACCOUNT_DEACTIVATED` / `ACCOUNT_LOCKED` event fires, so no session revocation happens, and the browser session stayed live until it expired.
22
+ 3. **A session minted after the account went bad by a path that emits no `SESSION_CREATING`.** `Web::SessionManager#load_session_from_remember_token` calls `create_browser_session` directly and publishes nothing, so remember-me re-auth walked straight past the sign-in guard.
23
+
24
+ Hosts that lock or deactivate accounts should expect support traffic from users whose sessions used to survive. `sidekick-web`'s `LockAccountModal` copy — *"The user will be immediately locked out / All active sessions will be terminated"* — is now true rather than aspirational.
25
+
26
+ **No new query on either hot path.** The web guard reads the `:account` association only when it is already loaded (`SessionManager` loads the session with `eager_load(:account)`) and otherwise falls back to a single indexed `find_by` — it must never lazily touch the association, because several consumers run `strict_loading_by_default` and this emitter is on the path of *every* authenticated request, where a `StrictLoadingViolationError` would turn an inert guard into a 500. It also runs *before* the blank/expired/revoked checks, so it handles a nil session. The API guard threads its `SessionManager` through to reuse the memoized, strict-loading-cleared `#current_account` that `emit_session_validated` resolves anyway.
27
+
28
+ The spec that let this survive for so long asserted enforcement by publishing `SESSION_VALIDATING` **by hand** with the very key the real publisher omitted — proof that the subscriber reacts to a correctly-shaped payload, not that any caller produces one. Those examples now drive the real guards over real HTTP requests (`spec/requests/standard_id/live_session_account_guard_spec.rb`), and were confirmed to fail with the `account:` key removed.
29
+
30
+ Tracked in rarebit-one/rarebit-ops#306.
31
+
32
+ ### Added
33
+
34
+ - **`POST /oauth/revoke` logs a warning when `revocation_scope: :grant` resolves a presented `jti` to no `RefreshToken`.** Under `:grant`, presenting an access token is a deliberate no-op that still answers 200 (RFC 7009 §2.2) — correct, because access tokens are stateless and never persisted, but until now completely silent. A client that only ever presents access tokens looked like it was revoking when it was not. As `:grant` is adopted across the estate, post-flip no-ops need to be visible. Emitted via `StandardId.logger&.warn`, consistent with the existing `revocation_scope` fallback warning. No behaviour change. Relates to rarebit-one/rarebit-ops#304.
35
+
10
36
  ## [0.35.0] - 2026-07-31
11
37
 
12
38
  ### Security
@@ -93,10 +93,27 @@ module StandardId
93
93
  .where(token_digest: StandardId::RefreshToken.digest_for(jti))
94
94
  .includes(:session, :account)
95
95
  .first
96
+ # No persisted grant for this jti. Per RFC 7009 §2.2 the endpoint
97
+ # still answers 200, and for an access token that is the correct
98
+ # no-op (access tokens are stateless JWTs and are never persisted).
99
+ # But under :grant the no-op is otherwise completely silent, so a
100
+ # client that only ever presents access tokens looks like it is
101
+ # revoking when it is not. Log it so post-flip no-ops are visible.
102
+ if record.nil?
103
+ StandardId.logger&.warn(
104
+ "[StandardId::Revocations] revocation_scope=:grant — presented jti " \
105
+ "resolved to no RefreshToken for account #{payload[:sub]}; nothing " \
106
+ "revoked (RFC 7009 §2.2 still returns 200). This is expected when " \
107
+ "an access token is presented; present the refresh token for " \
108
+ "revocation to take effect."
109
+ )
110
+ return
111
+ end
112
+
96
113
  # A jti that resolves to another subject's grant must never revoke
97
114
  # it — the token was signature-verified, but bind the two identities
98
115
  # anyway rather than trusting a single claim.
99
- return if record.nil? || record.account_id.to_s != payload[:sub].to_s
116
+ return if record.account_id.to_s != payload[:sub].to_s
100
117
 
101
118
  refresh_tokens_revoked = record.revoke_family!
102
119
 
@@ -3,7 +3,7 @@ module StandardId
3
3
  class AuthenticationGuard
4
4
  def require_session!(session_manager, request: nil)
5
5
  api_session = session_manager.current_session
6
- emit_session_validating(api_session, request)
6
+ emit_session_validating(api_session, request, session_manager: session_manager)
7
7
 
8
8
  if api_session.blank?
9
9
  raise StandardId::NotAuthenticatedError, "Invalid or missing access token"
@@ -55,10 +55,16 @@ module StandardId
55
55
  end
56
56
  end
57
57
 
58
- def emit_session_validating(api_session, request)
58
+ # Mirrors emit_session_validated/expired below. The session_manager is
59
+ # threaded through so resolve_account takes its memoized, strict-loading-
60
+ # cleared #current_account rather than a lazy `api_session.account` —
61
+ # this emitter runs on every authenticated request, before the
62
+ # blank/expired/revoked checks, so it must not be able to raise.
63
+ def emit_session_validating(api_session, request, session_manager: nil)
59
64
  StandardId::Events.publish(
60
65
  StandardId::Events::SESSION_VALIDATING,
61
- session: api_session
66
+ session: api_session,
67
+ account: resolve_account(api_session, session_manager)
62
68
  )
63
69
  end
64
70
 
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.35.0"
2
+ VERSION = "0.36.0"
3
3
  end
@@ -27,7 +27,8 @@ module StandardId
27
27
  def emit_session_validating(browser_session, request)
28
28
  StandardId::Events.publish(
29
29
  StandardId::Events::SESSION_VALIDATING,
30
- session: browser_session
30
+ session: browser_session,
31
+ account: resolve_account(browser_session)
31
32
  )
32
33
  end
33
34
 
@@ -47,6 +48,37 @@ module StandardId
47
48
  expired_at: browser_session.expires_at
48
49
  )
49
50
  end
51
+
52
+ # SESSION_VALIDATING is the only one of the three emitters that runs
53
+ # BEFORE the blank/expired/revoked checks, so it cannot borrow the
54
+ # assumptions its siblings rely on:
55
+ #
56
+ # * the session may be nil (the blank? check has not run yet), so a
57
+ # bare `browser_session.account` would NoMethodError; and
58
+ # * it also fires for sessions the host app installed into
59
+ # `Current.session` itself, which — unlike SessionManager's
60
+ # `eager_load(:account)` path — carries no loaded :account
61
+ # association. Several consumers run `strict_loading_by_default`
62
+ # (jumpdrive-web, luminality-web), where a lazy `record.account`
63
+ # raises StrictLoadingViolationError. Since this emitter is on the
64
+ # path of EVERY authenticated request, that would turn an inert
65
+ # guard into a 500.
66
+ #
67
+ # So: read the association only when it is already loaded, and otherwise
68
+ # fall back to the same single indexed lookup Api::AuthenticationGuard's
69
+ # resolve_account uses. Neither branch can raise under strict loading.
70
+ def resolve_account(browser_session)
71
+ return if browser_session.blank?
72
+
73
+ if browser_session.respond_to?(:association) && browser_session.association(:account).loaded?
74
+ return browser_session.account
75
+ end
76
+
77
+ account_id = browser_session.account_id if browser_session.respond_to?(:account_id)
78
+ return if account_id.blank?
79
+
80
+ StandardId.account_class.find_by(id: account_id)
81
+ end
50
82
  end
51
83
  end
52
84
  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.35.0
4
+ version: 0.36.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim