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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +178 -1
- data/app/controllers/concerns/standard_id/resource_server.rb +138 -0
- data/app/controllers/standard_id/api/authorization_controller.rb +1 -0
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +23 -0
- data/app/controllers/standard_id/api/oauth/registrations_controller.rb +99 -22
- data/app/controllers/standard_id/api/well_known/oauth_protected_resource_controller.rb +46 -0
- data/app/jobs/standard_id/cleanup_all_job.rb +4 -3
- data/app/jobs/standard_id/purge_stale_dynamic_clients_job.rb +73 -0
- data/app/models/standard_id/client_application.rb +38 -0
- data/config/routes/api.rb +9 -0
- data/db/migrate/20261020120000_add_registration_access_token_digest_to_standard_id_client_applications.rb +14 -0
- data/lib/generators/standard_id/install/install_generator.rb +2 -1
- data/lib/standard_id/config/callable_validator.rb +20 -0
- data/lib/standard_id/config/schema.rb +87 -0
- data/lib/standard_id/errors.rb +22 -0
- data/lib/standard_id/oauth/audience_resolver.rb +88 -0
- data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +1 -1
- data/lib/standard_id/oauth/authorization_code_flow.rb +3 -1
- data/lib/standard_id/oauth/authorization_flow.rb +21 -1
- data/lib/standard_id/oauth/client_registration.rb +151 -23
- data/lib/standard_id/oauth/direct_mint_flow.rb +128 -0
- data/lib/standard_id/oauth/implicit_authorization_flow.rb +1 -1
- data/lib/standard_id/oauth/protected_resource_document.rb +171 -0
- data/lib/standard_id/oauth/refresh_token_flow.rb +1 -1
- data/lib/standard_id/otp.rb +9 -1
- data/lib/standard_id/passwordless/base_strategy.rb +4 -2
- data/lib/standard_id/routing.rb +21 -0
- data/lib/standard_id/token_minter.rb +74 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +4 -0
- data/lib/tasks/standard_id_tasks.rake +8 -1
- 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
|
|
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
|
|
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
|
data/lib/standard_id/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|