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 +4 -4
- data/CHANGELOG.md +18 -5
- data/app/controllers/concerns/token_authority/client_authentication.rb +36 -4
- data/app/controllers/concerns/token_authority/token_authentication.rb +86 -0
- data/app/controllers/token_authority/authorization_grants_controller.rb +3 -1
- data/app/helpers/token_authority/authorization_grants_helper.rb +15 -1
- data/app/models/concerns/token_authority/resourceable.rb +11 -2
- data/app/views/token_authority/redirect.html.erb +6 -0
- data/config/locales/token_authority.en.yml +3 -0
- data/lib/token_authority/configuration.rb +18 -1
- data/lib/token_authority/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bba8d56df2875f6c50fc9937d6777f03ef3e0591e7adf5e39072820577946f1b
|
|
4
|
+
data.tar.gz: 934c70d43ada6108f9d46a2d11f1473a5925fa2bc9d8da9aa41313fc01a3ea3c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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] -
|
|
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] -
|
|
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] -
|
|
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.
|
|
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
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
|
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
|
-
|
|
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.
|
|
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?
|
|
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
|
-
|
|
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
|
|
@@ -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
|
#
|
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.
|
|
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
|