standard_id 0.49.1 → 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: 8b5e02f9651266077753a98125e3772a99bd2cf935da68d17f98df4bfd59505e
4
- data.tar.gz: e703964c3b6c2c0ffd10a5475f370c9dea0f6f4873fd6827606ee9869a12726d
3
+ metadata.gz: f6f93f66f3e1a4d95ac6effab590e560e39f2993dd072010676a3121efec6098
4
+ data.tar.gz: 6934ab15f5b9c394db6d15eb37ca91d41965421589553135c2a05fea1ebbf551
5
5
  SHA512:
6
- metadata.gz: 4091588b0c3b2cc3f06c25682e840cb2a1293a94d67abaf25eb81cce6331fe7a8325aea577fd69ceb5405c09dd0aa39fc0e70ed17df493a92da566fd8da10343
7
- data.tar.gz: a10b76363a599b3c5fc29681665f8a20d564476cb6682a32c8555f4e2464945f41e0ccfcc7897db8c65df57483f8be3b927294282121d47bbc3226564e3dc0b2
6
+ metadata.gz: 73eab42991c65471c2f77d7be554b4ec1d002eacb73e62d8d77bc79003b5121f4b64bd9750ce8991113ff451556313f54165cfd75b642501251dcb947c0f3900
7
+ data.tar.gz: 6a0b2b9e4628842eb1ab6d56acf348c829abc083a00e29671c3efadca5dc57e56aa63d958577c884a7ca224f6e768aebedc66755d5c1128912fe4ef1d9eed576
data/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ 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
+
10
20
  ## [0.49.1] - 2026-10-20
11
21
 
12
22
  ### Fixed
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|
@@ -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
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.49.1"
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.1
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