standard_id 0.34.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 +4 -4
- data/CHANGELOG.md +46 -0
- data/app/controllers/standard_id/api/oauth/introspections_controller.rb +20 -3
- data/app/controllers/standard_id/api/oauth/revocations_controller.rb +18 -1
- data/lib/standard_id/api/authentication_guard.rb +9 -3
- data/lib/standard_id/oauth/refresh_token_flow.rb +40 -1
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id/web/authentication_guard.rb +33 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7dd8850abb672f638f6db5cf4d60ae8ec64b26bdb8d5835405c72e7bebc4d800
|
|
4
|
+
data.tar.gz: 69575f6669362c905ed16059f35f15044f1ee2bbade449b6dc5c62bfd93caeb5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 87ed7e67e16666c2c576a2e7bb55e43ff2a861ecf93c824d5995da4d8f383d09f4bb60e8b389e9821e2d85acfb849690548cc0e6fbfabaa3e73d0f02dd44015c
|
|
7
|
+
data.tar.gz: 9036f22d16ee08ac97a4e5d6ae46d8c6fc7c63130da4ed66a815dfef2b21ecce787f576cc2127dbd6040405e6a6dbd2f4f71c5303d1c6e803770347af6d85b9f
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,52 @@ 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
|
+
|
|
36
|
+
## [0.35.0] - 2026-07-31
|
|
37
|
+
|
|
38
|
+
### Security
|
|
39
|
+
|
|
40
|
+
- **A refresh token is now refused when its parent session is revoked or expired.** `Oauth::RefreshTokenFlow` validated the `RefreshToken` row only — found, not revoked, not expired — and never consulted the session it belongs to.
|
|
41
|
+
|
|
42
|
+
So "revoking a session ends that session's access" was not a property of the gem. It was an emergent consequence of every caller reaching for `Session#revoke!` (a transaction that ALSO does `refresh_tokens.active.update_all(revoked_at:)`) rather than the obvious-looking `session.update!(revoked_at: ...)`, which revokes the session row and leaves its refresh tokens live. Two apps wrote the second form independently and shipped it (rarebit-one/nutripod-web#1100, luminalityai/luminality-web#1048), because the natural spec asserts the session's own `revoked_at` — exactly the half that does work.
|
|
43
|
+
|
|
44
|
+
On the device path that meant a refresh-token holder kept minting access tokens after the user had explicitly signed that device out. Checking the parent in the flow makes the property true by construction: it now holds however the session was revoked — `revoke!`, a bare `update!`, a bulk `update_all`, a DBA, a data fix — and covers session expiry too.
|
|
45
|
+
|
|
46
|
+
**Unaffected:** refresh tokens with no session (`session_id` nil) — the machine-to-machine shape, including `client_credentials`. There is no parent to outlive, so nothing is checked. No configuration flag was added because no flow in the gem legitimately needs a refresh to outlive its session; every token that carries a `session_id` was minted against that session, and rotation carries the same `session_id` forward.
|
|
47
|
+
|
|
48
|
+
The refusal reuses the existing `invalid_grant` / `"Refresh token is no longer valid"` response verbatim (RFC 6749 §5.2). A distinct error would be an oracle — it would tell a holder that the token itself is still good and only the session was pulled.
|
|
49
|
+
|
|
50
|
+
The parent session is fetched by `eager_load` in the same query as the token row, so the hot `/oauth/token` path gains no extra round trip.
|
|
51
|
+
|
|
52
|
+
Tracked in rarebit-one/rarebit-ops#297.
|
|
53
|
+
|
|
54
|
+
- **`POST /oauth/introspect` reports the same refresh token as `active: false`.** The RFC 7662 endpoint (off by default, behind `config.oauth.introspection_enabled`) checked the `RefreshToken` row and not its session, so it had the identical gap. Left alone it would have reported `active: true` for a token `/oauth/token` now refuses — introspection contradicting the endpoint it describes. Access tokens are unchanged and still introspect as active until their `exp`; that documented limit is unaffected, because they are stateless and carry no `sid`.
|
|
55
|
+
|
|
10
56
|
## [0.34.0] - 2026-07-31
|
|
11
57
|
|
|
12
58
|
### Added
|
|
@@ -32,8 +32,12 @@ module StandardId
|
|
|
32
32
|
# lifetime.
|
|
33
33
|
#
|
|
34
34
|
# Refresh tokens ARE persisted (as `SHA256(jti)`), so those are checked
|
|
35
|
-
# against the row
|
|
36
|
-
#
|
|
35
|
+
# against the row AND against the parent session: a refresh token that is
|
|
36
|
+
# itself revoked or expired — or whose session is — introspects as
|
|
37
|
+
# inactive, correctly and immediately. That second half matters because
|
|
38
|
+
# the session can be revoked without the cascade ever running (a bare
|
|
39
|
+
# `update!`, a bulk `update_all`); see Oauth::RefreshTokenFlow and
|
|
40
|
+
# rarebit-one/rarebit-ops#297.
|
|
37
41
|
class IntrospectionsController < BaseController
|
|
38
42
|
public_controller
|
|
39
43
|
|
|
@@ -86,8 +90,21 @@ module StandardId
|
|
|
86
90
|
return render_inactive if payload.nil?
|
|
87
91
|
|
|
88
92
|
# Persisted refresh tokens are checkable; access tokens are not.
|
|
89
|
-
|
|
93
|
+
#
|
|
94
|
+
# eager_load the parent session in the same query: the answer must
|
|
95
|
+
# match what Oauth::RefreshTokenFlow would do with the same token, and
|
|
96
|
+
# that flow refuses a refresh whose session is revoked or expired
|
|
97
|
+
# (rarebit-one/rarebit-ops#297). Reporting `active: true` here for a
|
|
98
|
+
# token /oauth/token would refuse would make introspection an
|
|
99
|
+
# authority that disagrees with the endpoint it describes.
|
|
100
|
+
#
|
|
101
|
+
# A nil session means no parent — the machine-to-machine shape — and
|
|
102
|
+
# is not a reason to call the token inactive.
|
|
103
|
+
if payload[:jti].present?
|
|
104
|
+
persisted = StandardId::RefreshToken.eager_load(:session).find_by_jti(payload[:jti].to_s)
|
|
105
|
+
end
|
|
90
106
|
return render_inactive if persisted && !persisted.active?
|
|
107
|
+
return render_inactive if persisted&.session && !persisted.session.active?
|
|
91
108
|
|
|
92
109
|
render json: active_response(payload, persisted), status: :ok
|
|
93
110
|
end
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
|
@@ -47,7 +47,11 @@ module StandardId
|
|
|
47
47
|
# once all pre-jti tokens have expired (refresh_token_lifetime after deploy).
|
|
48
48
|
return if jti.blank?
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
# eager_load (not includes/lazy) so the parent session arrives in the
|
|
51
|
+
# SAME query via a LEFT OUTER JOIN. #validate_parent_session! below
|
|
52
|
+
# reads it on every refresh, and /oauth/token is a hot path — a lazy
|
|
53
|
+
# `.session` would add a second SELECT per request.
|
|
54
|
+
@current_refresh_token_record = StandardId::RefreshToken.eager_load(:session).find_by_jti(jti)
|
|
51
55
|
|
|
52
56
|
unless @current_refresh_token_record
|
|
53
57
|
raise StandardId::InvalidGrantError, "Refresh token not found"
|
|
@@ -63,6 +67,41 @@ module StandardId
|
|
|
63
67
|
unless @current_refresh_token_record.active?
|
|
64
68
|
raise StandardId::InvalidGrantError, "Refresh token is no longer valid"
|
|
65
69
|
end
|
|
70
|
+
|
|
71
|
+
validate_parent_session!
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Refuse a refresh whose parent session is no longer active.
|
|
75
|
+
#
|
|
76
|
+
# Without this, "revoking a session ends that session's access" was not a
|
|
77
|
+
# property of the gem at all — it was an emergent consequence of every
|
|
78
|
+
# caller reaching for Session#revoke! (a transaction that ALSO does
|
|
79
|
+
# `refresh_tokens.active.update_all(revoked_at:)`) rather than the
|
|
80
|
+
# obvious-looking `session.update!(revoked_at:)`, which revokes the
|
|
81
|
+
# session row and leaves its refresh tokens live. Two apps wrote the
|
|
82
|
+
# second form independently and shipped it, because a spec asserting the
|
|
83
|
+
# session's own `revoked_at` passes against the buggy code.
|
|
84
|
+
#
|
|
85
|
+
# Checking the parent here makes the property true by construction: it
|
|
86
|
+
# holds however the session was revoked — `revoke!`, a bare `update!`, a
|
|
87
|
+
# bulk `update_all`, a DBA, a data fix — and it covers expiry as well.
|
|
88
|
+
# See rarebit-one/rarebit-ops#297.
|
|
89
|
+
#
|
|
90
|
+
# A refresh token with no session (`session_id` nil) is unaffected: that
|
|
91
|
+
# is the machine-to-machine shape (client_credentials and any other grant
|
|
92
|
+
# the gem issues without persisting a session), where there is no parent
|
|
93
|
+
# to outlive and nothing to check.
|
|
94
|
+
#
|
|
95
|
+
# The message is deliberately IDENTICAL to the inactive-token case above.
|
|
96
|
+
# A distinct error would be an oracle: it would tell an attacker holding a
|
|
97
|
+
# stolen refresh token that the token itself is still good and only the
|
|
98
|
+
# session was pulled. RFC 6749 §5.2 wants `invalid_grant` either way.
|
|
99
|
+
def validate_parent_session!
|
|
100
|
+
session = @current_refresh_token_record.session
|
|
101
|
+
return if session.nil?
|
|
102
|
+
return if session.active?
|
|
103
|
+
|
|
104
|
+
raise StandardId::InvalidGrantError, "Refresh token is no longer valid"
|
|
66
105
|
end
|
|
67
106
|
|
|
68
107
|
# Atomically revoke the current token as part of rotation.
|
data/lib/standard_id/version.rb
CHANGED
|
@@ -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
|