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 +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +32 -0
- data/app/models/standard_id/client_application.rb +7 -2
- data/db/migrate/20260915000000_remove_refresh_token_lifetime_from_standard_id_client_applications.rb +23 -0
- data/lib/standard_id/config/schema.rb +33 -0
- data/lib/standard_id/current_attributes.rb +4 -0
- data/lib/standard_id/oauth/audience_scope_resolver.rb +179 -0
- data/lib/standard_id/testing/factories/oauth.rb +0 -1
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id/web/session_manager.rb +75 -6
- data/lib/standard_id.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8cbe67c8d05fe6c7daeeb4d4a44562507e1d5104c32eeba94b701e98e6afe3ea
|
|
4
|
+
data.tar.gz: 8f9cb74cf37bb063c66547c7f563005f870a5a86509fe8f89b0bb57893f0ed82
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
data/db/migrate/20260915000000_remove_refresh_token_lifetime_from_standard_id_client_applications.rb
ADDED
|
@@ -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
|
data/lib/standard_id/version.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
65
|
-
session.
|
|
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
|
-
|
|
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]
|
|
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.
|
|
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
|