standard_id 0.40.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: 89d297a5e9c69566991b97cca501fd5a714b2d2e1200d2e740b3e45684981aab
4
- data.tar.gz: d93fc7206735373f14ac2cd51c4612aaf7b1d19244022d884f0c84757d8f78c6
3
+ metadata.gz: 8cbe67c8d05fe6c7daeeb4d4a44562507e1d5104c32eeba94b701e98e6afe3ea
4
+ data.tar.gz: 8f9cb74cf37bb063c66547c7f563005f870a5a86509fe8f89b0bb57893f0ed82
5
5
  SHA512:
6
- metadata.gz: 1ce536a136af167e65ec9d01e370a187d71f77a2f60f0c5af47ee5c23a9b58cb87444b95191a019fa06689902765f51eaff20656aeedcdef288e34f577c99c91
7
- data.tar.gz: f825db9970c937a76592c421fb71a9607f3ea0667dfafa105ff2d6ce78bbd754f5ca01f873cf3321962876c3ab0f8c7f7314ec95604cf5900d56291cede6ec57
6
+ metadata.gz: e8110399efd07b7b0455f2b8a5ee2c93acfb7dd9bb3847c1a7b43a78aec874b1709aebc05d7be37877007b81f19946322496745793da0418757ecd3de0349121
7
+ data.tar.gz: '08736fdbf31a259270c02c232f3a8e163c1dc80ca7f5fd3a4dba2168adb812aa91307bf662b7766bc0078b532bf549be64998342cf90f4e5c54048d31f2becd8'
data/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ 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
+
10
26
  ## [0.40.0] - 2026-09-05
11
27
 
12
28
  ### Fixed
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
@@ -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.40.0"
2
+ VERSION = "0.41.0"
3
3
  end
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.40.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