standard_id 0.33.0 → 0.35.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: c5341add412ad2ef395a4252d31f751e5b9eeb9df585f1eb3e5c276d7071211f
4
- data.tar.gz: b53b6dc29f5aa4a5994b040f5a6cdca2441c4c0c57cb2449b1fc1aead9caf4a7
3
+ metadata.gz: e5e5289f6f264b4175abb757c1423d9fc656e5b2e5057239e7486d843a3eaf7a
4
+ data.tar.gz: 209d3a72e8a16049ef1e633216c38939e69518e526baea0110dd99567ac23083
5
5
  SHA512:
6
- metadata.gz: a4162b488000bc8e889dba2887ffe2257ad8ac342d7612aff936c74fccbf30f22560f4194c39e0552446e3e6a93b2d335c2d34b3d27ceb67586fdeb188de5181
7
- data.tar.gz: fd04fc771429eaf0ed9efd4fa01fa85263983be3dd8cdf9a5b3df16194959387d929aa7bcf27711681dfb0262c3d57fd1df11f64761128646f43d9bb495ad8da
6
+ metadata.gz: bdc0a65782aa7ea1299b49b0eb946dc72e56391de6e7d5132b65e59e27281d9d16a5ef28e1d75fadca282768d853a95d703b1c81278b8008699ddfeb05ab02e1
7
+ data.tar.gz: ed73c2092285091f034f9d0b33423e4013004a6e129c37220d8a004f94e3c9bb79b1d9d95679f4e18f126db28243201f80cc7126780225e5114b6439a74ff214
data/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.35.0] - 2026-07-31
11
+
12
+ ### Security
13
+
14
+ - **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.
15
+
16
+ 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.
17
+
18
+ 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.
19
+
20
+ **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.
21
+
22
+ 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.
23
+
24
+ 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.
25
+
26
+ Tracked in rarebit-one/rarebit-ops#297.
27
+
28
+ - **`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`.
29
+
30
+ ## [0.34.0] - 2026-07-31
31
+
32
+ ### Added
33
+
34
+ - **`config.verify_issuer`** — decouples MINTING an `iss` claim from REQUIRING one. `nil` (default) follows `config.issuer`, which is the historic behaviour and changes nothing. Set `false` to stamp `iss` on new tokens without yet verifying it.
35
+
36
+ This exists because the two were one switch, which made adopting an issuer a flag day: every token already in flight was minted without an `iss`, so setting `config.issuer` rejected all of them at once — every access token and, far worse, every refresh token. An app that had never configured an issuer had no safe single step, which is where `nutripod-web` was stuck (rarebit-one/nutripod-web#1111) and why it could not retire its hand-rolled discovery controller with the rest of the estate.
37
+
38
+ Migration: set `issuer` with `verify_issuer = false`, wait out `refresh_token_lifetime` (the long pole — access tokens are short), then remove the override. Setting `true` with no issuer raises at boot rather than silently verifying against `nil` and accepting anything.
39
+
10
40
  ## [0.33.0] - 2026-07-30
11
41
 
12
42
  ### 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: a revoked or expired refresh token introspects as
36
- # inactive, correctly and immediately.
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
- persisted = StandardId::RefreshToken.find_by_jti(payload[:jti].to_s) if payload[:jti].present?
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
@@ -12,6 +12,26 @@ StandardId::ConfigSchema.define do
12
12
  field :passwordless_email_sender, type: :any, default: nil
13
13
  field :passwordless_sms_sender, type: :any, default: nil
14
14
  field :issuer, type: :string, default: nil
15
+
16
+ # Whether `JwtService.decode` REQUIRES a matching `iss` claim.
17
+ #
18
+ # `nil` (default) means "follow the issuer": verification is on exactly
19
+ # when `issuer` is set. That is the historic behaviour and is what you
20
+ # want once an issuer has always been configured.
21
+ #
22
+ # Set `false` to MINT an `iss` claim without yet REQUIRING one. This
23
+ # exists because the two were coupled, and that coupling makes adopting an
24
+ # issuer an all-or-nothing flag day: every token already in flight was
25
+ # minted without an `iss`, so turning the issuer on rejected all of them at
26
+ # once — every access token and, far worse, every refresh token. For an app
27
+ # that had never set an issuer there was no safe single step, which is
28
+ # exactly the position nutripod-web was stuck in (rarebit-one/nutripod-web#1111).
29
+ #
30
+ # The migration is then: set `issuer` with `verify_issuer = false`, wait
31
+ # out `refresh_token_lifetime` (the long pole — access tokens are short),
32
+ # then remove the override. Setting `true` with no issuer raises at boot
33
+ # rather than silently verifying nothing.
34
+ field :verify_issuer, type: :boolean, default: nil
15
35
  field :login_url, type: :string, default: nil
16
36
  field :allowed_post_logout_redirect_uris, type: :array, default: []
17
37
  field :account_scope, type: :any, default: nil
@@ -52,6 +52,30 @@ module StandardId
52
52
  SUPPORTED_ALGORITHMS[algorithm] || raise(ArgumentError, "Unsupported algorithm: #{algorithm}. Supported: #{SUPPORTED_ALGORITHMS.keys.join(', ')}")
53
53
  end
54
54
 
55
+ # Whether decode REQUIRES a matching `iss`. `nil` (the default) follows the
56
+ # issuer, which is the historic behaviour: verification on exactly when an
57
+ # issuer is configured.
58
+ #
59
+ # The override exists so an app can START MINTING an `iss` without yet
60
+ # REQUIRING one. Coupled, adopting an issuer is a flag day — every token
61
+ # already in flight was minted without the claim, so enabling the issuer
62
+ # rejects all of them at once, refresh tokens included. Decoupled, the app
63
+ # sets `verify_issuer = false`, waits out `refresh_token_lifetime`, and then
64
+ # drops the override with no window in which valid tokens are refused.
65
+ def self.verify_issuer?
66
+ configured = StandardId.config.verify_issuer
67
+ return StandardId.config.issuer.present? if configured.nil?
68
+
69
+ if configured && StandardId.config.issuer.blank?
70
+ raise StandardId::ConfigurationError,
71
+ "verify_issuer is true but no issuer is configured — decode would " \
72
+ "verify against nil and accept any token. Set config.issuer, or " \
73
+ "leave verify_issuer nil to follow it."
74
+ end
75
+
76
+ configured
77
+ end
78
+
55
79
  def self.asymmetric?
56
80
  algorithm_config[:type] == :asymmetric
57
81
  end
@@ -152,7 +176,7 @@ module StandardId
152
176
  def self.decode(token, allowed_audiences: nil)
153
177
  options = { algorithms: [algorithm] }
154
178
 
155
- if StandardId.config.issuer.present?
179
+ if StandardId.config.issuer.present? && verify_issuer?
156
180
  options[:iss] = StandardId.config.issuer
157
181
  options[:verify_iss] = true
158
182
  end
@@ -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
- @current_refresh_token_record = StandardId::RefreshToken.find_by_jti(jti)
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.
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.33.0"
2
+ VERSION = "0.35.0"
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.33.0
4
+ version: 0.35.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim