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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +25 -7
- data/app/controllers/standard_id/api/authorization_controller.rb +19 -3
- data/lib/standard_id/config/callable_validator.rb +5 -0
- data/lib/standard_id/config/schema.rb +22 -4
- data/lib/standard_id/oauth/audience_resolver.rb +11 -7
- data/lib/standard_id/oauth/authorization_flow.rb +12 -1
- data/lib/standard_id/oauth/authorize_scope_resolver.rb +36 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f6f93f66f3e1a4d95ac6effab590e560e39f2993dd072010676a3121efec6098
|
|
4
|
+
data.tar.gz: 6934ab15f5b9c394db6d15eb37ca91d41965421589553135c2a05fea1ebbf551
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
1371
|
-
`
|
|
1372
|
-
`
|
|
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
|
-
|
|
1382
|
-
`audience_scopes` enforcement is only as strong as
|
|
1383
|
-
at the token endpoint.
|
|
1384
|
-
|
|
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:
|
|
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:
|
|
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
|
|
425
|
-
#
|
|
426
|
-
#
|
|
427
|
-
# authorization code and reaches the
|
|
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`
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/standard_id/version.rb
CHANGED
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.
|
|
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
|