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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +172 -0
  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 +7 -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/routing.rb +21 -0
  28. data/lib/standard_id/token_minter.rb +74 -0
  29. data/lib/standard_id/version.rb +1 -1
  30. data/lib/standard_id.rb +4 -0
  31. data/lib/tasks/standard_id_tasks.rake +8 -1
  32. 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); on a metadata or
15
- # redirect-uri problem it raises the matching RFC 7591 §3.2.2 error
16
- # (InvalidRedirectUriError / InvalidClientMetadataError), which the
17
- # controller renders as HTTP 400. A nil owner resolver while the feature is
18
- # enabled raises ConfigurationError (a host-app bug, not client input).
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
- Result = Struct.new(:client, :client_secret, :token_endpoint_auth_method, keyword_init: true) do
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
- def initialize(metadata)
41
- @metadata = (metadata || {}).to_h.symbolize_keys
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
- def self.call(metadata)
45
- new(metadata).call
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
- scope = metadata[:scope].to_s.strip
130
- scope.presence || DEFAULT_SCOPE
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
- return "public" if method == PUBLIC_AUTH_METHOD
156
- return "confidential" if CONFIDENTIAL_AUTH_METHODS.include?(method)
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::InvalidClientMetadataError,
159
- "Unsupported token_endpoint_auth_method: #{method.inspect}. " \
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
- owner = resolver.call
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.
@@ -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
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.48.1"
2
+ VERSION = "0.49.0"
3
3
  end