standard_id 0.39.0 → 0.41.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: aafcc86281b43717bf4afeae67e469f9f773f62bb674137adce6b7924eb24be2
4
- data.tar.gz: 1236e399e761f545b7c2c749d44bd317f48355650b1f76accbf22704940f00d5
3
+ metadata.gz: 8cbe67c8d05fe6c7daeeb4d4a44562507e1d5104c32eeba94b701e98e6afe3ea
4
+ data.tar.gz: 8f9cb74cf37bb063c66547c7f563005f870a5a86509fe8f89b0bb57893f0ed82
5
5
  SHA512:
6
- metadata.gz: 0cbb9b1a8fd120918f0dbf986822b70990105c6e288ede4b8f2fc790b82cfa2f5ded7cbc774ae4e18afc6e03b29f40e93ffc736d8fd7021ce2ae81f10b03a59c
7
- data.tar.gz: dcbdbea82b00733fb9de47a2ba1b4f44362f819ee27ccac2bbb3b1c10573a225afac8d98c50644648ca9756d5a15ecc4262448ff899632f109e8510f2edf5fcd
6
+ metadata.gz: e8110399efd07b7b0455f2b8a5ee2c93acfb7dd9bb3847c1a7b43a78aec874b1709aebc05d7be37877007b81f19946322496745793da0418757ecd3de0349121
7
+ data.tar.gz: '08736fdbf31a259270c02c232f3a8e163c1dc80ca7f5fd3a4dba2168adb812aa91307bf662b7766bc0078b532bf549be64998342cf90f4e5c54048d31f2becd8'
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.41.0] - 2026-09-15
11
+
12
+ ### Added
13
+
14
+ - **Per-audience scope vocabulary hook.** An app can now declare, per audience, the set of scopes a client targeting that audience may request and be granted — the mechanism sidekick-web needs to express its per-audience MCP scope vocabularies (`mcp`, `mcp:read`, `mcp:eval:run`, …) through the gem instead of hard-coding a single flat `scopes_supported` list shared by every audience. This is the scope-side counterpart to the existing `audience_profile_types` binding: that binds an audience to a required profile, this binds an audience to a grantable scope vocabulary, and the two are configured and read the same way on purpose.
15
+
16
+ `c.oauth.audience_scopes` is a static `audience => Array<String>` map; `c.oauth.audience_scope_resolver` is an optional callable `->(audience:, client:, configured_scopes:) { … }` for apps that compute the vocabulary dynamically (per-client entitlements), filtered by arity like every other gem callable and returning `nil` to fall back to the static map. `StandardId::Oauth::AudienceScopeResolver` reads them and exposes `scopes_for`, `configured_for?`, `permits?`, `filter` (narrow a grant to the vocabulary), `disallowed`, and `assert!` (fail-closed, raising `InvalidScopeError` / RFC 6749 `invalid_scope`, naming only the client's offending scopes so the endpoint is not a vocabulary-enumeration oracle).
17
+
18
+ An **unconfigured** audience fails **open** everywhere — `filter`/`assert!` pass requested scopes through unchanged — exactly like `audience_profile_types` skips its check when unmapped, so an app that does not model per-audience vocabularies sees no behaviour change. The resolver ships ahead of any mint-/registration-time wiring so the hook API can be settled in review first, mirroring how `AudienceProfileResolver#resolve!` shipped before its strict path was wired.
19
+
20
+ ### Removed
21
+
22
+ - **The per-client `refresh_token_lifetime` column is dropped** (`standard_id_client_applications`), resolving the #765 asymmetry. It was never honoured: `TokenLifetimeResolver.refresh_token_lifetime` resolves the refresh-token lifetime **globally** from `oauth.refresh_token_lifetime` and has no per-client branch, so the column advertised a knob that did nothing. Refresh-token lifetime is a global policy by design — a client's re-authorization cadence is governed by **revocation**, not by a per-client lifetime (see the 0.39.x linked-session reasoning: "Revocation is the property the estate wants; lifetime remains `refresh_token_lifetime`'s job, which already exists and is already configured per host"). Access- and authorization-code lifetimes remain per-client.
23
+
24
+ **Migration:** consumers pick up `20260915000000_remove_refresh_token_lifetime_from_standard_id_client_applications` (idempotent, reversible). The global `oauth.refresh_token_lifetime` config is unchanged and continues to govern refresh-token lifetime for every client.
25
+
26
+ ## [0.40.0] - 2026-09-05
27
+
28
+ ### Fixed
29
+
30
+ - **`sign_in_account` now supersedes a nil memoised earlier in the same request.** With the `session_resolved` / `account_resolved` memo introduced in the entry below, a guard or shared prop that read `current_account` before the sign-in action ran would have kept returning nil for the rest of that request. Sign-in now memoises the new session and resets the account memo, so the next `current_account` re-derives the account through `load_current_account` — `config.account_scope` and `strict_loading!(false)` apply exactly as on an ordinary authenticated request (caught in review of #326 and #327).
31
+
32
+ - **An anonymous request no longer queries the session table on every `current_account` call, and no longer writes an empty Rails session.** `Web::SessionManager#current_session` memoised with `Current.session ||= …`, which never memoises a nil answer, so a visitor with no session paid the `BrowserSession.by_token(nil)` lookup on every read — shared props, guards, locale selection, nav helpers — five or six times per page. On one consumer's marketing homepage that was ~6 session-table queries per anonymous request and the app's single largest query by total time (fundbright/delivery-ops#598). Worse, the "no session" branch called `clear_session!`, whose `session.delete` loads the Rack session, and Rack persists every loaded session: a new empty session row and a `Set-Cookie` on every anonymous response, which is what stops a CDN from caching a public page.
33
+
34
+ Two new `Current` attributes, `session_resolved` and `account_resolved`, record that the question was answered (nil included) for the rest of the request. `load_session_from_session_token` returns without a query when there is no token, and only falls back to `session[:session_token]` when the Rails session is already loaded or its cookie is actually present on the request (`request.session_options[:key]`) — so a bare GET never allocates one. `load_session_from_remember_token` returns early without a `remember_token` cookie. `clear_session!` only touches the Rails session when it is loaded, and the no-session branch only clears at all when there is stale state to clear (a `session_token`/`remember_token` cookie, or session keys). Hosts that include `StandardId::CurrentAttributes` get the new attributes automatically; a host that hand-rolls `Current` must add them.
35
+
10
36
  ## [0.39.0] - 2026-08-10
11
37
 
12
38
  ### Added
data/README.md CHANGED
@@ -235,6 +235,38 @@ end
235
235
 
236
236
  Resolvers receive keyword arguments with the context containing `client`, `account`, and `request`, so you can reference only what you need. This lets you, for example, pull organization info off the client application or decorate claims with account attributes.
237
237
 
238
+ ### Per-Audience Scope Vocabulary
239
+
240
+ Declare, per audience, the set of scopes a client targeting that audience may request and be granted. This lets each audience expose its own MCP scope vocabulary rather than sharing one flat `scopes_supported` list. It is the scope-side counterpart to the `audience_profile_types` binding: that binds an audience to a required profile, this binds it to a grantable scope vocabulary.
241
+
242
+ ```ruby
243
+ StandardId.configure do |config|
244
+ config.oauth.audience_scopes = {
245
+ "harness" => %w[mcp mcp:read mcp:eval:run mcp:prompt:write],
246
+ "companion_kit" => %w[mcp mcp:read],
247
+ "admin_kit" => %w[mcp mcp:read mcp:admin]
248
+ }
249
+
250
+ # Optional: compute a vocabulary dynamically (e.g. per-client entitlements).
251
+ # Return an Array<String>, or nil to fall back to the static map above.
252
+ config.oauth.audience_scope_resolver = ->(audience:, client:, configured_scopes:) {
253
+ Entitlements.mcp_scopes_for(client, audience) || configured_scopes
254
+ }
255
+ end
256
+ ```
257
+
258
+ `StandardId::Oauth::AudienceScopeResolver` reads this config:
259
+
260
+ ```ruby
261
+ R = StandardId::Oauth::AudienceScopeResolver
262
+ R.scopes_for(audience: "companion_kit") # => ["mcp", "mcp:read"]
263
+ R.permits?(scope: "mcp:admin", audience: "companion_kit") # => false
264
+ R.filter(requested: %w[mcp mcp:admin], audience: "companion_kit") # => ["mcp"] (narrows, never raises)
265
+ R.assert!(requested: %w[mcp mcp:admin], audience: "companion_kit") # => raises InvalidScopeError
266
+ ```
267
+
268
+ An **unconfigured** audience fails **open** — `filter` and `assert!` pass the requested scopes through unchanged — so an app that does not model per-audience vocabularies sees no behaviour change; narrowing only bites once an audience has an explicit vocabulary.
269
+
238
270
  ### Social Login Setup
239
271
 
240
272
  The `social.google_*` and `social.apple_*` fields are declared by the **provider
@@ -19,8 +19,13 @@ module StandardId
19
19
  validates :scopes, presence: true
20
20
  validates :code_challenge_methods, presence: true, if: :require_pkce?
21
21
 
22
- # Lifecycle validations
23
- validates :access_token_lifetime, :refresh_token_lifetime, :authorization_code_lifetime,
22
+ # Lifecycle validations.
23
+ #
24
+ # Refresh-token lifetime is deliberately NOT per-client: it is resolved
25
+ # globally by TokenLifetimeResolver from `oauth.refresh_token_lifetime`, so
26
+ # there is no `refresh_token_lifetime` column to validate here (removed in
27
+ # migration 20260915000000, the #765 asymmetry fix).
28
+ validates :access_token_lifetime, :authorization_code_lifetime,
24
29
  presence: true, numericality: { greater_than: 0 }
25
30
 
26
31
  # Security: public clients cannot opt out of PKCE. Public clients run in
@@ -0,0 +1,23 @@
1
+ class RemoveRefreshTokenLifetimeFromStandardIdClientApplications < ActiveRecord::Migration[7.1]
2
+ # Drops the per-client `refresh_token_lifetime` column. It was never honoured:
3
+ # `TokenLifetimeResolver.refresh_token_lifetime` resolves the refresh-token
4
+ # lifetime GLOBALLY from `oauth.refresh_token_lifetime` and has no per-client
5
+ # branch, so the column advertised a knob that did nothing (the #765
6
+ # asymmetry). Refresh-token lifetime is a global policy by design — a client's
7
+ # session cadence is governed by revocation, not by a per-client lifetime
8
+ # (see CHANGELOG, "Revocation is the property the estate wants") — so the
9
+ # column is removed rather than wired up.
10
+ #
11
+ # Access- and authorization-code lifetimes remain per-client.
12
+ def up
13
+ return unless column_exists?(:standard_id_client_applications, :refresh_token_lifetime)
14
+
15
+ remove_column :standard_id_client_applications, :refresh_token_lifetime
16
+ end
17
+
18
+ def down
19
+ return if column_exists?(:standard_id_client_applications, :refresh_token_lifetime)
20
+
21
+ add_column :standard_id_client_applications, :refresh_token_lifetime, :integer, default: 2592000
22
+ end
23
+ end
@@ -341,6 +341,39 @@ StandardId::ConfigSchema.define do
341
341
  # and prefers an `active?`-responding record if multiple match.
342
342
  field :audience_profile_resolver, type: :any, default: nil
343
343
 
344
+ # Audience → scope-vocabulary binding (the per-audience MCP scope hook).
345
+ #
346
+ # Maps each configured audience string to the Array<String> of scopes a
347
+ # client targeting that audience may request and be granted. This is the
348
+ # scope-side counterpart to `audience_profile_types`: that binds an audience
349
+ # to a required profile, this binds an audience to a grantable scope
350
+ # vocabulary. `StandardId::Oauth::AudienceScopeResolver` reads it.
351
+ #
352
+ # Values may be a single space-delimited String or an Array<String>.
353
+ #
354
+ # Example:
355
+ # c.oauth.audience_scopes = {
356
+ # "harness" => %w[mcp mcp:read mcp:eval:run mcp:prompt:write],
357
+ # "companion_kit" => %w[mcp mcp:read],
358
+ # "admin_kit" => %w[mcp mcp:read mcp:admin]
359
+ # }
360
+ #
361
+ # When empty (default) or the matched audience is absent from the map, the
362
+ # vocabulary is empty and the resolver's enforcement helpers fail OPEN
363
+ # (requested scopes pass through unchanged) — back-compat with apps that do
364
+ # not model per-audience scope vocabularies.
365
+ field :audience_scopes, type: :hash, default: -> { {} }
366
+
367
+ # Optional resolver for computing an audience's scope vocabulary
368
+ # dynamically (e.g. per-client entitlements). Called with keyword arguments
369
+ # `(audience:, client:, configured_scopes:)` — any subset is accepted,
370
+ # arguments are filtered by arity — where `configured_scopes` is the
371
+ # `Array<String>` from `audience_scopes` for that audience. Must return an
372
+ # Array<String> (or nil to fall back to the static `audience_scopes` map).
373
+ #
374
+ # When nil (default), only the static `audience_scopes` map is consulted.
375
+ field :audience_scope_resolver, type: :any, default: nil
376
+
344
377
  # JWT signing configuration (for asymmetric algorithms)
345
378
  # If nil, uses HS256 with Rails.application.secret_key_base
346
379
  field :signing_key, type: :any, default: nil
@@ -4,6 +4,10 @@ module StandardId
4
4
 
5
5
  included do
6
6
  attribute :session, :account, :request_id, :ip_address, :user_agent, :scope
7
+ # Set once Web::SessionManager has answered "which session / account is
8
+ # this request?" — including when the answer is nil — so the lookup is
9
+ # never repeated within a request. Reset with the rest of Current.
10
+ attribute :session_resolved, :account_resolved
7
11
  end
8
12
  end
9
13
  end
@@ -0,0 +1,179 @@
1
+ module StandardId
2
+ module Oauth
3
+ # Resolves the scope VOCABULARY a given audience is allowed to express —
4
+ # the set of scope strings a client targeting that audience may request and
5
+ # be granted. This is the hook an app uses to declare its per-audience MCP
6
+ # scope vocabulary through the gem, rather than hard-coding a single flat
7
+ # `scopes_supported` list that every audience shares.
8
+ #
9
+ # It is the scope-side counterpart to `AudienceProfileResolver`
10
+ # (`c.oauth.audience_profile_types` + `c.oauth.audience_profile_resolver`):
11
+ # that one binds an audience to the PROFILE an account must hold; this one
12
+ # binds an audience to the SCOPES a client may carry. The two are read the
13
+ # same way and configured the same way, on purpose.
14
+ #
15
+ # Configuration (both optional, both default to "unconfigured"):
16
+ #
17
+ # # Static map: audience string => Array<String> of grantable scopes.
18
+ # c.oauth.audience_scopes = {
19
+ # "harness" => %w[mcp mcp:read mcp:eval:run mcp:prompt:write],
20
+ # "companion_kit" => %w[mcp mcp:read],
21
+ # "admin_kit" => %w[mcp mcp:read mcp:admin]
22
+ # }
23
+ #
24
+ # # Optional callable, for apps that compute the vocabulary dynamically
25
+ # # (e.g. per-client entitlements). Receives keyword args
26
+ # # `(audience:, client:, configured_scopes:)` — any subset is accepted,
27
+ # # arguments are filtered by arity — and must return an Array<String>
28
+ # # (or nil to fall back to the static map for that audience).
29
+ # c.oauth.audience_scope_resolver = ->(audience:, client:, **) {
30
+ # Entitlements.mcp_scopes_for(client, audience)
31
+ # }
32
+ #
33
+ # When an audience is UNCONFIGURED (absent from the map and the resolver
34
+ # returns nil / is unset), the vocabulary is empty and the enforcement
35
+ # helpers below fail OPEN — they pass the requested scopes through
36
+ # unchanged. This mirrors `audience_profile_types`: an app that does not
37
+ # model per-audience scope vocabularies sees no behaviour change. Narrowing
38
+ # only bites once an audience has an explicit vocabulary.
39
+ #
40
+ # @example
41
+ # StandardId::Oauth::AudienceScopeResolver.filter(
42
+ # requested: %w[mcp mcp:admin openid],
43
+ # audience: "companion_kit"
44
+ # ) # => ["mcp"] (mcp:admin + openid are not in companion_kit's vocabulary)
45
+ module AudienceScopeResolver
46
+ class << self
47
+ # The scope vocabulary for `audience` as an Array<String>.
48
+ #
49
+ # Resolution order:
50
+ # 1. the configured callable `audience_scope_resolver`, when set and
51
+ # it returns a non-nil value — filtered by arity like every other
52
+ # gem callable, and handed the static-map value as
53
+ # `configured_scopes:` so it can extend rather than replace it;
54
+ # 2. otherwise the static `audience_scopes` map entry;
55
+ # 3. otherwise `[]` (unconfigured).
56
+ #
57
+ # Always returns a de-duplicated Array<String> with blanks removed.
58
+ #
59
+ # @param audience [String, Symbol, nil]
60
+ # @param client [Object, nil] the ClientApplication in play, when known;
61
+ # passed through to the resolver callable for per-client vocabularies.
62
+ # @return [Array<String>]
63
+ def scopes_for(audience:, client: nil)
64
+ return [] if audience.blank?
65
+
66
+ configured = static_scopes_for(audience)
67
+
68
+ resolver = StandardId.config.oauth.audience_scope_resolver
69
+ if resolver.respond_to?(:call)
70
+ filtered = StandardId::Utils::CallableParameterFilter.filter(
71
+ resolver,
72
+ { audience: audience.to_s, client: client, configured_scopes: configured }
73
+ )
74
+ resolved = resolver.call(**filtered)
75
+ return normalize(resolved) unless resolved.nil?
76
+ end
77
+
78
+ configured
79
+ end
80
+
81
+ # True when `audience` has a non-empty scope vocabulary. Callers use
82
+ # this to distinguish "audience is unconfigured, pass everything" from
83
+ # "audience is configured with an empty vocabulary, allow nothing".
84
+ def configured_for?(audience, client: nil)
85
+ scopes_for(audience: audience, client: client).any?
86
+ end
87
+
88
+ # True when `scope` is within `audience`'s vocabulary. An unconfigured
89
+ # audience permits every scope (fail-open) — see the module note.
90
+ #
91
+ # @param scope [String, Symbol]
92
+ def permits?(scope:, audience:, client: nil)
93
+ return false if scope.blank?
94
+
95
+ vocabulary = scopes_for(audience: audience, client: client)
96
+ return true if vocabulary.empty? # unconfigured -> fail open
97
+
98
+ vocabulary.include?(scope.to_s)
99
+ end
100
+
101
+ # The subset of `requested` that `audience`'s vocabulary permits, in the
102
+ # requested order, de-duplicated. An unconfigured audience returns the
103
+ # requested scopes unchanged (fail-open). Use this to NARROW a grant
104
+ # down to what the audience allows without raising.
105
+ #
106
+ # @param requested [Array<String>, String] space-delimited String or Array
107
+ # @return [Array<String>]
108
+ def filter(requested:, audience:, client: nil)
109
+ req = normalize(requested)
110
+ vocabulary = scopes_for(audience: audience, client: client)
111
+ return req if vocabulary.empty? # unconfigured -> pass through
112
+
113
+ allowed = vocabulary.to_set
114
+ req.select { |s| allowed.include?(s) }
115
+ end
116
+
117
+ # The scopes in `requested` that `audience`'s vocabulary does NOT permit.
118
+ # Empty for an unconfigured audience (nothing is out of vocabulary when
119
+ # there is no vocabulary).
120
+ #
121
+ # @return [Array<String>]
122
+ def disallowed(requested:, audience:, client: nil)
123
+ req = normalize(requested)
124
+ vocabulary = scopes_for(audience: audience, client: client)
125
+ return [] if vocabulary.empty?
126
+
127
+ allowed = vocabulary.to_set
128
+ req.reject { |s| allowed.include?(s) }
129
+ end
130
+
131
+ # Strict, fail-closed variant for mint / registration enforcement.
132
+ #
133
+ # Returns the requested scopes (normalized) when every one is within the
134
+ # audience's vocabulary, and raises `StandardId::InvalidScopeError` —
135
+ # which renders as RFC 6749 `invalid_scope` — the moment one is not. An
136
+ # unconfigured audience is a no-op pass-through (fail-open), so wiring
137
+ # this into a mint path does not change behaviour for audiences that
138
+ # have not opted in.
139
+ #
140
+ # The error message names the offending scopes (which are the CLIENT's
141
+ # own request, not internal taxonomy) but never the full vocabulary, so
142
+ # the endpoint does not become a scope-enumeration oracle.
143
+ #
144
+ # @raise [StandardId::InvalidScopeError]
145
+ # @return [Array<String>]
146
+ def assert!(requested:, audience:, client: nil)
147
+ bad = disallowed(requested: requested, audience: audience, client: client)
148
+ return normalize(requested) if bad.empty?
149
+
150
+ raise StandardId::InvalidScopeError,
151
+ "Scope(s) not permitted for audience '#{audience}': #{bad.join(', ')}"
152
+ end
153
+
154
+ private
155
+
156
+ def static_scopes_for(audience)
157
+ mapping = StandardId.config.oauth.audience_scopes || {}
158
+ return [] if mapping.empty?
159
+
160
+ normalize(mapping[audience.to_s] || mapping[audience.to_sym])
161
+ end
162
+
163
+ # Coerce a String (space-delimited) or Array into a clean, de-duplicated
164
+ # Array<String>. Matches the space-delimited scope convention used by
165
+ # ClientApplication#scopes_array and RFC 6749.
166
+ def normalize(value)
167
+ list =
168
+ case value
169
+ when nil then []
170
+ when Array then value
171
+ else value.to_s.split(/\s+/)
172
+ end
173
+
174
+ list.map { |s| s.to_s.strip }.reject(&:blank?).uniq
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -12,7 +12,6 @@ FactoryBot.define do
12
12
  require_pkce { true }
13
13
  code_challenge_methods { "S256" }
14
14
  access_token_lifetime { 3600 }
15
- refresh_token_lifetime { 2_592_000 }
16
15
  authorization_code_lifetime { 600 }
17
16
  active { true }
18
17
 
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.39.0"
2
+ VERSION = "0.41.0"
3
3
  end
@@ -11,12 +11,25 @@ module StandardId
11
11
  @reset_session = reset_session
12
12
  end
13
13
 
14
+ # Both readers memoise their answer for the rest of the request — including
15
+ # a nil answer. `Current.session ||= …` never did: an anonymous visitor has
16
+ # no session, so every `current_account` call (shared props, before_actions,
17
+ # locale selection, nav helpers — five or six per page) re-ran the lookup.
18
+ # On one consumer's marketing homepage that was ~6 session-table queries
19
+ # per anonymous request, the app's single largest query by total time
20
+ # (fundbright/delivery-ops#598).
14
21
  def current_session
15
- Current.session ||= load_current_session
22
+ return Current.session if Current.session_resolved
23
+
24
+ Current.session_resolved = true
25
+ load_current_session
16
26
  end
17
27
 
18
28
  def current_account
19
- Current.account ||= load_current_account
29
+ return Current.account if Current.account_resolved
30
+
31
+ Current.account_resolved = true
32
+ Current.account = load_current_account
20
33
  end
21
34
 
22
35
  def sign_in_account(account, scope_name: nil)
@@ -41,7 +54,18 @@ module StandardId
41
54
  scopes << scope_name.to_s unless scopes.include?(scope_name.to_s)
42
55
  session[:standard_id_scopes] = scopes
43
56
  end
57
+ # Sign-in supersedes whatever this request already resolved: a guard
58
+ # or shared prop that asked `current_account` before the sign-in
59
+ # action ran memoised nil, and that memo must not outlive the sign-in.
60
+ # The session is known here, so memoise it; the account is NOT
61
+ # assigned directly — the memo is reset so the next `current_account`
62
+ # re-derives it through load_current_account, which applies
63
+ # `config.account_scope` and `strict_loading!(false)` exactly as an
64
+ # ordinary authenticated request would (review of #327).
44
65
  Current.session = browser_session
66
+ Current.session_resolved = true
67
+ Current.account = nil
68
+ Current.account_resolved = false
45
69
  emit_session_created(browser_session, account, "browser")
46
70
  end
47
71
  end
@@ -61,8 +85,18 @@ module StandardId
61
85
 
62
86
  def clear_session!
63
87
  # TODO: make token key names configurable
64
- session.delete(:session_token)
65
- session.delete(:standard_id_scopes)
88
+ #
89
+ # Only touch the Rails session when it is already loaded. Reading or
90
+ # deleting a key on an unloaded Rack session loads it, and Rack commits
91
+ # every loaded session on the way out — so on an anonymous request this
92
+ # used to write a brand-new empty session to the store and stamp a
93
+ # session cookie on the response, which is what stops any CDN from
94
+ # caching a public page. If the session was never loaded there is nothing
95
+ # in it to clear.
96
+ if rails_session_loaded?
97
+ session.delete(:session_token)
98
+ session.delete(:standard_id_scopes)
99
+ end
66
100
  # Delete the cookie outright. Assigning `cookies.encrypted[:session_token] = nil`
67
101
  # writes a fresh encrypted blob through the jar's default options (no httponly)
68
102
  # on every unauthenticated request, leaving a confusing non-HttpOnly
@@ -72,10 +106,32 @@ module StandardId
72
106
  cookies.delete(:remember_token)
73
107
 
74
108
  Current.session = nil
109
+ Current.account = nil
110
+ Current.session_resolved = true
111
+ Current.account_resolved = true
75
112
  end
76
113
 
77
114
  private
78
115
 
116
+ # A Rack session reports `loaded?`; the plain Hash the specs (and some
117
+ # hosts' test doubles) hand in does not, and a Hash is always "loaded".
118
+ def rails_session_loaded?
119
+ !session.respond_to?(:loaded?) || session.loaded?
120
+ end
121
+
122
+ # Whether the request carries the host app's Rails session cookie at all.
123
+ # Reading `session[...]` when it does not would make Rack allocate and
124
+ # then persist an empty session (see clear_session!). Without a request
125
+ # that can answer, assume it does — the old behaviour.
126
+ def rails_session_cookie_present?
127
+ return true unless request.respond_to?(:session_options) && request.respond_to?(:cookies)
128
+
129
+ key = request.session_options[:key]
130
+ return true if key.blank?
131
+
132
+ request.cookies.key?(key.to_s)
133
+ end
134
+
79
135
  # Persist the session token in an encrypted cookie whose lifetime matches
80
136
  # the DB session's expires_at, so an authenticated session survives a full
81
137
  # browser restart (a bare session cookie would be cleared on browser close,
@@ -117,19 +173,32 @@ module StandardId
117
173
  clear_session!
118
174
  end
119
175
  else
120
- clear_session!
176
+ # Nothing identified a session. Clear stale state only when there is
177
+ # some — a bare anonymous GET has no cookies to delete and no session
178
+ # to touch, and touching it is what makes the response uncacheable.
179
+ clear_session! if stale_session_state?
121
180
  end
122
181
 
123
182
  Current.session
124
183
  end
125
184
 
185
+ def stale_session_state?
186
+ cookies.encrypted[:session_token].present? || cookies[:session_token].present? || cookies[:remember_token].present? ||
187
+ (rails_session_loaded? && (session[:session_token].present? || session[:standard_id_scopes].present?))
188
+ end
189
+
126
190
  def load_session_from_session_token
127
191
  # Try encrypted cookie first (for Action Cable), then fall back to session (for backward compatibility)
128
- session_token = cookies.encrypted[:session_token] || session[:session_token]
192
+ session_token = cookies.encrypted[:session_token]
193
+ session_token ||= session[:session_token] if rails_session_loaded? || rails_session_cookie_present?
194
+ return if session_token.blank?
195
+
129
196
  StandardId::BrowserSession.eager_load(:account).by_token(session_token).first
130
197
  end
131
198
 
132
199
  def load_session_from_remember_token
200
+ return if cookies[:remember_token].blank?
201
+
133
202
  password_credential = StandardId::PasswordCredential.find_by_token_for(:remember_me, cookies[:remember_token])
134
203
  return if password_credential.blank?
135
204
 
data/lib/standard_id.rb CHANGED
@@ -29,6 +29,7 @@ require "standard_id/api/token_manager"
29
29
  require "standard_id/api/authentication_guard"
30
30
  require "standard_id/utils/callable_parameter_filter"
31
31
  require "standard_id/oauth/audience_profile_resolver"
32
+ require "standard_id/oauth/audience_scope_resolver"
32
33
  require "standard_id/oauth/base_request_flow"
33
34
  require "standard_id/oauth/token_lifetime_resolver"
34
35
  require "standard_id/oauth/oauth_session_persistence"
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.39.0
4
+ version: 0.41.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -230,6 +230,7 @@ files:
230
230
  - db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb
231
231
  - db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb
232
232
  - db/migrate/20260611000000_create_standard_id_client_grants.rb
233
+ - db/migrate/20260915000000_remove_refresh_token_lifetime_from_standard_id_client_applications.rb
233
234
  - lib/generators/standard_id/install/install_generator.rb
234
235
  - lib/generators/standard_id/install/templates/standard_id.rb
235
236
  - lib/standard_id.rb
@@ -261,6 +262,7 @@ files:
261
262
  - lib/standard_id/http_client.rb
262
263
  - lib/standard_id/jwt_service.rb
263
264
  - lib/standard_id/oauth/audience_profile_resolver.rb
265
+ - lib/standard_id/oauth/audience_scope_resolver.rb
264
266
  - lib/standard_id/oauth/authorization_code_authorization_flow.rb
265
267
  - lib/standard_id/oauth/authorization_code_flow.rb
266
268
  - lib/standard_id/oauth/authorization_flow.rb