standard_id 0.48.1 → 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 +17 -0
- data/README.md +172 -0
- 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 +7 -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/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
|
@@ -11,11 +11,18 @@ module StandardId
|
|
|
11
11
|
# in the same flow-object style as the other lib/standard_id/oauth/ objects.
|
|
12
12
|
#
|
|
13
13
|
# On success #call returns a Result carrying the persisted client plus the
|
|
14
|
-
# one-time plaintext secret (confidential clients only)
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
14
|
+
# one-time plaintext secret (confidential clients only) and, once migration
|
|
15
|
+
# 20261020120000 has run, the one-time RFC 7592 registration access token;
|
|
16
|
+
# on a metadata or redirect-uri problem it raises the matching RFC 7591
|
|
17
|
+
# §3.2.2 error (InvalidRedirectUriError / InvalidClientMetadataError), which
|
|
18
|
+
# the controller renders as HTTP 400. A nil owner resolver while the
|
|
19
|
+
# feature is enabled raises ConfigurationError (a host-app bug, not client
|
|
20
|
+
# input).
|
|
21
|
+
#
|
|
22
|
+
# `config.oauth.dynamic_registration_attributes` pins what the client may
|
|
23
|
+
# not choose (scopes, metadata, lifetimes, consent); `.update` applies an
|
|
24
|
+
# RFC 7592 §2.2 document to an existing client under the same rules, and
|
|
25
|
+
# `.deregister` is RFC 7592 §2.3.
|
|
19
26
|
class ClientRegistration
|
|
20
27
|
# RFC 7591 grant_types we support. M2M (client_credentials) is deliberately
|
|
21
28
|
# excluded from DCR — self-registered clients are public/interactive.
|
|
@@ -27,26 +34,68 @@ module StandardId
|
|
|
27
34
|
CONFIDENTIAL_AUTH_METHODS = %w[client_secret_basic client_secret_post].freeze
|
|
28
35
|
SUPPORTED_AUTH_METHODS = (CONFIDENTIAL_AUTH_METHODS + [PUBLIC_AUTH_METHOD]).freeze
|
|
29
36
|
DEFAULT_SCOPE = "openid profile email".freeze
|
|
37
|
+
# Keys `config.oauth.dynamic_registration_attributes` may pin.
|
|
38
|
+
PINNABLE_ATTRIBUTES = %w[scopes metadata access_token_lifetime authorization_code_lifetime require_consent description].freeze
|
|
39
|
+
REGISTRATION_ACCESS_TOKEN_BYTES = 32
|
|
30
40
|
|
|
31
41
|
# Minimal result object mirroring the gem's `result.success?` /
|
|
32
42
|
# `result.value` convention. `client_secret` is the one-time plaintext for
|
|
33
|
-
# confidential clients (nil for public clients)
|
|
34
|
-
|
|
43
|
+
# confidential clients (nil for public clients);
|
|
44
|
+
# `registration_access_token` the one-time RFC 7592 credential (nil until
|
|
45
|
+
# migration 20261020120000 has run, and on update).
|
|
46
|
+
Result = Struct.new(:client, :client_secret, :token_endpoint_auth_method, :registration_access_token, keyword_init: true) do
|
|
35
47
|
def success? = true
|
|
36
48
|
def value = client
|
|
37
49
|
end
|
|
38
50
|
|
|
39
51
|
# @param metadata [Hash] RFC 7591 client metadata (symbolized or stringified keys)
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
# @param request [ActionDispatch::Request, nil] handed to the owner and
|
|
53
|
+
# attributes callables
|
|
54
|
+
# Accepts the document positionally or as keywords (`call(redirect_uris:
|
|
55
|
+
# [...])`), since a bare Hash literal binds to keywords in Ruby 3.
|
|
56
|
+
def initialize(metadata = nil, request: nil, **document)
|
|
57
|
+
@metadata = (metadata || {}).to_h.merge(document).symbolize_keys
|
|
58
|
+
@request = request
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.call(metadata = nil, request: nil, **document)
|
|
62
|
+
new(metadata, request: request, **document).call
|
|
42
63
|
end
|
|
43
64
|
|
|
44
|
-
|
|
45
|
-
|
|
65
|
+
# RFC 7592 §2.2: apply `metadata` to an already-registered `client`.
|
|
66
|
+
# Only the client-shaped members change (client_name, redirect_uris,
|
|
67
|
+
# grant_types, response_types, and scope unless pinned); the pinned
|
|
68
|
+
# attributes, client_type, PKCE and the registration access token do not.
|
|
69
|
+
# A `client_id` in the document must match; a `token_endpoint_auth_method`
|
|
70
|
+
# must match the client's type (there is no secret rotation here).
|
|
71
|
+
def self.update(client, metadata = nil, request: nil, **document)
|
|
72
|
+
new(metadata, request: request, **document).update(client)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# RFC 7592 §2.3: the client stops being usable. Deactivated rather than
|
|
76
|
+
# deleted so the refresh_token grant and introspection can say "gone"
|
|
77
|
+
# (0.48.0 liveness); its live refresh tokens are revoked and its unused
|
|
78
|
+
# authorization codes removed. The purge job deletes the row later.
|
|
79
|
+
def self.deregister(client)
|
|
80
|
+
now = Time.current
|
|
81
|
+
StandardId::ClientApplication.transaction do
|
|
82
|
+
client.deactivate!
|
|
83
|
+
client.authorization_codes.where(consumed_at: nil).delete_all
|
|
84
|
+
if StandardId::RefreshToken.client_binding_column?
|
|
85
|
+
client.refresh_tokens.where(revoked_at: nil).update_all(revoked_at: now)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
client
|
|
46
89
|
end
|
|
47
90
|
|
|
48
91
|
def call
|
|
49
92
|
attrs = mapped_attributes
|
|
93
|
+
registration_access_token = nil
|
|
94
|
+
if StandardId::ClientApplication.registration_management_column?
|
|
95
|
+
registration_access_token = SecureRandom.urlsafe_base64(REGISTRATION_ACCESS_TOKEN_BYTES)
|
|
96
|
+
attrs[:registration_access_token_digest] =
|
|
97
|
+
StandardId::ClientApplication.digest_registration_access_token(registration_access_token)
|
|
98
|
+
end
|
|
50
99
|
client = StandardId::ClientApplication.new(attrs)
|
|
51
100
|
|
|
52
101
|
secret_plaintext = nil
|
|
@@ -61,17 +110,43 @@ module StandardId
|
|
|
61
110
|
end
|
|
62
111
|
end
|
|
63
112
|
|
|
64
|
-
Result.new(client: client, client_secret: secret_plaintext, token_endpoint_auth_method: auth_method
|
|
113
|
+
Result.new(client: client, client_secret: secret_plaintext, token_endpoint_auth_method: auth_method,
|
|
114
|
+
registration_access_token: registration_access_token)
|
|
115
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
116
|
+
raise_for(e.record)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def update(client)
|
|
120
|
+
if metadata[:client_id].present? && metadata[:client_id].to_s != client.client_id
|
|
121
|
+
raise StandardId::InvalidClientMetadataError, "client_id does not match the client being updated"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
requested_method = metadata[:token_endpoint_auth_method].to_s.strip
|
|
125
|
+
if requested_method.present? && (requested_method == PUBLIC_AUTH_METHOD) != client.public?
|
|
126
|
+
raise StandardId::InvalidClientMetadataError,
|
|
127
|
+
"token_endpoint_auth_method cannot change a client between public and confidential"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
attrs = {
|
|
131
|
+
name: client_name,
|
|
132
|
+
redirect_uris: redirect_uris,
|
|
133
|
+
grant_types: grant_types,
|
|
134
|
+
response_types: response_types
|
|
135
|
+
}
|
|
136
|
+
attrs[:scopes] = scope unless pinned.key?("scopes") || metadata[:scope].blank?
|
|
137
|
+
|
|
138
|
+
client.update!(attrs)
|
|
139
|
+
Result.new(client: client, token_endpoint_auth_method: client.token_endpoint_auth_method)
|
|
65
140
|
rescue ActiveRecord::RecordInvalid => e
|
|
66
141
|
raise_for(e.record)
|
|
67
142
|
end
|
|
68
143
|
|
|
69
144
|
private
|
|
70
145
|
|
|
71
|
-
attr_reader :metadata
|
|
146
|
+
attr_reader :metadata, :request
|
|
72
147
|
|
|
73
148
|
def mapped_attributes
|
|
74
|
-
{
|
|
149
|
+
attrs = {
|
|
75
150
|
owner: resolve_owner!,
|
|
76
151
|
name: client_name,
|
|
77
152
|
redirect_uris: redirect_uris,
|
|
@@ -81,8 +156,35 @@ module StandardId
|
|
|
81
156
|
client_type: client_type,
|
|
82
157
|
require_pkce: require_pkce?,
|
|
83
158
|
code_challenge_methods: code_challenge_methods,
|
|
84
|
-
require_consent: true
|
|
159
|
+
require_consent: pinned.fetch("require_consent", true)
|
|
85
160
|
}
|
|
161
|
+
attrs[:metadata] = pinned["metadata"].to_h.stringify_keys if pinned["metadata"].present?
|
|
162
|
+
%w[access_token_lifetime authorization_code_lifetime description].each do |key|
|
|
163
|
+
attrs[key.to_sym] = pinned[key] if pinned.key?(key)
|
|
164
|
+
end
|
|
165
|
+
attrs
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# `config.oauth.dynamic_registration_attributes`, resolved for this
|
|
169
|
+
# registration, keys stringified. An unknown key is a host bug.
|
|
170
|
+
def pinned
|
|
171
|
+
return @pinned if defined?(@pinned)
|
|
172
|
+
|
|
173
|
+
configured = StandardId.config.oauth.dynamic_registration_attributes
|
|
174
|
+
if configured.respond_to?(:call)
|
|
175
|
+
args = StandardId::Utils::CallableParameterFilter.filter(configured, { metadata: metadata, request: request })
|
|
176
|
+
configured = configured.call(**args)
|
|
177
|
+
end
|
|
178
|
+
resolved = (configured || {}).to_h.transform_keys(&:to_s)
|
|
179
|
+
|
|
180
|
+
unknown = resolved.keys - PINNABLE_ATTRIBUTES
|
|
181
|
+
if unknown.any?
|
|
182
|
+
raise StandardId::ConfigurationError,
|
|
183
|
+
"oauth.dynamic_registration_attributes has unknown key(s) #{unknown.inspect}; " \
|
|
184
|
+
"allowed: #{PINNABLE_ATTRIBUTES.join(', ')}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
@pinned = resolved
|
|
86
188
|
end
|
|
87
189
|
|
|
88
190
|
# redirect_uris is REQUIRED (RFC 7591 §2). We pass the raw value through to
|
|
@@ -125,9 +227,22 @@ module StandardId
|
|
|
125
227
|
requested.join(" ")
|
|
126
228
|
end
|
|
127
229
|
|
|
230
|
+
# Pinned scopes win over whatever was requested; a pinned audience with
|
|
231
|
+
# a scope vocabulary is the next best server-side answer; only then the
|
|
232
|
+
# requested scope.
|
|
128
233
|
def scope
|
|
129
|
-
|
|
130
|
-
|
|
234
|
+
if pinned.key?("scopes")
|
|
235
|
+
return Array(pinned["scopes"]).flat_map { |v| v.to_s.split }.reject(&:blank?).uniq.join(" ")
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
audience = pinned.dig("metadata", "audience") || pinned.dig("metadata", :audience)
|
|
239
|
+
if audience.present?
|
|
240
|
+
vocabulary = StandardId::Oauth::AudienceScopeResolver.scopes_for(audience: audience.to_s)
|
|
241
|
+
return vocabulary.join(" ") if vocabulary.any?
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
requested = metadata[:scope].to_s.strip
|
|
245
|
+
requested.presence || DEFAULT_SCOPE
|
|
131
246
|
end
|
|
132
247
|
|
|
133
248
|
def auth_method
|
|
@@ -152,12 +267,24 @@ module StandardId
|
|
|
152
267
|
|
|
153
268
|
def client_type
|
|
154
269
|
method = auth_method
|
|
155
|
-
|
|
156
|
-
|
|
270
|
+
allowed = allowed_auth_methods
|
|
271
|
+
unless allowed.include?(method)
|
|
272
|
+
raise StandardId::InvalidClientMetadataError,
|
|
273
|
+
"Unsupported token_endpoint_auth_method: #{method.inspect}. " \
|
|
274
|
+
"Allowed: #{allowed.join(', ')}"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
method == PUBLIC_AUTH_METHOD ? "public" : "confidential"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# `config.oauth.dynamic_registration_auth_methods` ∩ what the gem supports.
|
|
281
|
+
def allowed_auth_methods
|
|
282
|
+
configured = Array(StandardId.config.oauth.dynamic_registration_auth_methods).map(&:to_s)
|
|
283
|
+
allowed = configured & SUPPORTED_AUTH_METHODS
|
|
284
|
+
return allowed if allowed.any?
|
|
157
285
|
|
|
158
|
-
raise StandardId::
|
|
159
|
-
"
|
|
160
|
-
"Allowed: #{SUPPORTED_AUTH_METHODS.join(', ')}"
|
|
286
|
+
raise StandardId::ConfigurationError,
|
|
287
|
+
"oauth.dynamic_registration_auth_methods must include at least one of #{SUPPORTED_AUTH_METHODS.join(', ')}"
|
|
161
288
|
end
|
|
162
289
|
|
|
163
290
|
# Public clients are always forced onto PKCE/S256 (the model also validates
|
|
@@ -179,7 +306,8 @@ module StandardId
|
|
|
179
306
|
"(e.g. -> { Organization.default })"
|
|
180
307
|
end
|
|
181
308
|
|
|
182
|
-
|
|
309
|
+
args = StandardId::Utils::CallableParameterFilter.filter(resolver, { metadata: metadata, request: request })
|
|
310
|
+
owner = resolver.call(**args)
|
|
183
311
|
if owner.nil?
|
|
184
312
|
raise StandardId::ConfigurationError,
|
|
185
313
|
"oauth.dynamic_registration_owner resolved to nil; it must return the " \
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Oauth
|
|
3
|
+
# The grant behind StandardId::TokenMinter: a token for an account the host
|
|
4
|
+
# has ALREADY authenticated by its own means (an operator console, a
|
|
5
|
+
# service-to-service mint, a self-service "mint me a token" tool), minted
|
|
6
|
+
# through the same pipeline as every other grant — audience validation,
|
|
7
|
+
# audience → profile binding, scope_claims / claim_resolvers,
|
|
8
|
+
# custom_claims, OAUTH_TOKEN_ISSUING / OAUTH_TOKEN_ISSUED (so AccountStatus
|
|
9
|
+
# and AccountLocking refuse, and audit sees it), optional refresh token and
|
|
10
|
+
# session persistence.
|
|
11
|
+
#
|
|
12
|
+
# Deliberately NOT reachable from /oauth/token: TokensController maps
|
|
13
|
+
# grant_type strings to flows explicitly, and this one is never in that
|
|
14
|
+
# map. The caller supplies the account; the flow authenticates nothing.
|
|
15
|
+
class DirectMintFlow < TokenGrantFlow
|
|
16
|
+
def initialize(account:, audience:, grant_type:, scopes: nil, client_id: nil, claims: {}, expires_in: nil,
|
|
17
|
+
refresh_token: false, request: nil, auth_method: nil, auth_provider: nil)
|
|
18
|
+
super({ audience: audience }, request || NullRequest.new)
|
|
19
|
+
@account = account
|
|
20
|
+
@audience = audience
|
|
21
|
+
@grant_type = grant_type.to_s
|
|
22
|
+
@scopes = normalize_scopes(scopes)
|
|
23
|
+
@client_id = client_id.presence&.to_s
|
|
24
|
+
@claims = claims.to_h.symbolize_keys
|
|
25
|
+
@expires_in = expires_in
|
|
26
|
+
@refresh_token = refresh_token
|
|
27
|
+
@request_given = !request.nil?
|
|
28
|
+
@auth_method = auth_method
|
|
29
|
+
@auth_provider = auth_provider
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Public so TokenMinter can report the jti the audit event carried.
|
|
33
|
+
def issued_jti
|
|
34
|
+
@issued_jti
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def authenticate!
|
|
38
|
+
raise StandardId::InvalidGrantError, "Account is required" if @account.nil?
|
|
39
|
+
raise StandardId::InvalidGrantError, "Account must be persisted" unless @account.respond_to?(:persisted?) && @account.persisted?
|
|
40
|
+
raise StandardId::InvalidRequestError, "grant_type is required" if @grant_type.blank?
|
|
41
|
+
# The refresh grant matches the token's client_id against the one the
|
|
42
|
+
# caller presents, so a refreshable token with none could never be
|
|
43
|
+
# redeemed.
|
|
44
|
+
if @refresh_token && @client_id.nil?
|
|
45
|
+
raise StandardId::InvalidRequestError, "client_id is required for a refreshable token"
|
|
46
|
+
end
|
|
47
|
+
if !@expires_in.nil? && @expires_in.to_i <= 0
|
|
48
|
+
raise StandardId::InvalidRequestError, "expires_in must be a positive number of seconds"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# No new authentication happened here: the host vouches for the account.
|
|
55
|
+
def login_method_policy_context
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def subject_id = @account.id
|
|
60
|
+
def client_id = @client_id
|
|
61
|
+
def token_scope = @scopes
|
|
62
|
+
def grant_type = @grant_type
|
|
63
|
+
def audience = @audience
|
|
64
|
+
def token_account = @account
|
|
65
|
+
def supports_refresh_token? = @refresh_token
|
|
66
|
+
|
|
67
|
+
def token_client
|
|
68
|
+
return nil if @client_id.nil?
|
|
69
|
+
|
|
70
|
+
super
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# An explicit lifetime wins (validated positive in authenticate!);
|
|
74
|
+
# otherwise the configured lifetime for this grant_type key, falling
|
|
75
|
+
# back to default_token_lifetime.
|
|
76
|
+
def token_expiry
|
|
77
|
+
return @expires_in.to_i.seconds unless @expires_in.nil?
|
|
78
|
+
|
|
79
|
+
super
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# What the host says about how it authenticated the account, recorded on
|
|
83
|
+
# the refresh token so a restrictive login_method_policy can judge a
|
|
84
|
+
# later refresh; nil is recorded as :unspecified, which such a policy
|
|
85
|
+
# refuses — so pass it when a policy is configured.
|
|
86
|
+
def refresh_token_auth_lineage
|
|
87
|
+
StandardId::AuthLineage.build(@auth_method, @auth_provider)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Resolvers and custom_claims see `request: nil` for a sessionless mint,
|
|
91
|
+
# not the internal stand-in, so a `request&.…` guard takes the nil path.
|
|
92
|
+
def claim_resolvers_context
|
|
93
|
+
@claim_resolvers_context ||= super.merge(request: @request_given ? request : nil)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Host-supplied claims ride on top of the standard payload and the
|
|
97
|
+
# resolver-derived claims, but can never override a reserved JWT key or a
|
|
98
|
+
# base session field (same rule as config.oauth.custom_claims).
|
|
99
|
+
def build_jwt_payload(expires_in)
|
|
100
|
+
extra = @claims.except(*StandardId::JwtService::RESERVED_JWT_KEYS, *StandardId::JwtService::BASE_SESSION_FIELDS)
|
|
101
|
+
super(expires_in).merge(extra).compact
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# A session is only materialised when the caller passed a real request:
|
|
105
|
+
# the resolver and OauthSessionPersistence read remote_ip / user_agent
|
|
106
|
+
# from it, and a programmatic mint with no request has no client to
|
|
107
|
+
# describe. Machine-to-machine stays sessionless, as client_credentials is.
|
|
108
|
+
def maybe_persist_session_for_token!
|
|
109
|
+
return nil unless @request_given
|
|
110
|
+
|
|
111
|
+
super
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def normalize_scopes(scopes)
|
|
115
|
+
list = Array(scopes).flat_map { |s| s.to_s.split(/\s+/) }.reject(&:blank?).uniq
|
|
116
|
+
list.empty? ? nil : list.join(" ")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# What the flow reads from a request when the caller gave none.
|
|
120
|
+
class NullRequest
|
|
121
|
+
def remote_ip = nil
|
|
122
|
+
def user_agent = nil
|
|
123
|
+
def params = {}
|
|
124
|
+
def headers = {}
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -2,7 +2,7 @@ module StandardId
|
|
|
2
2
|
module Oauth
|
|
3
3
|
class ImplicitAuthorizationFlow < AuthorizationFlow
|
|
4
4
|
expect_params :client_id
|
|
5
|
-
permit_params :audience, :scope, :state, :redirect_uri, :nonce, :connection, :prompt, :organization, :invitation
|
|
5
|
+
permit_params :audience, :resource, :scope, :state, :redirect_uri, :nonce, :connection, :prompt, :organization, :invitation
|
|
6
6
|
|
|
7
7
|
private
|
|
8
8
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Oauth
|
|
3
|
+
# RFC 9728 OAuth 2.0 Protected Resource Metadata, and the RFC 6750
|
|
4
|
+
# `WWW-Authenticate` challenge that points clients at it.
|
|
5
|
+
#
|
|
6
|
+
# A host declares the resources it serves, keyed by the audience their
|
|
7
|
+
# tokens carry:
|
|
8
|
+
#
|
|
9
|
+
# c.oauth.protected_resources = {
|
|
10
|
+
# "mcp" => {
|
|
11
|
+
# path: "/mcp", # required: where the resource lives
|
|
12
|
+
# scopes_supported: %w[mcp mcp:read], # default: audience_scopes["mcp"]
|
|
13
|
+
# authorization_servers: [...], # default: [issuer || origin]
|
|
14
|
+
# bearer_methods_supported: %w[header], # default
|
|
15
|
+
# resource_name: "Example MCP server" # any other RFC 9728 member
|
|
16
|
+
# }
|
|
17
|
+
# }
|
|
18
|
+
#
|
|
19
|
+
# `path` may also be an absolute URL for a resource served from another
|
|
20
|
+
# origin. Any member may be a callable taking one Hash
|
|
21
|
+
# `{ origin:, audience:, issuer:, request: }`; a nil value removes the member.
|
|
22
|
+
#
|
|
23
|
+
# The document for a resource is served at
|
|
24
|
+
# `<origin>/.well-known/oauth-protected-resource<path>` (RFC 9728 §3.1,
|
|
25
|
+
# the path-inserted form that is the exact URL the challenge names) by
|
|
26
|
+
# `standard_id_protected_resource_routes` in the host's routes file, and the
|
|
27
|
+
# bare `/.well-known/oauth-protected-resource` serves the only configured
|
|
28
|
+
# resource (or the one marked `root: true`) for clients probing the root
|
|
29
|
+
# form. The engine also serves both under its own mount.
|
|
30
|
+
#
|
|
31
|
+
# Everything here is a pure function of config and an origin so it can be
|
|
32
|
+
# used from the controller, from StandardId::ResourceServer, and from tests
|
|
33
|
+
# without a request.
|
|
34
|
+
#
|
|
35
|
+
# The origin is the request's (`request.base_url`), on purpose: RFC 9728
|
|
36
|
+
# §3.3 requires `resource` to equal the identifier the client used, and the
|
|
37
|
+
# challenge must name a URL on the host the client is talking to. That
|
|
38
|
+
# makes the document only as trustworthy as the `Host` header, so a host
|
|
39
|
+
# must pin it (`config.hosts`, or the proxy) — the same requirement every
|
|
40
|
+
# request-derived URL in a Rails app has, stated here because this one is
|
|
41
|
+
# served with `Cache-Control: public`.
|
|
42
|
+
module ProtectedResourceDocument
|
|
43
|
+
WELL_KNOWN_PATH = "/.well-known/oauth-protected-resource".freeze
|
|
44
|
+
# `resource` is computed, never configured: RFC 9728 §3.3 requires it to
|
|
45
|
+
# match the URL the client used, which is the request origin plus `path`.
|
|
46
|
+
RESERVED_KEYS = %w[path root resource].freeze
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
# @return [Hash{String => Hash}] configured resources, keys stringified
|
|
51
|
+
def configured
|
|
52
|
+
(StandardId.config.oauth.protected_resources || {}).to_h.each_with_object({}) do |(audience, entry), acc|
|
|
53
|
+
acc[audience.to_s] = (entry || {}).to_h.transform_keys(&:to_s)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Boolean] whether `audience` names a configured resource
|
|
58
|
+
def configured?(audience)
|
|
59
|
+
audience.present? && configured.key?(audience.to_s)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The audience whose `path` is `path` (compared with the trailing slash
|
|
63
|
+
# removed), or nil.
|
|
64
|
+
def audience_for_path(path)
|
|
65
|
+
wanted = normalize_path(path)
|
|
66
|
+
return nil if wanted.empty?
|
|
67
|
+
|
|
68
|
+
configured.each do |audience, entry|
|
|
69
|
+
return audience if normalize_path(entry["path"]) == wanted
|
|
70
|
+
end
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The audience served at the bare well-known URL: the only one configured,
|
|
75
|
+
# else the one marked `root: true`, else nil.
|
|
76
|
+
def default_audience
|
|
77
|
+
all = configured
|
|
78
|
+
return all.keys.first if all.size == 1
|
|
79
|
+
|
|
80
|
+
all.each { |audience, entry| return audience if entry["root"] }
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Absolute URL of the resource (RFC 9728 `resource`).
|
|
85
|
+
def resource_url(audience:, origin:)
|
|
86
|
+
path = path_for!(audience)
|
|
87
|
+
return path.chomp("/") if absolute?(path)
|
|
88
|
+
|
|
89
|
+
"#{origin.to_s.chomp('/')}#{normalize_path(path)}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Absolute URL of this resource's metadata document — the value the
|
|
93
|
+
# `resource_metadata` challenge parameter carries. Raises for an
|
|
94
|
+
# unconfigured audience rather than naming a bare URL that would serve
|
|
95
|
+
# another resource's document (or nothing).
|
|
96
|
+
def metadata_url(audience:, origin:)
|
|
97
|
+
path = path_for!(audience)
|
|
98
|
+
suffix = absolute?(path) ? normalize_path(URI.parse(path).path) : normalize_path(path)
|
|
99
|
+
"#{origin.to_s.chomp('/')}#{WELL_KNOWN_PATH}#{suffix}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @return [String] the configured path
|
|
103
|
+
# @raise [StandardId::ConfigurationError] when the audience is not
|
|
104
|
+
# configured or its entry has no path
|
|
105
|
+
def path_for!(audience)
|
|
106
|
+
entry = configured[audience.to_s]
|
|
107
|
+
raise StandardId::ConfigurationError, "oauth.protected_resources has no entry for #{audience.inspect}" if entry.nil?
|
|
108
|
+
|
|
109
|
+
path = entry["path"].to_s
|
|
110
|
+
raise StandardId::ConfigurationError, "oauth.protected_resources[#{audience.inspect}] has no :path" if path.blank?
|
|
111
|
+
|
|
112
|
+
path
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @return [Hash] the RFC 9728 document for `audience`
|
|
116
|
+
# @raise [StandardId::ConfigurationError] when the audience is not configured
|
|
117
|
+
def build(audience:, origin:, request: nil)
|
|
118
|
+
entry = configured[audience.to_s]
|
|
119
|
+
raise StandardId::ConfigurationError, "oauth.protected_resources has no entry for #{audience.inspect}" if entry.nil?
|
|
120
|
+
|
|
121
|
+
context = { origin: origin, audience: audience.to_s, issuer: StandardId::Oauth::DiscoveryResolver.issuer, request: request }
|
|
122
|
+
doc = {
|
|
123
|
+
resource: resource_url(audience: audience, origin: origin),
|
|
124
|
+
authorization_servers: [StandardId::Oauth::DiscoveryResolver.issuer.presence || origin.to_s.chomp("/")],
|
|
125
|
+
bearer_methods_supported: %w[header]
|
|
126
|
+
}
|
|
127
|
+
scopes = StandardId::Oauth::AudienceScopeResolver.scopes_for(audience: audience.to_s)
|
|
128
|
+
doc[:scopes_supported] = scopes if scopes.any?
|
|
129
|
+
|
|
130
|
+
entry.each do |key, value|
|
|
131
|
+
next if RESERVED_KEYS.include?(key)
|
|
132
|
+
|
|
133
|
+
resolved = value.respond_to?(:call) ? value.call(context) : value
|
|
134
|
+
if resolved.nil?
|
|
135
|
+
doc.delete(key.to_sym)
|
|
136
|
+
else
|
|
137
|
+
doc[key.to_sym] = resolved
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
doc
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# RFC 6750 §3 `WWW-Authenticate` value carrying the RFC 9728
|
|
144
|
+
# `resource_metadata` pointer. `error` is one of the three RFC 6750 codes;
|
|
145
|
+
# `scope` lists the scopes the resource needed (insufficient_scope).
|
|
146
|
+
# Quoted values are limited to the RFC 6750 quoted-string charset; a
|
|
147
|
+
# description that breaks it is dropped rather than emitted broken.
|
|
148
|
+
def challenge(audience:, origin:, error: "invalid_token", error_description: nil, scope: nil)
|
|
149
|
+
parts = [%(realm="#{audience}")]
|
|
150
|
+
parts << %(error="#{error}") if error.present?
|
|
151
|
+
parts << %(error_description="#{error_description}") if error_description.present? && quotable?(error_description)
|
|
152
|
+
parts << %(scope="#{Array(scope).join(' ')}") if Array(scope).any?
|
|
153
|
+
parts << %(resource_metadata="#{metadata_url(audience: audience, origin: origin)}")
|
|
154
|
+
"Bearer #{parts.join(', ')}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def quotable?(value)
|
|
158
|
+
!value.to_s.match?(/["\\\r\n]/)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def absolute?(path)
|
|
162
|
+
path.to_s.match?(%r{\Ahttps?://}i)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# "" or "/foo" — never "/" and never a trailing slash.
|
|
166
|
+
def normalize_path(path)
|
|
167
|
+
StandardId::Oauth::DiscoveryResolver.normalize_path(path)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -7,7 +7,7 @@ module StandardId
|
|
|
7
7
|
MAX_REUSE_LEEWAY_SECONDS = 120
|
|
8
8
|
|
|
9
9
|
expect_params :refresh_token, :client_id
|
|
10
|
-
permit_params :client_secret, :scope, :audience
|
|
10
|
+
permit_params :client_secret, :scope, :audience, :resource
|
|
11
11
|
|
|
12
12
|
# authenticate! runs outside the transaction so reuse-detection
|
|
13
13
|
# revocations (revoke_family!) persist even when the error propagates.
|
data/lib/standard_id/routing.rb
CHANGED
|
@@ -99,6 +99,27 @@ module StandardId
|
|
|
99
99
|
end
|
|
100
100
|
end
|
|
101
101
|
|
|
102
|
+
# Draw the RFC 9728 protected-resource metadata at the ORIGIN ROOT:
|
|
103
|
+
#
|
|
104
|
+
# /.well-known/oauth-protected-resource (the default resource)
|
|
105
|
+
# /.well-known/oauth-protected-resource/<path> (RFC 9728 §3.1, per resource)
|
|
106
|
+
#
|
|
107
|
+
# One glob route; which resource a path names is decided per request from
|
|
108
|
+
# `config.oauth.protected_resources`, so adding a resource is a config
|
|
109
|
+
# change, not a routes change. The second form is the exact URL the
|
|
110
|
+
# `WWW-Authenticate resource_metadata` challenge from
|
|
111
|
+
# StandardId::ResourceServer carries.
|
|
112
|
+
#
|
|
113
|
+
# # config/routes.rb
|
|
114
|
+
# standard_id_well_known_routes at: "/api"
|
|
115
|
+
# standard_id_protected_resource_routes
|
|
116
|
+
def standard_id_protected_resource_routes
|
|
117
|
+
get ".well-known/oauth-protected-resource(/*resource_path)",
|
|
118
|
+
to: "standard_id/api/well_known/oauth_protected_resource#show",
|
|
119
|
+
as: :standard_id_root_oauth_protected_resource,
|
|
120
|
+
format: false
|
|
121
|
+
end
|
|
122
|
+
|
|
102
123
|
# @return [Hash] the WELL_KNOWN_DOCUMENTS entries `only:`/`except:` select
|
|
103
124
|
def self.selected_documents(only: nil, except: nil)
|
|
104
125
|
if only.present? && except.present?
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
# Mint an access token (and optionally a refresh token) for an account the
|
|
3
|
+
# host has already authenticated, through the same pipeline /oauth/token
|
|
4
|
+
# uses — so the claims, the audience → profile binding, the account-status
|
|
5
|
+
# guards and the OAUTH_TOKEN_ISSUING / OAUTH_TOKEN_ISSUED events are the ones
|
|
6
|
+
# every other token gets, instead of a hand-rolled JwtService.encode that
|
|
7
|
+
# re-derives the initializer's claim resolvers by hand.
|
|
8
|
+
#
|
|
9
|
+
# result = StandardId::TokenMinter.mint(
|
|
10
|
+
# account: account,
|
|
11
|
+
# audience: "harness",
|
|
12
|
+
# scopes: "profile permissions",
|
|
13
|
+
# grant_type: "harness.mint", # free-form; lands in the JWT and the audit event
|
|
14
|
+
# claims: { permissions: scopes }, # host extras; reserved keys are dropped
|
|
15
|
+
# expires_in: 60.minutes
|
|
16
|
+
# )
|
|
17
|
+
# result.access_token # the JWT
|
|
18
|
+
# result.jti # what OAUTH_TOKEN_ISSUED carried
|
|
19
|
+
#
|
|
20
|
+
# What runs, in order: audience validation (oauth.allowed_audiences),
|
|
21
|
+
# audience → profile binding (oauth.audience_profile_types, fail-closed),
|
|
22
|
+
# OAUTH_TOKEN_ISSUING (AccountStatus / AccountLocking refuse here),
|
|
23
|
+
# scope_claims + claim_resolvers and custom_claims, then the host's own
|
|
24
|
+
# +claims+, then OAUTH_TOKEN_ISSUED. A refresh token is persisted only when
|
|
25
|
+
# +refresh_token: true+, bound to +client_id+'s ClientApplication when that
|
|
26
|
+
# names one. Pass +request:+ to let config.session.session_type_resolver
|
|
27
|
+
# materialise a session; without it the mint is sessionless.
|
|
28
|
+
#
|
|
29
|
+
# Raises the same errors the token endpoint would (InvalidGrantError,
|
|
30
|
+
# InvalidRequestError, NoBoundProfileError, AccountDeactivatedError, …).
|
|
31
|
+
module TokenMinter
|
|
32
|
+
Result = Data.define(:access_token, :refresh_token, :token_type, :expires_in, :scope, :jti)
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# @param account [ActiveRecord::Base] a persisted account
|
|
37
|
+
# @param audience [String, Array<String>] the `aud` claim
|
|
38
|
+
# @param grant_type [String] recorded in the JWT and the audit events
|
|
39
|
+
# @param scopes [String, Array<String>, nil] the `scope` claim and the key
|
|
40
|
+
# into oauth.scope_claims
|
|
41
|
+
# @param client_id [String, nil] the `client_id` claim; a registered
|
|
42
|
+
# ClientApplication's id binds the refresh token to it
|
|
43
|
+
# @param claims [Hash] extra claims merged last (reserved keys dropped)
|
|
44
|
+
# @param expires_in [Integer, ActiveSupport::Duration, nil] access-token
|
|
45
|
+
# lifetime; default is oauth.token_lifetimes[grant_type] or
|
|
46
|
+
# oauth.default_token_lifetime
|
|
47
|
+
# @param refresh_token [Boolean] also persist and return a refresh token;
|
|
48
|
+
# requires +client_id+, which the refresh grant matches on redemption
|
|
49
|
+
# @param request [ActionDispatch::Request, nil] lets session_type_resolver
|
|
50
|
+
# persist a session; resolvers see `request: nil` without it
|
|
51
|
+
# @param auth_method [Symbol, String, nil] how the host authenticated the
|
|
52
|
+
# account, recorded on the refresh token for login_method_policy; nil is
|
|
53
|
+
# judged as :unspecified on refresh, which a restrictive policy refuses
|
|
54
|
+
# @param auth_provider [String, nil]
|
|
55
|
+
# @return [Result]
|
|
56
|
+
def mint(account:, audience:, grant_type:, scopes: nil, client_id: nil, claims: {}, expires_in: nil,
|
|
57
|
+
refresh_token: false, request: nil, auth_method: nil, auth_provider: nil)
|
|
58
|
+
flow = StandardId::Oauth::DirectMintFlow.new(
|
|
59
|
+
account: account, audience: audience, grant_type: grant_type, scopes: scopes, client_id: client_id,
|
|
60
|
+
claims: claims, expires_in: expires_in, refresh_token: refresh_token, request: request,
|
|
61
|
+
auth_method: auth_method, auth_provider: auth_provider
|
|
62
|
+
)
|
|
63
|
+
response = flow.execute
|
|
64
|
+
Result.new(
|
|
65
|
+
access_token: response[:access_token],
|
|
66
|
+
refresh_token: response[:refresh_token],
|
|
67
|
+
token_type: response[:token_type],
|
|
68
|
+
expires_in: response[:expires_in],
|
|
69
|
+
scope: response[:scope],
|
|
70
|
+
jti: flow.issued_jti
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/standard_id/version.rb
CHANGED