standard_id 0.48.0 → 0.49.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +178 -1
  4. data/app/controllers/concerns/standard_id/resource_server.rb +138 -0
  5. data/app/controllers/standard_id/api/authorization_controller.rb +1 -0
  6. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +23 -0
  7. data/app/controllers/standard_id/api/oauth/registrations_controller.rb +99 -22
  8. data/app/controllers/standard_id/api/well_known/oauth_protected_resource_controller.rb +46 -0
  9. data/app/jobs/standard_id/cleanup_all_job.rb +4 -3
  10. data/app/jobs/standard_id/purge_stale_dynamic_clients_job.rb +73 -0
  11. data/app/models/standard_id/client_application.rb +38 -0
  12. data/config/routes/api.rb +9 -0
  13. data/db/migrate/20261020120000_add_registration_access_token_digest_to_standard_id_client_applications.rb +14 -0
  14. data/lib/generators/standard_id/install/install_generator.rb +2 -1
  15. data/lib/standard_id/config/callable_validator.rb +20 -0
  16. data/lib/standard_id/config/schema.rb +87 -0
  17. data/lib/standard_id/errors.rb +22 -0
  18. data/lib/standard_id/oauth/audience_resolver.rb +88 -0
  19. data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +1 -1
  20. data/lib/standard_id/oauth/authorization_code_flow.rb +3 -1
  21. data/lib/standard_id/oauth/authorization_flow.rb +21 -1
  22. data/lib/standard_id/oauth/client_registration.rb +151 -23
  23. data/lib/standard_id/oauth/direct_mint_flow.rb +128 -0
  24. data/lib/standard_id/oauth/implicit_authorization_flow.rb +1 -1
  25. data/lib/standard_id/oauth/protected_resource_document.rb +171 -0
  26. data/lib/standard_id/oauth/refresh_token_flow.rb +1 -1
  27. data/lib/standard_id/otp.rb +9 -1
  28. data/lib/standard_id/passwordless/base_strategy.rb +4 -2
  29. data/lib/standard_id/routing.rb +21 -0
  30. data/lib/standard_id/token_minter.rb +74 -0
  31. data/lib/standard_id/version.rb +1 -1
  32. data/lib/standard_id.rb +4 -0
  33. data/lib/tasks/standard_id_tasks.rake +8 -1
  34. metadata +9 -1
@@ -0,0 +1,46 @@
1
+ module StandardId
2
+ module Api
3
+ module WellKnown
4
+ # RFC 9728 OAuth 2.0 Protected Resource Metadata.
5
+ #
6
+ # `GET /.well-known/oauth-protected-resource/<resource path>` serves the
7
+ # document for the resource configured at that path in
8
+ # `config.oauth.protected_resources`; the bare form serves the default
9
+ # (the only configured resource, or the one marked `root: true`). 404
10
+ # when nothing matches, so a host with no protected resources exposes no
11
+ # document. Public and cacheable, like the other discovery documents.
12
+ #
13
+ # Drawn at the origin root by `standard_id_protected_resource_routes`
14
+ # (the challenge in StandardId::ResourceServer names that URL) and also
15
+ # under the engine's own mount.
16
+ class OauthProtectedResourceController < ActionController::API
17
+ include StandardId::ControllerPolicy
18
+ public_controller
19
+
20
+ def show
21
+ audience = resolve_audience
22
+
23
+ unless audience
24
+ render json: { error: "not_found", error_description: "No protected resource is configured at this path" },
25
+ status: :not_found
26
+ return
27
+ end
28
+
29
+ response.headers["Cache-Control"] = "public, max-age=3600"
30
+ render json: StandardId::Oauth::ProtectedResourceDocument.build(
31
+ audience: audience, origin: request.base_url, request: request
32
+ )
33
+ end
34
+
35
+ private
36
+
37
+ def resolve_audience
38
+ path = params[:resource_path].presence
39
+ return StandardId::Oauth::ProtectedResourceDocument.default_audience if path.nil?
40
+
41
+ StandardId::Oauth::ProtectedResourceDocument.audience_for_path("/#{path}")
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -1,5 +1,5 @@
1
1
  module StandardId
2
- # Runs all four expired-row cleanup jobs inline, in one job.
2
+ # Runs all five cleanup jobs inline, in one job.
3
3
  #
4
4
  # Schedule this ONE job instead of the four individual ones when you want a
5
5
  # single recurring entry — typically so the schedule carries one cron
@@ -14,7 +14,7 @@ module StandardId
14
14
  # end
15
15
  #
16
16
  # One table's DELETE failing (a lock timeout, say) does not skip the others;
17
- # the first error is re-raised once all four have run, so the run still
17
+ # the first error is re-raised once all five have run, so the run still
18
18
  # fails its cron check-in and lands in the queue's failed executions.
19
19
  #
20
20
  # Each job keeps its default grace windows (see README "Scheduled
@@ -28,7 +28,8 @@ module StandardId
28
28
  StandardId::CleanupExpiredSessionsJob,
29
29
  StandardId::CleanupExpiredRefreshTokensJob,
30
30
  StandardId::CleanupExpiredAuthorizationCodesJob,
31
- StandardId::CleanupExpiredCodeChallengesJob
31
+ StandardId::CleanupExpiredCodeChallengesJob,
32
+ StandardId::PurgeStaleDynamicClientsJob
32
33
  ]
33
34
  end
34
35
 
@@ -0,0 +1,73 @@
1
+ module StandardId
2
+ # Garbage-collects dynamically registered clients (RFC 7591) that never
3
+ # amounted to anything, and deregistered ones.
4
+ #
5
+ # Open registration is unauthenticated, so bots and abandoned connectors
6
+ # accumulate ClientApplication rows. A client is reaped when it was
7
+ # registered more than `stale_after_seconds` ago and EITHER
8
+ #
9
+ # * it was deregistered (RFC 7592 DELETE deactivates; see
10
+ # ClientRegistration.deregister) more than the window ago — the row is
11
+ # what lets refresh and introspection say "gone" meanwhile, or
12
+ # * nobody ever used it: it is consent-gated (a completed authorization
13
+ # records a ClientGrant; a client registered with `require_consent:
14
+ # false` pinned never gets one, so it is only ever reaped once
15
+ # deregistered), and it has no ClientGrant, no AuthorizationCode in any
16
+ # state and no refresh token in any state. Consumed codes and dead
17
+ # refresh tokens count as use on purpose: they are what an active client
18
+ # leaves behind between authorizations, and the cleanup jobs delete them
19
+ # on their own schedule.
20
+ #
21
+ # The grace window is what lets a client register now and complete consent
22
+ # days later. Only clients created through dynamic registration are ever
23
+ # touched (migration 20261020120000 marks them); the job is a no-op before
24
+ # that migration has run.
25
+ class PurgeStaleDynamicClientsJob < ApplicationJob
26
+ queue_as :default
27
+
28
+ # @param stale_after_seconds [Integer] registered longer ago than this
29
+ # @param batch_size [Integer] rows per batch
30
+ # @return [Integer] clients deleted
31
+ def perform(stale_after_seconds: 7.days.to_i, batch_size: 100)
32
+ return 0 unless StandardId::ClientApplication.registration_management_column?
33
+
34
+ cutoff = stale_after_seconds.seconds.ago
35
+ deleted = 0
36
+ stale_clients(cutoff).find_each(batch_size: batch_size) do |client|
37
+ StandardId::ClientApplication.transaction do
38
+ if StandardId::RefreshToken.client_binding_column?
39
+ client.refresh_tokens.where(revoked_at: nil).update_all(revoked_at: Time.current)
40
+ end
41
+ # Grants are keyed by the client_id string with no FK, so they would
42
+ # otherwise outlive the client as orphans.
43
+ StandardId::ClientGrant.where(client_id: client.client_id).delete_all
44
+ # The dependent-destroy cascade lazy-loads the client's secrets and
45
+ # codes, which a host running strict_loading_by_default would refuse.
46
+ client.strict_loading!(false)
47
+ client.destroy!
48
+ end
49
+ deleted += 1
50
+ end
51
+ Rails.logger.info("[StandardId] Purged #{deleted} stale dynamically registered clients older than #{cutoff}")
52
+ deleted
53
+ end
54
+
55
+ private
56
+
57
+ def stale_clients(cutoff)
58
+ base = StandardId::ClientApplication.dynamically_registered.where("created_at < ?", cutoff)
59
+
60
+ # A deregistered client only ever goes through the deactivated branch,
61
+ # whatever it did or did not do while live.
62
+ never_used = base
63
+ .where(active: true, require_consent: true)
64
+ .where.not(client_id: StandardId::ClientGrant.select(:client_id))
65
+ .where.not(client_id: StandardId::AuthorizationCode.select(:client_id))
66
+ if StandardId::RefreshToken.client_binding_column?
67
+ never_used = never_used.where.not(id: StandardId::RefreshToken.where.not(client_application_id: nil).select(:client_application_id))
68
+ end
69
+
70
+ base.where(active: false).where("deactivated_at < ?", cutoff).or(never_used)
71
+ end
72
+ end
73
+ end
@@ -55,6 +55,9 @@ module StandardId
55
55
  # they drift (update_columns, a data fix) must read the same from a query
56
56
  # as from the instance — the refresh grant and introspection rely on it.
57
57
  scope :live, -> { where(active: true, deactivated_at: nil) }
58
+ # Clients created through RFC 7591 dynamic registration (migration
59
+ # 20261020120000): the ones RFC 7592 can manage and the purge may reap.
60
+ scope :dynamically_registered, -> { where.not(registration_access_token_digest: nil) }
58
61
  scope :confidential, -> { where(client_type: "confidential") }
59
62
  scope :public_clients, -> { where(client_type: "public") }
60
63
  scope :for_owner, ->(owner) { where(owner: owner) }
@@ -191,6 +194,41 @@ module StandardId
191
194
  nil
192
195
  end
193
196
 
197
+ # Whether the host has run migration 20261020120000. Until then the gem
198
+ # neither issues registration access tokens nor serves RFC 7592.
199
+ def self.registration_management_column?
200
+ column_names.include?("registration_access_token_digest")
201
+ rescue ActiveRecord::ActiveRecordError
202
+ false
203
+ end
204
+
205
+ # Keyed with secret_key_base like AuthorizationCode.hash_for, so a leaked
206
+ # table does not yield usable management credentials.
207
+ def self.digest_registration_access_token(token)
208
+ Digest::SHA256.hexdigest("#{token}:#{Rails.configuration.secret_key_base}")
209
+ end
210
+
211
+ def dynamically_registered?
212
+ self.class.registration_management_column? && registration_access_token_digest.present?
213
+ end
214
+
215
+ # Constant-time check of an RFC 7592 registration access token.
216
+ def authenticate_registration_access_token(token)
217
+ return false unless dynamically_registered? && token.present?
218
+
219
+ ActiveSupport::SecurityUtils.secure_compare(
220
+ self.class.digest_registration_access_token(token.to_s),
221
+ registration_access_token_digest.to_s
222
+ )
223
+ end
224
+
225
+ # The RFC 7591 token_endpoint_auth_method a client information response
226
+ # reports. Public clients are "none"; both confidential methods are
227
+ # accepted at the token endpoint, so client_secret_basic stands for either.
228
+ def token_endpoint_auth_method
229
+ public? ? "none" : "client_secret_basic"
230
+ end
231
+
194
232
  def confidential?
195
233
  client_type == "confidential"
196
234
  end
data/config/routes/api.rb CHANGED
@@ -27,6 +27,11 @@ StandardId::ApiEngine.routes.draw do
27
27
  # The controller returns 404 when oauth.dynamic_registration_enabled is
28
28
  # false, so the endpoint is fully absent unless explicitly enabled.
29
29
  resource :register, only: [:create], controller: :registrations
30
+ # RFC 7592 client-configuration endpoint, authenticated by the
31
+ # registration access token issued at registration.
32
+ get "register/:client_id", to: "registrations#show", as: :registration
33
+ put "register/:client_id", to: "registrations#update"
34
+ delete "register/:client_id", to: "registrations#destroy"
30
35
 
31
36
  namespace :callback do
32
37
  post ":provider", to: "providers#callback", as: :provider
@@ -37,6 +42,10 @@ StandardId::ApiEngine.routes.draw do
37
42
  get "jwks.json", to: "jwks#show", as: :jwks
38
43
  get "openid-configuration", to: "openid_configuration#show", as: :openid_configuration
39
44
  get "oauth-authorization-server", to: "oauth_authorization_server#show", as: :oauth_authorization_server
45
+ # RFC 9728. The host also draws this at the origin root with
46
+ # standard_id_protected_resource_routes, which is the URL the challenge names.
47
+ get "oauth-protected-resource(/*resource_path)", to: "oauth_protected_resource#show",
48
+ as: :oauth_protected_resource, format: false
40
49
  end
41
50
  end
42
51
  end
@@ -0,0 +1,14 @@
1
+ # RFC 7592 client-configuration management for dynamically registered clients.
2
+ # The registration access token is returned once, at registration, and only
3
+ # its digest is stored; a non-NULL digest is also what marks a client as
4
+ # dynamically registered (StandardId::ClientApplication.dynamically_registered),
5
+ # which the stale-client purge keys on. One nullable string column, no
6
+ # default, no index (lookup is by client_id, then a constant-time compare):
7
+ # metadata-only on PostgreSQL. Until it has run, registration works as before
8
+ # (no registration_access_token / registration_client_uri in the response) and
9
+ # the management endpoints are absent (404).
10
+ class AddRegistrationAccessTokenDigestToStandardIdClientApplications < ActiveRecord::Migration[8.0]
11
+ def change
12
+ add_column :standard_id_client_applications, :registration_access_token_digest, :string
13
+ end
14
+ end
@@ -44,7 +44,8 @@ module StandardId
44
44
  "standard_id_cleanup_expired_sessions" => ["StandardId::CleanupExpiredSessionsJob", "every hour at minute 6"],
45
45
  "standard_id_cleanup_expired_refresh_tokens" => ["StandardId::CleanupExpiredRefreshTokensJob", "every hour at minute 3"],
46
46
  "standard_id_cleanup_expired_authorization_codes" => ["StandardId::CleanupExpiredAuthorizationCodesJob", "every hour at minute 9"],
47
- "standard_id_cleanup_expired_code_challenges" => ["StandardId::CleanupExpiredCodeChallengesJob", "every hour at minute 13"]
47
+ "standard_id_cleanup_expired_code_challenges" => ["StandardId::CleanupExpiredCodeChallengesJob", "every hour at minute 13"],
48
+ "standard_id_purge_stale_dynamic_clients" => ["StandardId::PurgeStaleDynamicClientsJob", "every day at 4am"]
48
49
  }.freeze
49
50
 
50
51
  def create_initializer_file
@@ -45,6 +45,26 @@ module StandardId
45
45
  signature: "(account:, client:, request:, audience:)",
46
46
  kind: :keyword,
47
47
  keywords: %i[account client request audience]
48
+ },
49
+ "oauth.audience_resolver" => {
50
+ signature: "(client:, audience:, resource:, request:)",
51
+ kind: :keyword,
52
+ keywords: %i[client audience resource request]
53
+ },
54
+ "oauth.dynamic_registration_owner" => {
55
+ signature: "(metadata:, request:)",
56
+ kind: :keyword,
57
+ keywords: %i[metadata request]
58
+ },
59
+ "oauth.dynamic_registration_attributes" => {
60
+ signature: "(metadata:, request:)",
61
+ kind: :keyword,
62
+ keywords: %i[metadata request]
63
+ },
64
+ "oauth.audience_scope_resolver" => {
65
+ signature: "(audience:, client:, configured_scopes:)",
66
+ kind: :keyword,
67
+ keywords: %i[audience client configured_scopes]
48
68
  }
49
69
  }.freeze
50
70
 
@@ -417,6 +417,39 @@ StandardId::ConfigSchema.define do
417
417
  # When nil (default), only the static `audience_scopes` map is consulted.
418
418
  field :audience_scope_resolver, type: :any, default: nil
419
419
 
420
+ # How /authorize decides which audience a request is for — server-side,
421
+ # instead of trusting a client-supplied `audience` alone. Resolution order
422
+ # (StandardId::Oauth::AudienceResolver): this callable when set (any
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`
428
+ # (above) is then enforced at /authorize for it.
429
+ field :audience_resolver, type: :any, default: nil
430
+
431
+ # RFC 8707: resource indicator → audience, e.g.
432
+ # { "https://app.example.com/mcp" => "mcp" }. Matched with the trailing
433
+ # slash removed. Empty (the default) means the `resource` parameter is
434
+ # ignored; once set, a resource outside the map is refused with
435
+ # `invalid_target` rather than issued without an audience.
436
+ field :resource_audiences, type: :hash, default: -> { {} }
437
+
438
+ # RFC 9728 protected resources this host serves, keyed by the audience
439
+ # their tokens carry. `path` is required (where the resource lives, or an
440
+ # absolute URL on another origin); `scopes_supported` defaults to
441
+ # `audience_scopes[audience]`, `authorization_servers` to `[issuer]`,
442
+ # `bearer_methods_supported` to `["header"]`; any other RFC 9728 member is
443
+ # emitted as given (callables receive `{ origin:, audience:, issuer:,
444
+ # request: }`; nil removes a member). `root: true` picks the resource the
445
+ # bare /.well-known/oauth-protected-resource serves when several exist.
446
+ #
447
+ # c.oauth.protected_resources = { "mcp" => { path: "/mcp", scopes_supported: %w[mcp mcp:read] } }
448
+ #
449
+ # Served by `standard_id_protected_resource_routes` in the host's routes
450
+ # file; consumed by StandardId::ResourceServer for the 401/403 challenge.
451
+ field :protected_resources, type: :hash, default: -> { {} }
452
+
420
453
  # JWT signing configuration (for asymmetric algorithms)
421
454
  # If nil, uses HS256 with Rails.application.secret_key_base
422
455
  field :signing_key, type: :any, default: nil
@@ -591,6 +624,36 @@ StandardId::ConfigSchema.define do
591
624
  # "client_secret_post" — confidential, secret in the request body
592
625
  field :dynamic_registration_default_auth_method, type: :string, default: "none"
593
626
 
627
+ # Server-pinned attributes for every dynamically registered client — what
628
+ # a self-registering MCP client may NOT choose for itself. A Hash, or a
629
+ # callable taking any subset of `metadata:` (the RFC 7591 document) and
630
+ # `request:` and returning one. Keys (any other raises ConfigurationError):
631
+ #
632
+ # scopes String or Array; the client's scope, whatever it asked for
633
+ # metadata Hash merged into ClientApplication#metadata
634
+ # ("audience" => "mcp" is what AudienceResolver reads)
635
+ # access_token_lifetime seconds
636
+ # authorization_code_lifetime seconds
637
+ # require_consent default true
638
+ # description String
639
+ #
640
+ # When `scopes` is not pinned but `metadata["audience"]` is, and that
641
+ # audience has an `audience_scopes` vocabulary, the client's scope is that
642
+ # vocabulary. Otherwise the requested scope (or "openid profile email").
643
+ #
644
+ # c.oauth.dynamic_registration_attributes = {
645
+ # scopes: %w[mcp mcp:read],
646
+ # metadata: { "audience" => "mcp", "dcr" => true },
647
+ # access_token_lifetime: 3600
648
+ # }
649
+ field :dynamic_registration_attributes, type: :any, default: -> { {} }
650
+
651
+ # token_endpoint_auth_method values dynamic registration accepts. Narrow it
652
+ # to %w[none] to register public (PKCE) clients only, and advertise the
653
+ # same list as token_endpoint_auth_methods_supported.
654
+ field :dynamic_registration_auth_methods, type: :array,
655
+ default: -> { %w[none client_secret_basic client_secret_post] }
656
+
594
657
  # RFC 6749 §4.1.3 strictness at the token endpoint.
595
658
  #
596
659
  # The spec is unambiguous: if `redirect_uri` was included in the
@@ -633,6 +696,26 @@ StandardId::ConfigSchema.define do
633
696
 
634
697
  scope :social do
635
698
  field :social_account_attributes, type: :any, default: nil
699
+
700
+ # Runs on the API social callback (POST /oauth/callback/:provider) AFTER the
701
+ # account is found or created and BEFORE the token is minted — the one
702
+ # place a host can make the account ready for the mint: provision the
703
+ # profile the requested audience is bound to, apply a region or
704
+ # pending-deletion check, record a first-touch. A callable taking any
705
+ # subset of `account:`, `provider:`, `social_info:`, `provider_tokens:`,
706
+ # `audience:` and `scopes:` (the requested ones, as strings or nil),
707
+ # `params:` (the forwarded request params, minus the reserved OAuth ones)
708
+ # and `request:`. Raising refuses the sign-in through the callback's
709
+ # normal path: nothing is minted, a staged social link is dropped, and an
710
+ # account this request created is removed again. Two things to know: the
711
+ # hook runs BEFORE the login-method policy and the audience binding, so
712
+ # if those refuse the sign-in on an account that already existed, whatever
713
+ # the hook wrote stays (make it idempotent); and it covers the API
714
+ # callback only — the WebEngine's social callback does not run it.
715
+ # SOCIAL_AUTH_COMPLETED still fires after the mint as before. Replaces the
716
+ # host subclass of the callback controller that reordered these steps by
717
+ # copying its body.
718
+ field :before_mint, type: :any, default: nil
636
719
  field :allowed_redirect_url_prefixes, type: :array, default: []
637
720
  field :available_scopes, type: :array, default: -> { [] }
638
721
  # How a social login whose (provider, sub) is not linked yet may link to
@@ -734,6 +817,10 @@ StandardId::ConfigSchema.define do
734
817
  # Dynamic client registration (RFC 7591) — throttle the open registration
735
818
  # endpoint by IP so an enabled deployment can't be flooded with client rows.
736
819
  field :dynamic_registration_per_ip, type: :integer, default: 10 # per hour
820
+ # RFC 7592 management (GET/PUT/DELETE /oauth/register/:client_id) — its
821
+ # own counter, so management traffic behind a shared NAT does not compete
822
+ # with registrations, while still slowing token guessing.
823
+ field :dynamic_registration_management_per_ip, type: :integer, default: 60 # per hour
737
824
 
738
825
  # Token introspection (RFC 7662) — throttle by IP so the endpoint cannot be
739
826
  # used to bulk-classify stolen tokens. Note the limit renders the ordinary
@@ -93,6 +93,21 @@ module StandardId
93
93
  def http_status = :too_many_requests
94
94
  end
95
95
 
96
+ # The OTP resend cooldown (`passwordless.retry_delay`) refused a start: a code
97
+ # for this target was issued less than retry_delay seconds ago and is still
98
+ # usable, so nothing new was sent. A subclass of InvalidRequestError so every
99
+ # existing rescue keeps working; Otp.issue reports it as `error_code:
100
+ # :cooldown` so a host can show its own "a code is already on its way" copy
101
+ # and tell it apart from a send failure. `retry_after` is the seconds left.
102
+ class RetryDelayError < InvalidRequestError
103
+ attr_reader :retry_after
104
+
105
+ def initialize(message = "Please wait before requesting another code", retry_after: nil)
106
+ super(message)
107
+ @retry_after = retry_after
108
+ end
109
+ end
110
+
96
111
  class InvalidClientError < OAuthError
97
112
  def oauth_error_code = :invalid_client
98
113
  def http_status = :unauthorized
@@ -146,6 +161,13 @@ module StandardId
146
161
  def oauth_error_code = :invalid_scope
147
162
  end
148
163
 
164
+ # RFC 8707 §2: the `resource` parameter names something this server does not
165
+ # issue tokens for. Raised by StandardId::Oauth::AudienceResolver when
166
+ # `oauth.resource_audiences` is configured and the resource is not in it.
167
+ class InvalidTargetError < OAuthError
168
+ def oauth_error_code = :invalid_target
169
+ end
170
+
149
171
  class UnauthorizedClientError < OAuthError
150
172
  def oauth_error_code = :unauthorized_client
151
173
  end
@@ -0,0 +1,88 @@
1
+ module StandardId
2
+ module Oauth
3
+ # Which audience an /authorize request is for — decided server-side, from
4
+ # what the request and the registered client say, rather than trusted from
5
+ # a client-supplied `audience` alone.
6
+ #
7
+ # Resolution order:
8
+ # 1. `config.oauth.audience_resolver`, when set: a callable taking any
9
+ # subset of `client:`, `audience:` (the raw param), `resource:` (the
10
+ # RFC 8707 param) and `request:`. A non-nil answer is final; nil
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
15
+ # `config.oauth.resource_audiences` ({ "https://app.example/mcp" =>
16
+ # "mcp" }); compared with the trailing slash removed. With no mapping
17
+ # configured the parameter is ignored (MCP clients send it on every
18
+ # request, and a host relying on the client default below must still
19
+ # get that default). With a mapping, an unmapped resource is refused
20
+ # with `invalid_target` (RFC 8707 §2), never silently issued without
21
+ # 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
+ #
26
+ # Nil when none of these apply: audience stays optional at /authorize.
27
+ module AudienceResolver
28
+ module_function
29
+
30
+ def resolve(client:, params:, request: nil)
31
+ requested = params[:audience].presence
32
+ resource = params[:resource].presence
33
+
34
+ custom = StandardId.config.oauth.audience_resolver
35
+ if custom.respond_to?(:call)
36
+ context = { client: client, audience: requested, resource: resource, request: request }
37
+ answer = custom.call(**StandardId::Utils::CallableParameterFilter.filter(custom, context))
38
+ return coerce_hook_answer(answer) unless answer.nil?
39
+ end
40
+
41
+ return requested.to_s if requested
42
+ return from_resource(resource) if resource && resource_mapping.any?
43
+
44
+ client_default(client)
45
+ end
46
+
47
+ # @raise [StandardId::InvalidTargetError] when a mapping is configured
48
+ # and this resource is not in it
49
+ def from_resource(resource)
50
+ key = normalize_resource(resource)
51
+ resource_mapping.each do |candidate, audience|
52
+ return audience.to_s if normalize_resource(candidate) == key
53
+ end
54
+
55
+ raise StandardId::InvalidTargetError, "The resource parameter is not a resource this server issues tokens for"
56
+ end
57
+
58
+ def resource_mapping
59
+ StandardId.config.oauth.resource_audiences || {}
60
+ end
61
+
62
+ # The hook's contract is "nil falls through, anything else is the
63
+ # audience". A String or Symbol is it; a blank string is treated as "no
64
+ # audience" (the hook decided, and decided none). Anything else is a
65
+ # programming error worth failing loudly on rather than stringifying.
66
+ def coerce_hook_answer(answer)
67
+ case answer
68
+ when String, Symbol then answer.to_s.presence
69
+ else
70
+ raise StandardId::ConfigurationError,
71
+ "StandardId config: `oauth.audience_resolver` must return a String, Symbol or nil, got #{answer.class}"
72
+ end
73
+ end
74
+
75
+ def client_default(client)
76
+ return nil if client.nil?
77
+
78
+ metadata = client.respond_to?(:metadata) ? client.metadata : nil
79
+ value = metadata.is_a?(Hash) ? (metadata["audience"] || metadata[:audience]) : nil
80
+ value.presence&.to_s
81
+ end
82
+
83
+ def normalize_resource(value)
84
+ value.to_s.strip.delete_suffix("/")
85
+ end
86
+ end
87
+ end
88
+ end
@@ -7,7 +7,7 @@ module StandardId
7
7
  # TokenGrantFlow#validate_audience! already no-ops when audience is blank or
8
8
  # when no allowed_audiences are configured, so omitting it is safe and lets
9
9
  # standards-compliant clients (e.g. MCP) authorize without it.
10
- permit_params :audience, :scope, :redirect_uri, :state, :connection, :prompt, :organization, :invitation, :code_challenge, :code_challenge_method, :nonce
10
+ permit_params :audience, :resource, :scope, :redirect_uri, :state, :connection, :prompt, :organization, :invitation, :code_challenge, :code_challenge_method, :nonce
11
11
 
12
12
  private
13
13
 
@@ -2,7 +2,9 @@ module StandardId
2
2
  module Oauth
3
3
  class AuthorizationCodeFlow < TokenGrantFlow
4
4
  expect_params :client_id, :code
5
- permit_params :client_secret, :redirect_uri, :code_verifier
5
+ # `resource` (RFC 8707) is accepted so a client that repeats it at the
6
+ # token endpoint is not refused; the audience was bound at /authorize.
7
+ permit_params :client_secret, :redirect_uri, :code_verifier, :resource
6
8
 
7
9
  def authenticate!
8
10
  @client = StandardId::ClientApplication.find_by(client_id: params[:client_id])
@@ -22,6 +22,7 @@ module StandardId
22
22
  def execute
23
23
  validate_params!
24
24
  authenticate_client!
25
+ enforce_audience_scopes!
25
26
  generate_authorization_response
26
27
  end
27
28
 
@@ -95,8 +96,27 @@ module StandardId
95
96
  params[:scope]
96
97
  end
97
98
 
99
+ # Resolved once, after the client is known (see AudienceResolver): the
100
+ # `audience` param, the RFC 8707 `resource`, or the client's registered
101
+ # default. Bound to the authorization code and carried to the token.
98
102
  def audience
99
- params[:audience]
103
+ return @audience if defined?(@audience)
104
+
105
+ @audience = StandardId::Oauth::AudienceResolver.resolve(client: @client, params: params, request: request)
106
+ end
107
+
108
+ def resource
109
+ params[:resource]
110
+ end
111
+
112
+ # `oauth.audience_scopes` is the vocabulary an audience may be asked for;
113
+ # until now it was only ever consulted by hosts' own shims. A request for
114
+ # a scope outside it is refused here, at /authorize, before anything is
115
+ # issued. Unconfigured audiences (or no scope) pass through.
116
+ def enforce_audience_scopes!
117
+ return if audience.blank? || scope.blank?
118
+
119
+ StandardId::Oauth::AudienceScopeResolver.assert!(requested: scope, audience: audience, client: @client)
100
120
  end
101
121
  end
102
122
  end