token_authority 0.3.1 → 0.3.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30eb5cd2101ab6cc044d31d5dc15396ffef4c9efd955960c6dec70e57e613583
4
- data.tar.gz: 6a24f7df903407dff09561e1ece8495c803dc6fa7175f0d3ff3641d8120ddc8f
3
+ metadata.gz: bba8d56df2875f6c50fc9937d6777f03ef3e0591e7adf5e39072820577946f1b
4
+ data.tar.gz: 934c70d43ada6108f9d46a2d11f1473a5925fa2bc9d8da9aa41313fc01a3ea3c
5
5
  SHA512:
6
- metadata.gz: 62f5c24dd950f5956817ca5531cd695b0ab3b1a2785e67c828c3ca116a2254ff25b376abf5db01cf9931da8bfce13c9869dbff332c8a8955f30009ef5e296a41
7
- data.tar.gz: be5cd3a31b883f6bf7bc0b80a1d2d1d87bcfaa1dc4c8469d3c36b874aeac2a471cf0286a0c8c35e11b5a4fec0bb8b749a98acf9fc3709a4c26253bd0728637ec
6
+ metadata.gz: 3acf7f1a3e38bb6145fc94b760fde4ef2ee74ba5a1e7049ee677f7875ed8799af6a840950cfbe7efeda1adda2f7b129984161ead5404e559626b9cd2fa44c019
7
+ data.tar.gz: e058adbe90a2367b1b28d89cf4df9d82cd79486f52594e96011da4d538d13cfdfec4ee496a9bbd1562a59fd2e11cdfda6558cd888ad479f0cdf922322390800a
data/CHANGELOG.md CHANGED
@@ -1,12 +1,24 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.3.1] - 2025-01-25
3
+ ## [0.3.2] - 2026-01-26
4
+
5
+ ### Added
6
+
7
+ - WWW-Authenticate header on 401 responses from protected endpoints per RFC 9728, enabling automatic OAuth discovery for MCP clients
8
+ - `client_secret_post` authentication for confidential clients on token and revocation endpoints
9
+ - HTML redirect page for OAuth authorization completion, improving UX for desktop apps with custom URI schemes
10
+
11
+ ### Fixed
12
+
13
+ - Resource URI comparison now normalizes trailing slashes, preventing `invalid_target` errors
14
+
15
+ ## [0.3.1] - 2026-01-25
4
16
 
5
17
  ### Fixed
6
18
 
7
19
  - Do not expire OAuth sessions when access tokens expire; this was preventing session refresh flow from completing successfully in some cases.
8
20
 
9
- ## [0.3.0] - 2025-01-24
21
+ ## [0.3.0] - 2026-01-24
10
22
 
11
23
  ### Added
12
24
 
@@ -46,7 +58,7 @@
46
58
  - `rfc_7591_software_statement_required` → `dcr_software_statement_required`
47
59
  - `rfc_7591_jwks_cache_ttl` → `dcr_jwks_cache_ttl`
48
60
 
49
- ## [0.2.1] - 2025-01-24
61
+ ## [0.2.1] - 2026-01-24
50
62
 
51
63
  ### Fixes
52
64
 
@@ -57,7 +69,7 @@
57
69
 
58
70
  - Update README to include link to MCP Quickstart guide.
59
71
 
60
- ## [0.2.0] - 2025-01-23
72
+ ## [0.2.0] - 2026-01-23
61
73
 
62
74
  - Implemented support for OAuth 2.1 authorization flows and JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens (RFC 9068).
63
75
  - Implemented support for OAuth 2.0 Authorization Server Metadata (RFC 8414).
@@ -75,7 +87,8 @@
75
87
 
76
88
  - Initial release
77
89
 
78
- [Unreleased]: https://github.com/dickdavis/token_authority/compare/v0.3.1...HEAD
90
+ [Unreleased]: https://github.com/dickdavis/token_authority/compare/v0.3.2...HEAD
91
+ [0.3.2]: https://github.com/dickdavis/token_authority/compare/v0.3.1...v0.3.2
79
92
  [0.3.1]: https://github.com/dickdavis/token_authority/compare/v0.3.0...v0.3.1
80
93
  [0.3.0]: https://github.com/dickdavis/token_authority/compare/v0.2.1...v0.3.0
81
94
  [0.2.1]: https://github.com/dickdavis/token_authority/compare/v0.2.0...v0.2.1
@@ -4,13 +4,14 @@ module TokenAuthority
4
4
  # Provides OAuth client authentication for controllers.
5
5
  #
6
6
  # This concern handles authentication of OAuth clients during authorization
7
- # and token requests. It supports both confidential clients (using HTTP Basic
8
- # authentication with client_id and client_secret) and public clients (using
9
- # only client_id).
7
+ # and token requests. It supports multiple authentication methods:
8
+ # - Public clients (using only client_id, no secret required)
9
+ # - HTTP Basic authentication (client_secret_basic)
10
+ # - POST body credentials (client_secret_post)
10
11
  #
11
12
  # The concern automatically:
12
13
  # - Resolves client_id to either a registered Client or URL-based ClientMetadataDocument
13
- # - Validates HTTP Basic credentials for confidential clients
14
+ # - Validates credentials via HTTP Basic or POST body for confidential clients
14
15
  # - Emits authentication events for monitoring and security auditing
15
16
  # - Handles authentication errors with appropriate HTTP responses
16
17
  #
@@ -89,6 +90,14 @@ module TokenAuthority
89
90
  return
90
91
  end
91
92
 
93
+ if client_secret_post_auth_successful?
94
+ notify_event("authentication.client.succeeded",
95
+ client_id: @token_authority_client.public_id,
96
+ client_type: @token_authority_client.client_type,
97
+ auth_method: "client_secret_post")
98
+ return
99
+ end
100
+
92
101
  notify_event("authentication.client.failed",
93
102
  client_id: client_id,
94
103
  failure_reason: "missing_credentials",
@@ -111,6 +120,29 @@ module TokenAuthority
111
120
  @token_authority_client = nil
112
121
  end
113
122
 
123
+ # Attempts to authenticate using client_secret_post (credentials in POST body).
124
+ #
125
+ # Validates the client_secret from POST parameters against the loaded client.
126
+ # Accepts POST body credentials for confidential clients configured with either
127
+ # client_secret_post or client_secret_basic auth methods.
128
+ #
129
+ # @return [Boolean] true if authentication succeeds
130
+ # @api private
131
+ def client_secret_post_auth_successful?
132
+ return false if params[:client_secret].blank?
133
+ return false if @token_authority_client.blank?
134
+ return false if @token_authority_client.public_client_type?
135
+
136
+ authenticated = @token_authority_client.authenticate_with_secret(params[:client_secret])
137
+ unless authenticated
138
+ notify_event("authentication.client.failed",
139
+ client_id: @token_authority_client.public_id,
140
+ failure_reason: "invalid_secret",
141
+ auth_method_attempted: "client_secret_post")
142
+ end
143
+ authenticated
144
+ end
145
+
114
146
  # Attempts to authenticate using HTTP Basic credentials.
115
147
  #
116
148
  # Verifies that the client_id in Basic auth matches params[:client_id] if present,
@@ -102,6 +102,7 @@ module TokenAuthority
102
102
  notify_event("authentication.token.failed",
103
103
  failure_reason: "missing_authorization_header")
104
104
 
105
+ set_www_authenticate_header
105
106
  render json: {error: I18n.t("token_authority.errors.missing_auth_header")}, status: :unauthorized
106
107
  end
107
108
 
@@ -112,6 +113,7 @@ module TokenAuthority
112
113
  notify_event("authentication.token.failed",
113
114
  failure_reason: "invalid_token_format")
114
115
 
116
+ set_www_authenticate_header(error: "invalid_token")
115
117
  render json: {error: I18n.t("token_authority.errors.invalid_token")}, status: :unauthorized
116
118
  end
117
119
 
@@ -122,7 +124,91 @@ module TokenAuthority
122
124
  notify_event("authentication.token.failed",
123
125
  failure_reason: "unauthorized_token")
124
126
 
127
+ set_www_authenticate_header(error: "invalid_token")
125
128
  render json: {error: I18n.t("token_authority.errors.unauthorized_token")}, status: :unauthorized
126
129
  end
130
+
131
+ # Sets the WWW-Authenticate header for 401 responses per RFC 9728 and MCP spec.
132
+ #
133
+ # This header tells OAuth clients where to find the protected resource
134
+ # metadata, enabling automatic OAuth flow discovery (including DCR).
135
+ #
136
+ # Per MCP Authorization spec, the header SHOULD include a scope parameter
137
+ # to indicate scopes required for accessing the resource (RFC 6750 Section 3).
138
+ #
139
+ # @param error [String, nil] optional OAuth error code (e.g., "invalid_token")
140
+ # @return [void]
141
+ # @api private
142
+ def set_www_authenticate_header(error: nil)
143
+ resource_config = current_protected_resource_config
144
+ metadata_url = protected_resource_metadata_url(resource_config)
145
+ return if metadata_url.blank?
146
+
147
+ header_value = %(Bearer resource_metadata="#{metadata_url}")
148
+ header_value += %(, scope="#{www_authenticate_scope(resource_config)}") if www_authenticate_scope(resource_config).present?
149
+ header_value += %(, error="#{error}") if error.present?
150
+
151
+ response.headers["WWW-Authenticate"] = header_value
152
+ end
153
+
154
+ # Returns the current protected resource configuration.
155
+ #
156
+ # Looks up the resource by request subdomain. If no subdomain match is found,
157
+ # the first configured resource is used.
158
+ #
159
+ # @return [Hash, nil] the resource configuration
160
+ # @api private
161
+ def current_protected_resource_config
162
+ subdomain = request.subdomain.presence
163
+ TokenAuthority.config.protected_resource_for(subdomain)
164
+ end
165
+
166
+ # Returns the URL for the protected resource metadata endpoint.
167
+ #
168
+ # Derives the URL from the resource configuration's :resource field,
169
+ # which represents the protected resource URI. The well-known path is
170
+ # appended to form the complete metadata URL.
171
+ #
172
+ # If no resources are configured, falls back to deriving from the current
173
+ # request host.
174
+ #
175
+ # Controllers can override this method to customize the metadata URL.
176
+ #
177
+ # @param resource_config [Hash, nil] the resource configuration
178
+ # @return [String] the metadata URL
179
+ # @api private
180
+ def protected_resource_metadata_url(resource_config = nil)
181
+ resource_config ||= current_protected_resource_config
182
+
183
+ if resource_config.is_a?(Hash) && resource_config[:resource].present?
184
+ resource_uri = URI(resource_config[:resource])
185
+ return "#{resource_uri.scheme}://#{resource_uri.host}/.well-known/oauth-protected-resource"
186
+ end
187
+
188
+ # Fallback: derive from request origin
189
+ "#{request.protocol}#{request.host_with_port}/.well-known/oauth-protected-resource"
190
+ end
191
+
192
+ # Returns the scope value for the WWW-Authenticate header.
193
+ #
194
+ # Uses the resource's scopes_supported configuration to indicate what
195
+ # scopes are required for accessing this resource. Per MCP spec, this
196
+ # provides clients with guidance on appropriate scopes to request.
197
+ #
198
+ # Controllers can override this method to specify different scopes
199
+ # (e.g., endpoint-specific required scopes).
200
+ #
201
+ # @param resource_config [Hash, nil] the resource configuration
202
+ # @return [String, nil] space-separated scope string, or nil if no scopes configured
203
+ # @api private
204
+ def www_authenticate_scope(resource_config = nil)
205
+ resource_config ||= current_protected_resource_config
206
+ return nil unless resource_config.is_a?(Hash)
207
+
208
+ scopes = resource_config[:scopes_supported]
209
+ return nil unless scopes.is_a?(Array) && scopes.any?
210
+
211
+ scopes.join(" ")
212
+ end
127
213
  end
128
214
  end
@@ -102,7 +102,9 @@ module TokenAuthority
102
102
  def redirect_to_client(params_for_redirect:)
103
103
  clear_internal_state
104
104
  url = @token_authority_client.url_for_redirect(params: params_for_redirect.compact)
105
- redirect_to url, allow_other_host: true
105
+ render "token_authority/redirect",
106
+ layout: TokenAuthority.config.consent_page_layout,
107
+ locals: {redirect_url: url}
106
108
  end
107
109
 
108
110
  def clear_internal_state
@@ -6,10 +6,14 @@ module TokenAuthority
6
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
+ # The URI is normalized (trailing slash removed) before lookup to match
10
+ # how the resource registry stores URIs.
11
+ #
9
12
  # @param resource_uri [String] The resource URI
10
13
  # @return [String] The display name or the URI if no mapping exists
11
14
  def resource_display_name(resource_uri)
12
- TokenAuthority.config.resource_registry[resource_uri] || resource_uri
15
+ normalized_uri = TokenAuthority.config.normalize_resource_uri(resource_uri)
16
+ TokenAuthority.config.resource_registry[normalized_uri] || resource_uri
13
17
  end
14
18
 
15
19
  # Returns a human-friendly display name for a scope.
@@ -22,5 +26,15 @@ module TokenAuthority
22
26
  scopes = TokenAuthority.config.scopes || {}
23
27
  scopes[scope] || scope
24
28
  end
29
+
30
+ # Generates a script tag that redirects the browser to the given URL.
31
+ # Used for OAuth redirects where the browser may not navigate away
32
+ # (e.g., custom URI schemes like claude://).
33
+ #
34
+ # @param url [String] The URL to redirect to
35
+ # @return [String] A script tag with the redirect JavaScript
36
+ def redirect_script_tag(url)
37
+ javascript_tag "window.location.href = #{url.to_json};"
38
+ end
25
39
  end
26
40
  end
@@ -84,19 +84,25 @@ module TokenAuthority
84
84
  # Checks if all requested resources are in the allowed resources list.
85
85
  #
86
86
  # Returns true if resource indicators are not enabled in configuration.
87
+ # URIs are normalized (trailing slashes removed) before comparison for
88
+ # interoperability.
87
89
  #
88
90
  # @return [Boolean] true if all resources are allowed
89
91
  # @api private
90
92
  def allowed_resources?
91
93
  return true unless TokenAuthority.config.resources_enabled?
92
94
 
93
- resources.all? { |uri| TokenAuthority.config.resource_registry.key?(uri) }
95
+ resources.all? do |uri|
96
+ normalized_uri = TokenAuthority.config.normalize_resource_uri(uri)
97
+ TokenAuthority.config.resource_registry.key?(normalized_uri)
98
+ end
94
99
  end
95
100
 
96
101
  # Checks if the current resources are a subset of the granted resources.
97
102
  #
98
103
  # Used during token refresh to ensure the new token doesn't request
99
104
  # access to more resources than the original grant.
105
+ # URIs are normalized (trailing slashes removed) before comparison.
100
106
  #
101
107
  # @param granted [Array<String>, nil] the originally granted resources
102
108
  #
@@ -105,7 +111,10 @@ module TokenAuthority
105
111
  def resources_subset_of?(granted)
106
112
  return true if granted.blank? || resources.blank?
107
113
 
108
- (resources - granted).empty?
114
+ normalized_resources = resources.map { |uri| TokenAuthority.config.normalize_resource_uri(uri) }
115
+ normalized_granted = granted.map { |uri| TokenAuthority.config.normalize_resource_uri(uri) }
116
+
117
+ (normalized_resources - normalized_granted).empty?
109
118
  end
110
119
  end
111
120
  end
@@ -0,0 +1,6 @@
1
+ <div>
2
+ <h2><%= t('token_authority.redirect.title') %></h2>
3
+ <p><%= t('token_authority.redirect.message') %></p>
4
+ </div>
5
+
6
+ <%= redirect_script_tag(redirect_url) %>
@@ -238,6 +238,9 @@ en:
238
238
  client_error:
239
239
  title: 'Client Error'
240
240
  lede: 'The request provided by the client to this server is malformed. Please report this error through the client support channel.'
241
+ redirect:
242
+ title: 'Authorization Complete'
243
+ message: 'You may close this window.'
241
244
  errors:
242
245
  invalid_grant: 'The authorization grant is invalid'
243
246
  mismatched_refresh_token: 'The provided refresh token JTI does not match the refresh token JTI of the target OAuthSession.'
@@ -408,11 +408,28 @@ module TokenAuthority
408
408
  resources.each_with_object({}) do |(_key, config), registry|
409
409
  next unless config.is_a?(Hash) && config[:resource].present?
410
410
 
411
- uri = config[:resource]
411
+ uri = normalize_resource_uri(config[:resource])
412
412
  registry[uri] = config[:resource_name] || uri
413
413
  end
414
414
  end
415
415
 
416
+ # Normalizes a resource URI for consistent comparison.
417
+ #
418
+ # This method removes trailing slashes from resource URIs to ensure consistent
419
+ # matching between configured resources and client-provided resource parameters.
420
+ #
421
+ # @param uri [String] the resource URI to normalize
422
+ # @return [String] the normalized URI without trailing slash
423
+ #
424
+ # @example
425
+ # normalize_resource_uri("https://mcp.example.com/")
426
+ # # => "https://mcp.example.com"
427
+ def normalize_resource_uri(uri)
428
+ return uri if uri.blank?
429
+
430
+ uri.to_s.chomp("/")
431
+ end
432
+
416
433
  # Validates the configuration for internal consistency.
417
434
  # Ensures that required features are properly configured before use.
418
435
  #
@@ -2,5 +2,5 @@ module TokenAuthority
2
2
  # The current version of the TokenAuthority gem.
3
3
  #
4
4
  # @return [String] the version string in semantic versioning format
5
- VERSION = "0.3.1"
5
+ VERSION = "0.3.2"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: token_authority
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dick Davis
@@ -86,6 +86,7 @@ files:
86
86
  - app/models/token_authority/software_statement.rb
87
87
  - app/views/token_authority/authorization_grants/new.html.erb
88
88
  - app/views/token_authority/client_error.html.erb
89
+ - app/views/token_authority/redirect.html.erb
89
90
  - config/locales/token_authority.en.yml
90
91
  - config/routes.rb
91
92
  - lib/generators/token_authority/install/install_generator.rb