standard_id 0.49.0 → 0.50.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: 960b6bdf3cc6fd613e1f3cdb6a172f9b30fb42f2c215174787f7858f0c532075
4
- data.tar.gz: 654fde0c77971e868578805a3d033909adbe49da8006ca1f1174110975e2b255
3
+ metadata.gz: f6f93f66f3e1a4d95ac6effab590e560e39f2993dd072010676a3121efec6098
4
+ data.tar.gz: 6934ab15f5b9c394db6d15eb37ca91d41965421589553135c2a05fea1ebbf551
5
5
  SHA512:
6
- metadata.gz: f5a099d1243e7ac19872b907c402937941a3718efec2caffa7fc75af717893bc6b685671d528929e99c658bc08187cb58d776fc36c744978190f1c850bf5c457
7
- data.tar.gz: db01eff1f65d191da0166de6f09def5231d88a9675d6240efd0d2c0eddd02b91553b2aad9f1d062d026fccfcf9d17ee1ffca4e04c2b6e1cfe4b627229d8a56be
6
+ metadata.gz: 73eab42991c65471c2f77d7be554b4ec1d002eacb73e62d8d77bc79003b5121f4b64bd9750ce8991113ff451556313f54165cfd75b642501251dcb947c0f3900
7
+ data.tar.gz: 6a0b2b9e4628842eb1ab6d56acf348c829abc083a00e29671c3efadca5dc57e56aa63d958577c884a7ca224f6e768aebedc66755d5c1128912fe4ef1d9eed576
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.50.0] - 2026-10-20
11
+
12
+ ### Changed
13
+
14
+ - **`AudienceResolver`: the client's registered `metadata["audience"]` now outranks the `audience` parameter.** The registered default is set by the server (`dynamic_registration_attributes`) or an operator; letting the request's `audience` beat it meant an openly registered client could ask `/authorize` for another allowed audience, which every consumer's shim had prevented by stripping the parameter. New order: `audience_resolver` hook → registered default → `audience` param → RFC 8707 `resource`. Hosts that set `audience_resolver` to return the registered default are unaffected; a client with no registered default behaves as before.
15
+
16
+ ### Added
17
+
18
+ - **`c.oauth.authorize_scope_resolver`**: normalise the requested scope at `/authorize` once the client and audience are known and before `audience_scopes` is enforced — collapse a hierarchy, narrow to what the audience consumes, append a default. Callable `(scope:, audience:, client:, request:)` returning a String, Array or nil (unchanged); the answer is what the code is issued with and what consent shows. Validated at boot.
19
+
20
+ ## [0.49.1] - 2026-10-20
21
+
22
+ ### Fixed
23
+
24
+ - **Dynamic registration refuses non-https redirect URIs** (`invalid_redirect_uri`): https, or http only on a loopback host (RFC 8252 §7.3), and at most 10 entries — the rules every consumer's own registration operation enforced and the gem's model (which also serves operator-registered clients) does not. Applies to RFC 7592 updates too.
25
+ - **A refused registration no longer leaves an owner row behind**: the `dynamic_registration_owner` callable now runs inside the registration transaction, so a host creating a per-registration owner record gets it rolled back when the client is invalid.
26
+ - **`StandardId::ResourceServer`'s scope check defers to the auth layer**: with no session (the action skipped `verify_access_token!`, e.g. a 405 responder) it no longer raises `NotAuthenticatedError` itself, matching `AudienceVerification`.
27
+
10
28
  ## [0.49.0] - 2026-10-20
11
29
 
12
30
  ### Added
data/README.md CHANGED
@@ -1367,9 +1367,9 @@ redirect_to "/api/authorize?" + {
1367
1367
 
1368
1368
  **Which audience is the token for?** `/authorize` resolves it server-side
1369
1369
  (`StandardId::Oauth::AudienceResolver`), in this order: `config.oauth.audience_resolver`
1370
- when set; the `audience` parameter; the RFC 8707 `resource` parameter mapped through
1371
- `config.oauth.resource_audiences`; the client's registered default
1372
- `metadata["audience"]`. The result is bound to the authorization code and reaches
1370
+ when set; the client's registered default `metadata["audience"]` (server-set, so it
1371
+ outranks the request); the `audience` parameter; the RFC 8707 `resource` parameter
1372
+ mapped through `config.oauth.resource_audiences`. The result is bound to the authorization code and reaches
1373
1373
  the token as `aud`, and `config.oauth.audience_scopes` is enforced for it at
1374
1374
  `/authorize` (a scope outside the audience's vocabulary is `invalid_scope`). So a
1375
1375
  dynamically registered MCP client whose registration stamps
@@ -1378,10 +1378,28 @@ in front of the endpoint. The `resource` parameter is ignored until
1378
1378
  `resource_audiences` is set; once it is, an unmapped resource is `invalid_target`
1379
1379
  (RFC 8707 §2).
1380
1380
 
1381
- A client-supplied `audience` still outranks the registered default, so
1382
- `audience_scopes` enforcement is only as strong as `config.oauth.allowed_audiences`
1383
- at the token endpoint. A host that wants the registered default to be the only
1384
- answer sets `audience_resolver` to `->(client:) { client.metadata["audience"] }`.
1381
+ For a client with no registered default, a client-supplied `audience` is taken as
1382
+ is, so `audience_scopes` enforcement for such clients is only as strong as
1383
+ `config.oauth.allowed_audiences` at the token endpoint.
1384
+
1385
+ **Which scope?** `config.oauth.authorize_scope_resolver` normalises the requested
1386
+ scope once the client and audience are known and before `audience_scopes` is
1387
+ enforced — collapse a hierarchy, narrow to what the audience consumes, append a
1388
+ default — and its answer is what the code is issued with and the consent screen
1389
+ shows:
1390
+
1391
+ ```ruby
1392
+ config.oauth.authorize_scope_resolver = ->(scope:, audience:) {
1393
+ tokens = scope.to_s.split
1394
+ next nil unless audience == "mcp" && tokens.include?("mcp") # nil leaves the request as is
1395
+
1396
+ tokens - ["mcp:read"] # "mcp" already implies read; an Array or a String is fine
1397
+ }
1398
+ ```
1399
+
1400
+ It runs again when a consent resumes, so make it idempotent (its own output must
1401
+ come back unchanged); it also decides what the consent screen shows and what the
1402
+ grant records.
1385
1403
 
1386
1404
  ```ruby
1387
1405
  StandardId.configure do |config|
@@ -81,8 +81,12 @@ module StandardId
81
81
  raise(StandardId::ConfigurationError, "#{self.class.name} includes StandardId::ResourceServer but never called protected_resource")
82
82
  end
83
83
 
84
+ # Like verify_audience!: without a session there is nothing to check, and
85
+ # either the auth layer is answering the 401 or the action skipped
86
+ # verify_access_token! on purpose (a 405 responder, say).
84
87
  def verify_protected_resource_scopes!
85
88
  return if _protected_resource_scopes.empty?
89
+ return unless current_session
86
90
 
87
91
  require_scopes!(*_protected_resource_scopes)
88
92
  end
@@ -74,7 +74,7 @@ module StandardId
74
74
  !StandardId::ClientGrant.granted?(
75
75
  account: current_account,
76
76
  client_id: client.client_id,
77
- requested_scope: params[:scope]
77
+ requested_scope: consent_scope
78
78
  )
79
79
  end
80
80
 
@@ -82,6 +82,21 @@ module StandardId
82
82
  @consent_client ||= StandardId::ClientApplication.active.find_by(client_id: params[:client_id])
83
83
  end
84
84
 
85
+ # The scope consent is checked against, shown and recorded: the request's,
86
+ # normalised by `oauth.authorize_scope_resolver` exactly as the flow will
87
+ # normalise it when the code is issued, so the screen and the grant never
88
+ # disagree with the token. Needs the client and the resolved audience, so
89
+ # it is computed here rather than inside the flow.
90
+ def consent_scope
91
+ return @consent_scope if defined?(@consent_scope)
92
+
93
+ client = consent_client
94
+ audience = StandardId::Oauth::AudienceResolver.resolve(client: client, params: params, request: request)
95
+ @consent_scope = StandardId::Oauth::AuthorizeScopeResolver.resolve(
96
+ scope: params[:scope], audience: audience, client: client, request: request
97
+ )
98
+ end
99
+
85
100
  def redirect_to_consent
86
101
  payload = StandardId::Oauth::ConsentPayload.encode(authorize_params_for_resume)
87
102
  base = StandardId.config.login_url.present? ? consent_base_from_login_url : "/consent"
@@ -98,13 +113,14 @@ module StandardId
98
113
 
99
114
  # The exact params required to resume authorization-code issuance after
100
115
  # approval. Carried through a signed payload so they cannot be tampered
101
- # with; redirect_uri + PKCE are revalidated when the flow re-runs.
116
+ # with; redirect_uri + PKCE are revalidated when the flow re-runs. The
117
+ # scope is the normalised one the user is shown (see #consent_scope).
102
118
  def authorize_params_for_resume
103
119
  {
104
120
  response_type: response_type,
105
121
  client_id: params[:client_id],
106
122
  redirect_uri: params[:redirect_uri],
107
- scope: params[:scope],
123
+ scope: consent_scope,
108
124
  audience: params[:audience],
109
125
  resource: params[:resource],
110
126
  state: params[:state],
@@ -61,6 +61,11 @@ module StandardId
61
61
  kind: :keyword,
62
62
  keywords: %i[metadata request]
63
63
  },
64
+ "oauth.authorize_scope_resolver" => {
65
+ signature: "(scope:, audience:, client:, request:)",
66
+ kind: :keyword,
67
+ keywords: %i[scope audience client request]
68
+ },
64
69
  "oauth.audience_scope_resolver" => {
65
70
  signature: "(audience:, client:, configured_scopes:)",
66
71
  kind: :keyword,
@@ -421,13 +421,31 @@ StandardId::ConfigSchema.define do
421
421
  # instead of trusting a client-supplied `audience` alone. Resolution order
422
422
  # (StandardId::Oauth::AudienceResolver): this callable when set (any
423
423
  # subset of client:, audience:, resource:, request:; a non-nil answer is
424
- # final), else the `audience` param, else the RFC 8707 `resource` param
425
- # mapped through `resource_audiences`, else the client's registered
426
- # default `metadata["audience"]`. The resolved audience is bound to the
427
- # authorization code and reaches the token as `aud`; `audience_scopes`
424
+ # final), else the client's registered default `metadata["audience"]`
425
+ # (server-set, so it outranks the request), else the `audience` param,
426
+ # else the RFC 8707 `resource` param mapped through `resource_audiences`.
427
+ # The resolved audience is bound to the authorization code and reaches the
428
+ # token as `aud`; `audience_scopes`
428
429
  # (above) is then enforced at /authorize for it.
429
430
  field :audience_resolver, type: :any, default: nil
430
431
 
432
+ # Normalise the scope an /authorize request asks for, once the client and
433
+ # audience are known and before `audience_scopes` is enforced — the place
434
+ # for what hosts used to do in a shim: collapse a hierarchy ("mcp mcp:read"
435
+ # → "mcp"), narrow a request to what the chosen audience consumes, append
436
+ # a default. A callable taking any subset of `scope:` (the raw param),
437
+ # `audience:` (resolved), `client:` and `request:`; returns the scope as a
438
+ # String or Array, or nil to leave the request unchanged. The result is
439
+ # what the code is issued with, what the consent screen shows and what the
440
+ # grant is checked against and recorded as. It runs again on the consent
441
+ # resume, so it must be idempotent (its own output must come back unchanged).
442
+ #
443
+ # c.oauth.authorize_scope_resolver = ->(scope:, audience:) {
444
+ # tokens = scope.to_s.split
445
+ # audience == "mcp" && tokens.include?("mcp") ? tokens - ["mcp:read"] : nil
446
+ # }
447
+ field :authorize_scope_resolver, type: :any, default: nil
448
+
431
449
  # RFC 8707: resource indicator → audience, e.g.
432
450
  # { "https://app.example.com/mcp" => "mcp" }. Matched with the trailing
433
451
  # slash removed. Empty (the default) means the `resource` parameter is
@@ -9,9 +9,14 @@ module StandardId
9
9
  # subset of `client:`, `audience:` (the raw param), `resource:` (the
10
10
  # RFC 8707 param) and `request:`. A non-nil answer is final; nil
11
11
  # falls through to the defaults below.
12
- # 2. The `audience` parameter, when present (what hosts' shims inject
13
- # today, so nothing changes for them).
14
- # 3. The `resource` parameter (RFC 8707), mapped through
12
+ # 2. The client's registered default, `metadata["audience"]` — set by the
13
+ # server at registration (`dynamic_registration_attributes`) or by an
14
+ # operator, so it is the one input here the client did not choose, and
15
+ # it outranks what the request says (0.50.0; before that the
16
+ # `audience` parameter came first, which let an openly registered
17
+ # client ask for another allowed audience).
18
+ # 3. The `audience` parameter, when present.
19
+ # 4. The `resource` parameter (RFC 8707), mapped through
15
20
  # `config.oauth.resource_audiences` ({ "https://app.example/mcp" =>
16
21
  # "mcp" }); compared with the trailing slash removed. With no mapping
17
22
  # configured the parameter is ignored (MCP clients send it on every
@@ -19,9 +24,6 @@ module StandardId
19
24
  # get that default). With a mapping, an unmapped resource is refused
20
25
  # with `invalid_target` (RFC 8707 §2), never silently issued without
21
26
  # an audience.
22
- # 4. The client's registered default, `metadata["audience"]` — the
23
- # convention every dynamic-registration operation in the estate
24
- # stamps, and the reason each app carried an authorize shim.
25
27
  #
26
28
  # Nil when none of these apply: audience stays optional at /authorize.
27
29
  module AudienceResolver
@@ -38,10 +40,12 @@ module StandardId
38
40
  return coerce_hook_answer(answer) unless answer.nil?
39
41
  end
40
42
 
43
+ registered = client_default(client)
44
+ return registered if registered
41
45
  return requested.to_s if requested
42
46
  return from_resource(resource) if resource && resource_mapping.any?
43
47
 
44
- client_default(client)
48
+ nil
45
49
  end
46
50
 
47
51
  # @raise [StandardId::InvalidTargetError] when a mapping is configured
@@ -92,8 +92,19 @@ module StandardId
92
92
  params[:state]
93
93
  end
94
94
 
95
+ # The requested scope, normalised by `oauth.authorize_scope_resolver`
96
+ # when set (a host that collapses a scope hierarchy, or narrows a
97
+ # request to what the chosen audience consumes, does it here instead of
98
+ # in a shim in front of this endpoint). Resolved once, after the client
99
+ # and audience are known; nil from the hook leaves the request as is.
95
100
  def scope
96
- params[:scope]
101
+ return @scope if defined?(@scope)
102
+
103
+ @scope = resolve_scope(params[:scope])
104
+ end
105
+
106
+ def resolve_scope(requested)
107
+ StandardId::Oauth::AuthorizeScopeResolver.resolve(scope: requested, audience: audience, client: @client, request: request)
97
108
  end
98
109
 
99
110
  # Resolved once, after the client is known (see AudienceResolver): the
@@ -0,0 +1,36 @@
1
+ module StandardId
2
+ module Oauth
3
+ # Applies `config.oauth.authorize_scope_resolver` to an /authorize request's
4
+ # scope once the client and audience are known. Shared by the authorize
5
+ # flows (what the code is issued with) and the consent gate (what the user
6
+ # is shown, what the grant is checked against and recorded as), so the two
7
+ # cannot disagree.
8
+ #
9
+ # The consent resume re-runs the flow with the already-normalised scope, so
10
+ # a resolver must be idempotent: applying it to its own output must change
11
+ # nothing. Collapsing, filtering and appending a default all are; an
12
+ # append that does not check for presence is not (the Array/String output
13
+ # is de-duplicated, which covers the common case).
14
+ module AuthorizeScopeResolver
15
+ module_function
16
+
17
+ # @return [String, nil] the normalised scope (nil/blank in → nil/blank out
18
+ # unless the resolver answers)
19
+ def resolve(scope:, audience:, client:, request: nil)
20
+ custom = StandardId.config.oauth.authorize_scope_resolver
21
+ return scope unless custom.respond_to?(:call)
22
+
23
+ context = { scope: scope, audience: audience, client: client, request: request }
24
+ answer = custom.call(**StandardId::Utils::CallableParameterFilter.filter(custom, context))
25
+ case answer
26
+ when nil then scope
27
+ when Array then answer.map(&:to_s).reject(&:blank?).uniq.join(" ")
28
+ when String, Symbol then answer.to_s.split.uniq.join(" ")
29
+ else
30
+ raise StandardId::ConfigurationError,
31
+ "StandardId config: `oauth.authorize_scope_resolver` must return a String, Array, Symbol or nil, got #{answer.class}"
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -37,6 +37,8 @@ module StandardId
37
37
  # Keys `config.oauth.dynamic_registration_attributes` may pin.
38
38
  PINNABLE_ATTRIBUTES = %w[scopes metadata access_token_lifetime authorization_code_lifetime require_consent description].freeze
39
39
  REGISTRATION_ACCESS_TOKEN_BYTES = 32
40
+ # A self-registered client gets a handful of callbacks, not a list.
41
+ MAX_REDIRECT_URIS = 10
40
42
 
41
43
  # Minimal result object mirroring the gem's `result.success?` /
42
44
  # `result.value` convention. `client_secret` is the one-time plaintext for
@@ -88,18 +90,23 @@ module StandardId
88
90
  client
89
91
  end
90
92
 
93
+ # Everything, the owner resolver included, runs inside one transaction:
94
+ # a host whose resolver creates a per-registration owner row must not be
95
+ # left with that row when the client itself is refused.
91
96
  def call
92
- attrs = mapped_attributes
97
+ validate_redirect_uris!
93
98
  registration_access_token = nil
94
- if StandardId::ClientApplication.registration_management_column?
95
- registration_access_token = SecureRandom.urlsafe_base64(REGISTRATION_ACCESS_TOKEN_BYTES)
96
- attrs[:registration_access_token_digest] =
97
- StandardId::ClientApplication.digest_registration_access_token(registration_access_token)
98
- end
99
- client = StandardId::ClientApplication.new(attrs)
100
-
101
99
  secret_plaintext = nil
100
+ client = nil
101
+
102
102
  StandardId::ClientApplication.transaction do
103
+ attrs = mapped_attributes
104
+ if StandardId::ClientApplication.registration_management_column?
105
+ registration_access_token = SecureRandom.urlsafe_base64(REGISTRATION_ACCESS_TOKEN_BYTES)
106
+ attrs[:registration_access_token_digest] =
107
+ StandardId::ClientApplication.digest_registration_access_token(registration_access_token)
108
+ end
109
+ client = StandardId::ClientApplication.new(attrs)
103
110
  client.save!
104
111
  if client.confidential?
105
112
  secret_plaintext = SecureRandom.hex(32)
@@ -117,6 +124,7 @@ module StandardId
117
124
  end
118
125
 
119
126
  def update(client)
127
+ validate_redirect_uris!
120
128
  if metadata[:client_id].present? && metadata[:client_id].to_s != client.client_id
121
129
  raise StandardId::InvalidClientMetadataError, "client_id does not match the client being updated"
122
130
  end
@@ -191,7 +199,34 @@ module StandardId
191
199
  # the model and let its redirect-uri validation surface an invalid_redirect_uri
192
200
  # error — keeping a single source of truth for URI rules.
193
201
  def redirect_uris
194
- Array(metadata[:redirect_uris]).map { |u| u.to_s.strip }.reject(&:blank?).join(" ")
202
+ redirect_uri_list.join(" ")
203
+ end
204
+
205
+ def redirect_uri_list
206
+ value = metadata[:redirect_uris]
207
+ list = value.is_a?(Array) ? value : value.to_s.split(/\s+/)
208
+ list.map { |u| u.to_s.strip }.reject(&:blank?)
209
+ end
210
+
211
+ # What the model does not enforce for a client an operator registers by
212
+ # hand, but a self-registering client must meet: https, or http only on a
213
+ # loopback host (RFC 8252 §7.3, for native apps on an ephemeral port), and
214
+ # a bounded list. Absent or empty is left to the model's presence check.
215
+ def validate_redirect_uris!
216
+ list = redirect_uri_list
217
+ return if list.empty?
218
+
219
+ if list.size > MAX_REDIRECT_URIS
220
+ raise StandardId::InvalidRedirectUriError, "redirect_uris must not contain more than #{MAX_REDIRECT_URIS} entries"
221
+ end
222
+
223
+ list.each do |uri|
224
+ parsed = StandardId::ClientApplication.parse_redirect_uri(uri)
225
+ next if parsed && (parsed.scheme == "https" || StandardId::ClientApplication.loopback_redirect_uri?(parsed))
226
+
227
+ raise StandardId::InvalidRedirectUriError,
228
+ "redirect_uri #{uri.inspect} must use https (http is allowed only for loopback hosts)"
229
+ end
195
230
  end
196
231
 
197
232
  def client_name
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.49.0"
2
+ VERSION = "0.50.0"
3
3
  end
data/lib/standard_id.rb CHANGED
@@ -39,6 +39,7 @@ require "standard_id/account_cleanup"
39
39
  require "standard_id/oauth/audience_profile_resolver"
40
40
  require "standard_id/oauth/audience_scope_resolver"
41
41
  require "standard_id/oauth/audience_resolver"
42
+ require "standard_id/oauth/authorize_scope_resolver"
42
43
  require "standard_id/oauth/base_request_flow"
43
44
  require "standard_id/oauth/token_lifetime_resolver"
44
45
  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.49.0
4
+ version: 0.50.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -287,6 +287,7 @@ files:
287
287
  - lib/standard_id/oauth/authorization_code_authorization_flow.rb
288
288
  - lib/standard_id/oauth/authorization_code_flow.rb
289
289
  - lib/standard_id/oauth/authorization_flow.rb
290
+ - lib/standard_id/oauth/authorize_scope_resolver.rb
290
291
  - lib/standard_id/oauth/base_request_flow.rb
291
292
  - lib/standard_id/oauth/client_credentials_flow.rb
292
293
  - lib/standard_id/oauth/client_registration.rb