token_authority 0.2.1 → 0.3.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 (28) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/README.md +52 -14
  4. data/app/controllers/concerns/token_authority/initial_access_token_authentication.rb +2 -2
  5. data/app/controllers/token_authority/protected_resource_metadata_controller.rb +39 -0
  6. data/app/helpers/token_authority/authorization_grants_helper.rb +2 -3
  7. data/app/models/concerns/token_authority/claim_validatable.rb +2 -2
  8. data/app/models/concerns/token_authority/resourceable.rb +2 -2
  9. data/app/models/token_authority/access_token.rb +2 -2
  10. data/app/models/token_authority/access_token_request.rb +1 -1
  11. data/app/models/token_authority/authorization_request.rb +2 -2
  12. data/app/models/token_authority/authorization_server_metadata.rb +4 -4
  13. data/app/models/token_authority/client.rb +4 -4
  14. data/app/models/token_authority/client_metadata_document.rb +2 -2
  15. data/app/models/token_authority/client_registration_request.rb +5 -5
  16. data/app/models/token_authority/jwks_fetcher.rb +1 -1
  17. data/app/models/token_authority/protected_resource_metadata.rb +110 -31
  18. data/app/models/token_authority/refresh_token.rb +2 -2
  19. data/app/models/token_authority/refresh_token_request.rb +1 -1
  20. data/lib/generators/token_authority/install/templates/token_authority.rb +100 -114
  21. data/lib/token_authority/configuration.rb +345 -175
  22. data/lib/token_authority/errors.rb +29 -0
  23. data/lib/token_authority/routing/constraints.rb +2 -2
  24. data/lib/token_authority/routing/routes.rb +74 -16
  25. data/lib/token_authority/version.rb +1 -1
  26. data/lib/token_authority.rb +2 -2
  27. metadata +2 -2
  28. data/app/controllers/token_authority/resource_metadata_controller.rb +0 -12
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1b6b1981cf717ab9e179f87aaea998642ad74315333c25ea3ea1e8e267d3a293
4
- data.tar.gz: 25b6bb154158120808d02ab4f8c939c1d7f731f57db9e380167fe414ecaba7e0
3
+ metadata.gz: 8989a407b702176b74b3e121e4463dfc28e858097716c4cf6752f2ab54c78871
4
+ data.tar.gz: 1ecdecd8785d3f214e64359b4ab7e43ecb8630eb2af9237bb5a4d8a604d5227d
5
5
  SHA512:
6
- metadata.gz: 9ce50536b44c5ef5063160808e7c93bc757f01b01f81f62c1ed0d3ba86da56d675c0d97e6fb3d944da4ac822925e5a7efc81dfef2c1adbc2094f9a43a4801af7
7
- data.tar.gz: 476f43e644e3bf3b10aa57c3cff10e0bf2d1a73712b9cc0f2b9c89d306ec187e8663e3efd58582f5032f8f81f0ef8b5f46343daeb415b8e17a32ad047e2a38ee
6
+ metadata.gz: b3b4bbc0e464d6781255efe9b523d753abbe78843c5bec77c5057861eef157b112e099c4afbd8a073ef0f1d1f69fff1946f27dddb6d0ce95a9a91bb3e0bc6c34
7
+ data.tar.gz: 0615a3d781b577ebb433bc5e01cae57827a8fe5bad6a6866092ea44438526e81090c28a39b708120150610c34a544fb4a405f8d9331f16e18f7943a27e55df3a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2025-01-24
4
+
5
+ ### Added
6
+
7
+ - Added support for multiple protected resources; applications can now define multiple protected resources under different subdomain constraints.
8
+ - Added `issuer_url` method that returns either `token_issuer_url` or derives from `authorization_servers`
9
+ - Added validation requiring either `token_issuer_url` or `:authorization_servers` on at least one resource
10
+
11
+ ### Breaking
12
+
13
+ - Replaced `rfc_9728_*` config options with `config.resources` hash (keyed by symbol)
14
+ - Replaced `token_authority_routes` with `token_authority_auth_server_routes` and `token_authority_protected_resource_route`
15
+ - Removed `rfc_8707_resources` config option; resource allowlist is now derived from `config.resources`
16
+ - Renamed `rfc_8707_require_resource` to `require_resource`
17
+ - Renamed `rfc_8707_enabled?` method to `resources_enabled?`
18
+ - Changed default for `require_scope` from `false` to `true`
19
+ - Changed default for `require_resource` from `false` to `true`
20
+ - Changed default for `token_audience_url` from application URL to `nil`
21
+ - Changed default for `dcr_enabled` from `false` to `true`
22
+ - Changed default for `token_issuer_url` from required to `nil`
23
+ - When `token_audience_url` is nil, the `:resource` URL is used as the audience claim
24
+ - When `token_issuer_url` is nil, it's derived from the first resource's `:authorization_servers`
25
+ - Renamed configuration options to remove RFC number prefixes:
26
+ - `rfc_9068_audience_url` → `token_audience_url`
27
+ - `rfc_9068_issuer_url` → `token_issuer_url`
28
+ - `rfc_9068_default_access_token_duration` → `default_access_token_duration`
29
+ - `rfc_9068_default_refresh_token_duration` → `default_refresh_token_duration`
30
+ - `rfc_8414_service_documentation` → `authorization_server_documentation`
31
+ - `rfc_7591_enabled` → `dcr_enabled`
32
+ - `rfc_7591_require_initial_access_token` → `dcr_require_initial_access_token`
33
+ - `rfc_7591_initial_access_token_validator` → `dcr_initial_access_token_validator`
34
+ - `rfc_7591_allowed_grant_types` → `dcr_allowed_grant_types`
35
+ - `rfc_7591_allowed_response_types` → `dcr_allowed_response_types`
36
+ - `rfc_7591_allowed_scopes` → `dcr_allowed_scopes`
37
+ - `rfc_7591_allowed_token_endpoint_auth_methods` → `dcr_allowed_token_endpoint_auth_methods`
38
+ - `rfc_7591_client_secret_expiration` → `dcr_client_secret_expiration`
39
+ - `rfc_7591_software_statement_jwks` → `dcr_software_statement_jwks`
40
+ - `rfc_7591_software_statement_required` → `dcr_software_statement_required`
41
+ - `rfc_7591_jwks_cache_ttl` → `dcr_jwks_cache_ttl`
42
+
3
43
  ## [0.2.1] - 2025-01-24
4
44
 
5
45
  ### Fixes
@@ -29,7 +69,8 @@
29
69
 
30
70
  - Initial release
31
71
 
32
- [Unreleased]: https://github.com/dickdavis/token_authority/compare/v0.2.1...HEAD
72
+ [Unreleased]: https://github.com/dickdavis/token_authority/compare/v0.3.0...HEAD
73
+ [0.2.1]: https://github.com/dickdavis/token_authority/compare/v0.2.1...v0.3.0
33
74
  [0.2.1]: https://github.com/dickdavis/token_authority/compare/v0.2.0...v0.2.1
34
75
  [0.2.0]: https://github.com/dickdavis/token_authority/compare/v0.1.0...v0.2.0
35
76
  [0.1.0]: https://github.com/dickdavis/token_authority/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # TokenAuthority
2
2
 
3
- Rails engine allowing apps to act as their own OAuth 2.1 provider. The goal of this project is to make authorization dead simple for MCP server developers.
4
-
5
- This project aims to implement the OAuth standards specified in the [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#standards-compliance).
3
+ Rails engine allowing apps to act as their own OAuth 2.1 provider. The goal of this project is to make standards-based authorization as simple as possible.
6
4
 
7
5
  | Status | Standard |
8
6
  |--------|----------|
@@ -38,22 +36,35 @@ See the [Installation Guide](https://github.com/dickdavis/token_authority/wiki/I
38
36
 
39
37
  ### Configuration
40
38
 
41
- Configure TokenAuthority in the generated initializer. The following represents a minimal configuration:
39
+ Configure TokenAuthority in the generated initializer. TokenAuthority is configured with dynamic client registration, client metadata documents, and resource indicators enabled by default. The following represents a minimal configuration:
42
40
 
43
41
  ```ruby
44
42
  # config/initializers/token_authority.rb
45
43
  TokenAuthority.configure do |config|
46
- # The secret key used for encryption/decryption
44
+ # The secret key used for signing JWT tokens
47
45
  config.secret_key = Rails.application.credentials.secret_key_base
48
- # The URI for the protected resource (to be included in tokens and metadata)
49
- config.rfc_9068_audience_url = "https://example.com/api/"
50
- # The URI for the authorization server (to be included in tokens and metadata)
51
- config.rfc_9068_issuer_url = "https://example.com/"
52
- # Define available scopes and their descriptions (shown on consent screen)
46
+
47
+ # Define available scopes (required by default)
53
48
  config.scopes = {
54
49
  "read" => "Read your data",
55
- "write" => "Create and modify your data",
56
- "delete" => "Delete your data"
50
+ "write" => "Create and modify your data"
51
+ }
52
+
53
+ # Define protected resources (required by default)
54
+ # :resource is used as the audience (aud) claim in tokens
55
+ # :authorization_servers provides the issuer (iss) claim
56
+ config.resources = {
57
+ api: {
58
+ resource: "https://example.com/api",
59
+ resource_name: "My API",
60
+ scopes_supported: %w[read write],
61
+ authorization_servers: ["https://example.com"],
62
+ bearer_methods_supported: ["header"],
63
+ jwks_uri: "https://example.com/.well-known/jwks.json",
64
+ resource_documentation: "https://example.com/docs/api",
65
+ resource_policy_uri: "https://example.com/privacy",
66
+ resource_tos_uri: "https://example.com/terms"
67
+ }
57
68
  }
58
69
  end
59
70
  ```
@@ -66,7 +77,8 @@ Add the engine routes to your `config/routes.rb`:
66
77
 
67
78
  ```ruby
68
79
  Rails.application.routes.draw do
69
- token_authority_routes
80
+ token_authority_auth_server_routes
81
+ token_authority_protected_resource_route
70
82
  end
71
83
  ```
72
84
 
@@ -79,10 +91,36 @@ To mount the engine at a different path, use the `at` option:
79
91
 
80
92
  ```ruby
81
93
  Rails.application.routes.draw do
82
- token_authority_routes(at: "/auth")
94
+ token_authority_auth_server_routes(at: "/auth")
95
+ token_authority_protected_resource_route
83
96
  end
84
97
  ```
85
98
 
99
+ For applications with multiple protected resources, each resource must be on its own subdomain. This is because RFC 9728 defines a fixed well-known path (`/.well-known/oauth-protected-resource`) that can only exist once per host:
100
+
101
+ ```ruby
102
+ Rails.application.routes.draw do
103
+ token_authority_auth_server_routes
104
+
105
+ constraints subdomain: "api" do
106
+ token_authority_protected_resource_route
107
+ end
108
+
109
+ constraints subdomain: "mcp" do
110
+ token_authority_protected_resource_route
111
+ end
112
+ end
113
+ ```
114
+
115
+ > **Development Note:** Rails subdomain constraints require a real domain with proper DNS resolution. Use `lvh.me` (which resolves to `127.0.0.1`) for local development: `mcp.lvh.me:3000`, `api.lvh.me:3000`, etc. You'll also need to allow these hosts in your development config:
116
+ >
117
+ > ```ruby
118
+ > # config/environments/development.rb
119
+ > config.hosts << /.*\.lvh\.me/
120
+ > ```
121
+ >
122
+ > See the [Installation Guide](https://github.com/dickdavis/token_authority/wiki/Installation-Guide#subdomain-development-setup) for details.
123
+
86
124
  ### User Consent
87
125
 
88
126
  Before issuing authorization codes, TokenAuthority displays a consent screen where users can approve or deny access to OAuth clients. The consent views are fully customizable and the layout is configurable—see [Customizing Views](https://github.com/dickdavis/token_authority/wiki/Customizing-Views) for details.
@@ -13,14 +13,14 @@ module TokenAuthority
13
13
  private
14
14
 
15
15
  def initial_access_token_required?
16
- TokenAuthority.config.rfc_7591_require_initial_access_token
16
+ TokenAuthority.config.dcr_require_initial_access_token
17
17
  end
18
18
 
19
19
  def authenticate_initial_access_token
20
20
  token = extract_bearer_token
21
21
  raise TokenAuthority::InvalidInitialAccessTokenError if token.blank?
22
22
 
23
- validator = TokenAuthority.config.rfc_7591_initial_access_token_validator
23
+ validator = TokenAuthority.config.dcr_initial_access_token_validator
24
24
  raise TokenAuthority::InvalidInitialAccessTokenError unless validator&.call(token)
25
25
  end
26
26
 
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenAuthority
4
+ ##
5
+ # Serves RFC 9728 Protected Resource Metadata at /.well-known/oauth-protected-resource
6
+ #
7
+ # This controller enables OAuth clients to discover metadata about protected resources
8
+ # without requiring manual configuration. Clients can query this endpoint to learn:
9
+ # - Which authorization servers issue tokens for this resource
10
+ # - What scopes are supported
11
+ # - How to present bearer tokens (header vs body)
12
+ # - Where to find public keys for token verification
13
+ #
14
+ # The subdomain extraction strategy supports multi-tenant deployments where different
15
+ # subdomains represent different protected resources (e.g., api.example.com vs
16
+ # mcp.example.com). Returns 404 if no configuration exists for the subdomain.
17
+ #
18
+ # @see https://www.rfc-editor.org/rfc/rfc9728.html RFC 9728
19
+ # @since 0.3.0
20
+ class ProtectedResourceMetadataController < ActionController::API
21
+ # Returns metadata for the protected resource identified by request subdomain.
22
+ #
23
+ # The subdomain is extracted from the request and used to look up configuration.
24
+ # For requests without a subdomain (bare domain), uses the default protected_resource
25
+ # configuration if present. This allows both single-resource and multi-resource
26
+ # deployments with the same code.
27
+ #
28
+ # @return [JSON] RFC 9728 compliant metadata response
29
+ # @return [HTTP 404] if no configuration exists for this subdomain
30
+ def show
31
+ metadata = ProtectedResourceMetadata.new(resource: request.subdomain)
32
+ render json: metadata.to_h
33
+ rescue ResourceNotConfiguredError
34
+ # Return 404 rather than 500 because this is a client error: they're querying
35
+ # a subdomain that hasn't been configured as a protected resource
36
+ head :not_found
37
+ end
38
+ end
39
+ end
@@ -3,14 +3,13 @@
3
3
  module TokenAuthority
4
4
  module AuthorizationGrantsHelper
5
5
  # Returns a human-friendly display name for a resource URI.
6
- # Looks up the URI in the configured rfc_8707_resources mapping.
6
+ # Looks up the URI in the resource registry derived from protected resources.
7
7
  # Falls back to the URI itself if no mapping is configured.
8
8
  #
9
9
  # @param resource_uri [String] The resource URI
10
10
  # @return [String] The display name or the URI if no mapping exists
11
11
  def resource_display_name(resource_uri)
12
- resources = TokenAuthority.config.rfc_8707_resources || {}
13
- resources[resource_uri] || resource_uri
12
+ TokenAuthority.config.resource_registry[resource_uri] || resource_uri
14
13
  end
15
14
 
16
15
  # Returns a human-friendly display name for a scope.
@@ -37,7 +37,7 @@ module TokenAuthority
37
37
 
38
38
  validates :jti, presence: true
39
39
 
40
- validates :aud, presence: true, format: {with: /\A#{TokenAuthority.config.rfc_9068_audience_url}*/}
40
+ validates :aud, presence: true, format: {with: /\A#{TokenAuthority.config.audience_url}*/}
41
41
 
42
42
  validates :exp, presence: true
43
43
  validate do
@@ -46,7 +46,7 @@ module TokenAuthority
46
46
  errors.add(:exp, :expired) if Time.zone.now > Time.zone.at(exp)
47
47
  end
48
48
 
49
- validates :iss, presence: true, format: {with: /\A#{TokenAuthority.config.rfc_9068_issuer_url}\z/}
49
+ validates :iss, presence: true, format: {with: /\A#{TokenAuthority.config.issuer_url}\z/}
50
50
 
51
51
  after_validation :expire_token_authority_session, if: :errors_for_expirable_claims?
52
52
  after_validation :revoke_token_authority_session, if: :errors_for_revocable_claims?
@@ -88,9 +88,9 @@ module TokenAuthority
88
88
  # @return [Boolean] true if all resources are allowed
89
89
  # @api private
90
90
  def allowed_resources?
91
- return true unless TokenAuthority.config.rfc_8707_enabled?
91
+ return true unless TokenAuthority.config.resources_enabled?
92
92
 
93
- resources.all? { |uri| TokenAuthority.config.rfc_8707_resources.key?(uri) }
93
+ resources.all? { |uri| TokenAuthority.config.resource_registry.key?(uri) }
94
94
  end
95
95
 
96
96
  # Checks if the current resources are a subset of the granted resources.
@@ -74,7 +74,7 @@ module TokenAuthority
74
74
  aud = if resources.any?
75
75
  (resources.size == 1) ? resources.first : resources
76
76
  else
77
- TokenAuthority.config.rfc_9068_audience_url
77
+ TokenAuthority.config.audience_url
78
78
  end
79
79
 
80
80
  scope_claim = scopes.any? ? scopes.join(" ") : nil
@@ -83,7 +83,7 @@ module TokenAuthority
83
83
  aud:,
84
84
  exp:,
85
85
  iat: Time.zone.now.to_i,
86
- iss: TokenAuthority.config.rfc_9068_issuer_url,
86
+ iss: TokenAuthority.config.issuer_url,
87
87
  jti: SecureRandom.uuid,
88
88
  sub: user_id.to_s,
89
89
  client_id:,
@@ -141,7 +141,7 @@ module TokenAuthority
141
141
  return if resources.empty?
142
142
 
143
143
  # If resources are provided but feature is disabled, reject them
144
- unless TokenAuthority.config.rfc_8707_enabled?
144
+ unless TokenAuthority.config.resources_enabled?
145
145
  errors.add(:resources, :not_allowed)
146
146
  return
147
147
  end
@@ -225,7 +225,7 @@ module TokenAuthority
225
225
 
226
226
  def resources_must_be_valid
227
227
  # Check if resource is required
228
- if TokenAuthority.config.rfc_8707_require_resource && resources.empty?
228
+ if TokenAuthority.config.require_resource && resources.empty?
229
229
  errors.add(:resources, :required)
230
230
  return
231
231
  end
@@ -233,7 +233,7 @@ module TokenAuthority
233
233
  return if resources.empty?
234
234
 
235
235
  # If resources are provided but feature is disabled, reject them
236
- unless TokenAuthority.config.rfc_8707_enabled?
236
+ unless TokenAuthority.config.resources_enabled?
237
237
  errors.add(:resources, :not_allowed)
238
238
  return
239
239
  end
@@ -61,7 +61,7 @@ module TokenAuthority
61
61
  metadata[:service_documentation] = service_documentation if service_documentation.present?
62
62
 
63
63
  # RFC 7591 Dynamic Client Registration
64
- if TokenAuthority.config.rfc_7591_enabled
64
+ if TokenAuthority.config.dcr_enabled
65
65
  metadata[:registration_endpoint] = "#{issuer}#{@mount_path}/register"
66
66
  end
67
67
 
@@ -74,7 +74,7 @@ module TokenAuthority
74
74
  # @return [String]
75
75
  # @api private
76
76
  def issuer
77
- TokenAuthority.config.rfc_9068_issuer_url.to_s.chomp("/")
77
+ TokenAuthority.config.issuer_url.to_s.chomp("/")
78
78
  end
79
79
 
80
80
  # Returns the list of supported scopes from configuration.
@@ -88,14 +88,14 @@ module TokenAuthority
88
88
  # @return [String, nil]
89
89
  # @api private
90
90
  def service_documentation
91
- TokenAuthority.config.rfc_8414_service_documentation
91
+ TokenAuthority.config.authorization_server_documentation
92
92
  end
93
93
 
94
94
  # Returns the supported token endpoint authentication methods.
95
95
  # @return [Array<String>]
96
96
  # @api private
97
97
  def token_endpoint_auth_methods_supported
98
- TokenAuthority.config.rfc_7591_allowed_token_endpoint_auth_methods
98
+ TokenAuthority.config.dcr_allowed_token_endpoint_auth_methods
99
99
  end
100
100
  end
101
101
  end
@@ -241,8 +241,8 @@ module TokenAuthority
241
241
  end
242
242
 
243
243
  def set_default_durations
244
- self.access_token_duration ||= TokenAuthority.config.rfc_9068_default_access_token_duration
245
- self.refresh_token_duration ||= TokenAuthority.config.rfc_9068_default_refresh_token_duration
244
+ self.access_token_duration ||= TokenAuthority.config.default_access_token_duration
245
+ self.refresh_token_duration ||= TokenAuthority.config.default_refresh_token_duration
246
246
  end
247
247
 
248
248
  def set_client_id_issued_at
@@ -251,9 +251,9 @@ module TokenAuthority
251
251
 
252
252
  def set_client_secret_expiration
253
253
  return if client_type == "public"
254
- return unless TokenAuthority.config.rfc_7591_client_secret_expiration
254
+ return unless TokenAuthority.config.dcr_client_secret_expiration
255
255
 
256
- self.client_secret_expires_at = Time.current + TokenAuthority.config.rfc_7591_client_secret_expiration
256
+ self.client_secret_expires_at = Time.current + TokenAuthority.config.dcr_client_secret_expiration
257
257
  end
258
258
 
259
259
  def generate_client_secret_for(secret_id)
@@ -47,11 +47,11 @@ module TokenAuthority
47
47
 
48
48
  # URL-based clients use default token durations from config
49
49
  def access_token_duration
50
- TokenAuthority.config.rfc_9068_default_access_token_duration
50
+ TokenAuthority.config.default_access_token_duration
51
51
  end
52
52
 
53
53
  def refresh_token_duration
54
- TokenAuthority.config.rfc_9068_default_refresh_token_duration
54
+ TokenAuthority.config.default_refresh_token_duration
55
55
  end
56
56
 
57
57
  # URL-based clients cannot have secrets
@@ -72,7 +72,7 @@ module TokenAuthority
72
72
  def token_endpoint_auth_method_is_allowed
73
73
  return if token_endpoint_auth_method.blank?
74
74
 
75
- allowed = TokenAuthority.config.rfc_7591_allowed_token_endpoint_auth_methods
75
+ allowed = TokenAuthority.config.dcr_allowed_token_endpoint_auth_methods
76
76
  unless allowed.include?(token_endpoint_auth_method)
77
77
  errors.add(:token_endpoint_auth_method, "is not allowed: #{token_endpoint_auth_method}")
78
78
  end
@@ -82,7 +82,7 @@ module TokenAuthority
82
82
  return if grant_types.blank?
83
83
  return unless grant_types.is_a?(Array)
84
84
 
85
- allowed = TokenAuthority.config.rfc_7591_allowed_grant_types
85
+ allowed = TokenAuthority.config.dcr_allowed_grant_types
86
86
  disallowed = grant_types - allowed
87
87
  errors.add(:grant_types, "contains disallowed types: #{disallowed.join(", ")}") if disallowed.any?
88
88
  end
@@ -98,7 +98,7 @@ module TokenAuthority
98
98
  def scopes_are_allowed
99
99
  return if scope.blank?
100
100
 
101
- allowed = TokenAuthority.config.rfc_7591_allowed_scopes
101
+ allowed = TokenAuthority.config.dcr_allowed_scopes
102
102
  return if allowed.blank? # If no restrictions configured, allow all
103
103
 
104
104
  requested_scopes = scope.to_s.split(/\s+/).reject(&:blank?)
@@ -146,10 +146,10 @@ module TokenAuthority
146
146
  end
147
147
 
148
148
  def parse_software_statement
149
- jwks = TokenAuthority.config.rfc_7591_software_statement_jwks
149
+ jwks = TokenAuthority.config.dcr_software_statement_jwks
150
150
  if jwks.present?
151
151
  SoftwareStatement.decode_and_verify(software_statement, jwks: jwks)
152
- elsif TokenAuthority.config.rfc_7591_software_statement_required
152
+ elsif TokenAuthority.config.dcr_software_statement_required
153
153
  raise TokenAuthority::UnapprovedSoftwareStatementError
154
154
  else
155
155
  SoftwareStatement.decode(software_statement)
@@ -55,7 +55,7 @@ module TokenAuthority
55
55
  end
56
56
 
57
57
  def store_in_cache(uri, jwks_data)
58
- ttl = TokenAuthority.config.rfc_7591_jwks_cache_ttl
58
+ ttl = TokenAuthority.config.dcr_jwks_cache_ttl
59
59
  uri_hash = JwksCache.hash_uri(uri)
60
60
 
61
61
  JwksCache.find_or_initialize_by(uri_hash: uri_hash).tap do |cache|
@@ -2,73 +2,152 @@
2
2
 
3
3
  module TokenAuthority
4
4
  ##
5
- # Builds the RFC 9728 OAuth 2.0 Protected Resource Metadata response
5
+ # Assembles RFC 9728 Protected Resource Metadata responses for OAuth clients.
6
+ #
7
+ # This class transforms configured resource metadata into standardized JSON responses
8
+ # that clients use to discover how to interact with protected resources. The metadata
9
+ # includes supported scopes, bearer token methods, and links to authorization servers.
10
+ #
11
+ # The lookup strategy supports multi-tenant deployments: the resource_key parameter
12
+ # (typically extracted from the request subdomain) is converted to a symbol and used
13
+ # to look up the configuration in config.resources. If no match is found, the first
14
+ # resource in the hash is used as the fallback.
15
+ #
16
+ # @example Single resource deployment
17
+ # metadata = ProtectedResourceMetadata.new(resource: nil)
18
+ # metadata.to_h
19
+ # # => Returns first entry from config.resources
20
+ #
21
+ # @example Multi-tenant with subdomain-specific config
22
+ # metadata = ProtectedResourceMetadata.new(resource: "api")
23
+ # metadata.to_h
24
+ # # => Returns config.resources[:api]
25
+ #
26
+ # @see https://www.rfc-editor.org/rfc/rfc9728.html RFC 9728 OAuth 2.0 Protected Resource Metadata
27
+ # @since 0.3.0
6
28
  class ProtectedResourceMetadata
7
- def initialize(mount_path:)
8
- @mount_path = mount_path
29
+ # RFC 9728 defines these standard metadata fields for protected resources.
30
+ # This constant serves as documentation of the complete metadata schema and
31
+ # ensures field ordering matches the RFC for consistency.
32
+ ATTRIBUTES = %i[
33
+ resource
34
+ authorization_servers
35
+ scopes_supported
36
+ bearer_methods_supported
37
+ resource_name
38
+ resource_documentation
39
+ resource_policy_uri
40
+ resource_tos_uri
41
+ jwks_uri
42
+ ].freeze
43
+
44
+ # @return [String, nil] the lookup key used to find configuration (typically subdomain)
45
+ attr_reader :resource_key
46
+
47
+ # Initializes metadata builder for a specific resource.
48
+ #
49
+ # The resource parameter acts as a lookup key into the configuration system.
50
+ # In typical deployments, this is the request subdomain (e.g., "api", "mcp").
51
+ # When nil or blank, uses the default protected_resource configuration.
52
+ #
53
+ # @param resource [String, nil] the configuration key to look up
54
+ def initialize(resource:)
55
+ @resource_key = resource
9
56
  end
10
57
 
58
+ # Generates the RFC 9728 metadata response as a hash.
59
+ #
60
+ # Returns only the fields that are configured; optional fields with nil or blank
61
+ # values are omitted to reduce response size and comply with RFC requirements.
62
+ # The authorization_servers field defaults to the OAuth issuer URL if not explicitly
63
+ # configured, ensuring clients always know where to obtain tokens.
64
+ #
65
+ # @return [Hash{Symbol => Object}] metadata hash with snake_case keys
66
+ # @raise [ResourceNotConfiguredError] if no configuration exists for the resource_key
11
67
  def to_h
12
- metadata = {
68
+ {
13
69
  resource: resource,
14
- authorization_servers: authorization_servers
15
- }
16
-
17
- metadata[:scopes_supported] = scopes_supported if scopes_supported.any?
18
- metadata[:bearer_methods_supported] = bearer_methods_supported if bearer_methods_supported.present?
19
- metadata[:jwks_uri] = jwks_uri if jwks_uri.present?
20
- metadata[:resource_name] = resource_name if resource_name.present?
21
- metadata[:resource_documentation] = resource_documentation if resource_documentation.present?
22
- metadata[:resource_policy_uri] = resource_policy_uri if resource_policy_uri.present?
23
- metadata[:resource_tos_uri] = resource_tos_uri if resource_tos_uri.present?
24
-
25
- metadata
70
+ authorization_servers: authorization_servers,
71
+ scopes_supported: scopes_supported,
72
+ bearer_methods_supported: bearer_methods_supported,
73
+ jwks_uri: jwks_uri,
74
+ resource_name: resource_name,
75
+ resource_documentation: resource_documentation,
76
+ resource_policy_uri: resource_policy_uri,
77
+ resource_tos_uri: resource_tos_uri
78
+ }.compact_blank
26
79
  end
27
80
 
28
81
  private
29
82
 
30
- def config
31
- TokenAuthority.config
32
- end
33
-
34
- def issuer
35
- config.rfc_9068_issuer_url.to_s.chomp("/")
83
+ # Retrieves the configuration hash for this resource.
84
+ #
85
+ # Delegates to Configuration#protected_resource_for which implements the
86
+ # fallback strategy (subdomain-specific -> default -> nil). Memoizes the
87
+ # result since configuration doesn't change during a request.
88
+ #
89
+ # @return [Hash] the resource configuration hash
90
+ # @raise [ResourceNotConfiguredError] if no config exists for the resource key
91
+ def resource_config
92
+ @resource_config ||= TokenAuthority.config.protected_resource_for(resource_key) ||
93
+ raise(ResourceNotConfiguredError)
36
94
  end
37
95
 
96
+ # The URI identifying this protected resource.
97
+ # This is the only required field in RFC 9728 metadata.
98
+ #
99
+ # @return [String] resource identifier URI
38
100
  def resource
39
- config.rfc_9728_resource.presence || issuer
101
+ resource_config[:resource]
40
102
  end
41
103
 
104
+ # Array of authorization server URLs that can issue tokens for this resource.
105
+ #
106
+ # Defaults to the configured issuer URL if not explicitly set, which is correct
107
+ # for the common case where the authorization server and protected resource
108
+ # share the same deployment. Multi-AS scenarios can override this.
109
+ #
110
+ # @return [Array<String>] authorization server URLs
42
111
  def authorization_servers
43
- config.rfc_9728_authorization_servers.presence || [issuer]
112
+ resource_config[:authorization_servers].presence || [issuer]
44
113
  end
45
114
 
46
115
  def scopes_supported
47
- config.rfc_9728_scopes_supported.presence || config.scopes&.keys || []
116
+ resource_config[:scopes_supported]
48
117
  end
49
118
 
50
119
  def bearer_methods_supported
51
- config.rfc_9728_bearer_methods_supported
120
+ resource_config[:bearer_methods_supported]
52
121
  end
53
122
 
54
123
  def jwks_uri
55
- config.rfc_9728_jwks_uri
124
+ resource_config[:jwks_uri]
56
125
  end
57
126
 
58
127
  def resource_name
59
- config.rfc_9728_resource_name
128
+ resource_config[:resource_name]
60
129
  end
61
130
 
62
131
  def resource_documentation
63
- config.rfc_9728_resource_documentation
132
+ resource_config[:resource_documentation]
64
133
  end
65
134
 
66
135
  def resource_policy_uri
67
- config.rfc_9728_resource_policy_uri
136
+ resource_config[:resource_policy_uri]
68
137
  end
69
138
 
70
139
  def resource_tos_uri
71
- config.rfc_9728_resource_tos_uri
140
+ resource_config[:resource_tos_uri]
141
+ end
142
+
143
+ # The authorization server issuer URL, used as a default for authorization_servers.
144
+ #
145
+ # Strips trailing slash for consistency with OAuth discovery URL formats, which
146
+ # typically omit trailing slashes in issuer identifiers.
147
+ #
148
+ # @return [String] issuer URL without trailing slash
149
+ def issuer
150
+ TokenAuthority.config.issuer_url.to_s.chomp("/")
72
151
  end
73
152
  end
74
153
  end
@@ -60,7 +60,7 @@ module TokenAuthority
60
60
  aud = if resources.any?
61
61
  (resources.size == 1) ? resources.first : resources
62
62
  else
63
- TokenAuthority.config.rfc_9068_audience_url
63
+ TokenAuthority.config.audience_url
64
64
  end
65
65
 
66
66
  # Only include scope if scopes are provided
@@ -70,7 +70,7 @@ module TokenAuthority
70
70
  aud:,
71
71
  exp:,
72
72
  iat: Time.zone.now.to_i,
73
- iss: TokenAuthority.config.rfc_9068_issuer_url,
73
+ iss: TokenAuthority.config.issuer_url,
74
74
  jti: SecureRandom.uuid,
75
75
  scope: scope_claim
76
76
  )