token_authority 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +199 -7
- data/app/controllers/concerns/token_authority/client_authentication.rb +141 -0
- data/app/controllers/concerns/token_authority/controller_event_logging.rb +98 -0
- data/app/controllers/concerns/token_authority/initial_access_token_authentication.rb +35 -0
- data/app/controllers/concerns/token_authority/token_authentication.rb +128 -0
- data/app/controllers/token_authority/authorization_grants_controller.rb +119 -0
- data/app/controllers/token_authority/authorizations_controller.rb +105 -0
- data/app/controllers/token_authority/clients_controller.rb +99 -0
- data/app/controllers/token_authority/metadata_controller.rb +12 -0
- data/app/controllers/token_authority/resource_metadata_controller.rb +12 -0
- data/app/controllers/token_authority/sessions_controller.rb +228 -0
- data/app/helpers/token_authority/authorization_grants_helper.rb +27 -0
- data/app/models/concerns/token_authority/claim_validatable.rb +95 -0
- data/app/models/concerns/token_authority/event_logging.rb +144 -0
- data/app/models/concerns/token_authority/resourceable.rb +111 -0
- data/app/models/concerns/token_authority/scopeable.rb +105 -0
- data/app/models/concerns/token_authority/session_creatable.rb +101 -0
- data/app/models/token_authority/access_token.rb +127 -0
- data/app/models/token_authority/access_token_request.rb +193 -0
- data/app/models/token_authority/authorization_grant.rb +119 -0
- data/app/models/token_authority/authorization_request.rb +276 -0
- data/app/models/token_authority/authorization_server_metadata.rb +101 -0
- data/app/models/token_authority/client.rb +263 -0
- data/app/models/token_authority/client_id_resolver.rb +114 -0
- data/app/models/token_authority/client_metadata_document.rb +164 -0
- data/app/models/token_authority/client_metadata_document_cache.rb +33 -0
- data/app/models/token_authority/client_metadata_document_fetcher.rb +266 -0
- data/app/models/token_authority/client_registration_request.rb +214 -0
- data/app/models/token_authority/client_registration_response.rb +58 -0
- data/app/models/token_authority/jwks_cache.rb +37 -0
- data/app/models/token_authority/jwks_fetcher.rb +70 -0
- data/app/models/token_authority/protected_resource_metadata.rb +74 -0
- data/app/models/token_authority/refresh_token.rb +110 -0
- data/app/models/token_authority/refresh_token_request.rb +116 -0
- data/app/models/token_authority/session.rb +193 -0
- data/app/models/token_authority/software_statement.rb +70 -0
- data/app/views/token_authority/authorization_grants/new.html.erb +25 -0
- data/app/views/token_authority/client_error.html.erb +8 -0
- data/config/locales/token_authority.en.yml +248 -0
- data/config/routes.rb +29 -0
- data/lib/generators/token_authority/install/install_generator.rb +61 -0
- data/lib/generators/token_authority/install/templates/create_token_authority_tables.rb.erb +116 -0
- data/lib/generators/token_authority/install/templates/token_authority.rb +247 -0
- data/lib/token_authority/configuration.rb +397 -0
- data/lib/token_authority/engine.rb +34 -0
- data/lib/token_authority/errors.rb +221 -0
- data/lib/token_authority/instrumentation.rb +80 -0
- data/lib/token_authority/instrumentation_log_subscriber.rb +62 -0
- data/lib/token_authority/json_web_token.rb +78 -0
- data/lib/token_authority/log_event_subscriber.rb +43 -0
- data/lib/token_authority/routing/constraints.rb +71 -0
- data/lib/token_authority/routing/routes.rb +39 -0
- data/lib/token_authority/version.rb +4 -1
- data/lib/token_authority.rb +30 -1
- metadata +65 -5
- data/app/assets/stylesheets/token_authority/application.css +0 -15
- data/app/controllers/token_authority/application_controller.rb +0 -4
- data/app/helpers/token_authority/application_helper.rb +0 -4
- data/app/views/layouts/token_authority/application.html.erb +0 -17
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "resolv"
|
|
5
|
+
require "ipaddr"
|
|
6
|
+
|
|
7
|
+
module TokenAuthority
|
|
8
|
+
##
|
|
9
|
+
# Service for fetching and caching client metadata documents from remote URIs
|
|
10
|
+
class ClientMetadataDocumentFetcher
|
|
11
|
+
extend TokenAuthority::Instrumentation
|
|
12
|
+
include TokenAuthority::EventLogging
|
|
13
|
+
|
|
14
|
+
# Private IP ranges for SSRF protection
|
|
15
|
+
PRIVATE_IP_RANGES = [
|
|
16
|
+
IPAddr.new("10.0.0.0/8"),
|
|
17
|
+
IPAddr.new("172.16.0.0/12"),
|
|
18
|
+
IPAddr.new("192.168.0.0/16"),
|
|
19
|
+
IPAddr.new("127.0.0.0/8"),
|
|
20
|
+
IPAddr.new("169.254.0.0/16"), # Link-local
|
|
21
|
+
IPAddr.new("0.0.0.0/8"),
|
|
22
|
+
IPAddr.new("::1/128"), # IPv6 localhost
|
|
23
|
+
IPAddr.new("fc00::/7"), # IPv6 unique local
|
|
24
|
+
IPAddr.new("fe80::/10") # IPv6 link-local
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def fetch(uri)
|
|
29
|
+
instrument("client_metadata.fetch", uri: uri) do |payload|
|
|
30
|
+
validate_url!(uri)
|
|
31
|
+
|
|
32
|
+
cached = ClientMetadataDocumentCache.find_by_uri(uri)
|
|
33
|
+
|
|
34
|
+
if cached && !cached.expired?
|
|
35
|
+
debug_event("client.metadata.cache_hit", uri: uri, expires_at: cached.expires_at&.iso8601)
|
|
36
|
+
payload[:cache_hit] = true
|
|
37
|
+
return cached.metadata
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
debug_event("client.metadata.cache_miss", uri: uri)
|
|
41
|
+
payload[:cache_hit] = false
|
|
42
|
+
|
|
43
|
+
# Fetch fresh data and update/create cache entry
|
|
44
|
+
metadata = fetch_from_uri(uri)
|
|
45
|
+
validate_metadata!(uri, metadata)
|
|
46
|
+
store_in_cache(uri, metadata)
|
|
47
|
+
|
|
48
|
+
debug_event("client.metadata.fetched", uri: uri, client_name: metadata["client_name"])
|
|
49
|
+
|
|
50
|
+
metadata
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def clear_cache(uri)
|
|
55
|
+
ClientMetadataDocumentCache.find_by_uri(uri)&.destroy
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def cleanup_expired!
|
|
59
|
+
ClientMetadataDocumentCache.cleanup_expired!
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def valid_client_id_url?(url)
|
|
63
|
+
validate_url!(url)
|
|
64
|
+
true
|
|
65
|
+
rescue InvalidClientMetadataDocumentUrlError
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def validate_url!(url)
|
|
72
|
+
parsed_uri = URI.parse(url)
|
|
73
|
+
|
|
74
|
+
# Must be HTTPS
|
|
75
|
+
unless parsed_uri.is_a?(URI::HTTPS)
|
|
76
|
+
raise InvalidClientMetadataDocumentUrlError, "Client ID URL must use HTTPS scheme"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Must have a path (not just "/")
|
|
80
|
+
if parsed_uri.path.blank? || parsed_uri.path == "/"
|
|
81
|
+
raise InvalidClientMetadataDocumentUrlError, "Client ID URL must have a path"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Must not have a fragment
|
|
85
|
+
if parsed_uri.fragment.present?
|
|
86
|
+
raise InvalidClientMetadataDocumentUrlError, "Client ID URL must not have a fragment"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Must not have user credentials
|
|
90
|
+
if parsed_uri.user.present? || parsed_uri.password.present?
|
|
91
|
+
raise InvalidClientMetadataDocumentUrlError, "Client ID URL must not have credentials"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Check host against allow/block lists
|
|
95
|
+
validate_host!(parsed_uri.host)
|
|
96
|
+
|
|
97
|
+
true
|
|
98
|
+
rescue URI::InvalidURIError => e
|
|
99
|
+
raise InvalidClientMetadataDocumentUrlError, "Invalid URI: #{e.message}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_host!(host)
|
|
103
|
+
config = TokenAuthority.config
|
|
104
|
+
|
|
105
|
+
# Check blocked hosts
|
|
106
|
+
if config.client_metadata_document_blocked_hosts&.any? { |pattern| host_matches?(host, pattern) }
|
|
107
|
+
raise InvalidClientMetadataDocumentUrlError, "Host is blocked: #{host}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Check allowed hosts (if configured)
|
|
111
|
+
if config.client_metadata_document_allowed_hosts.present?
|
|
112
|
+
unless config.client_metadata_document_allowed_hosts.any? { |pattern| host_matches?(host, pattern) }
|
|
113
|
+
raise InvalidClientMetadataDocumentUrlError, "Host is not in allowed list: #{host}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def host_matches?(host, pattern)
|
|
121
|
+
if pattern.start_with?("*.")
|
|
122
|
+
# Wildcard pattern: *.example.com matches foo.example.com
|
|
123
|
+
suffix = pattern[1..]
|
|
124
|
+
host.end_with?(suffix) || host == pattern[2..]
|
|
125
|
+
else
|
|
126
|
+
host == pattern
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def fetch_from_uri(uri)
|
|
131
|
+
parsed_uri = URI.parse(uri)
|
|
132
|
+
config = TokenAuthority.config
|
|
133
|
+
|
|
134
|
+
# Resolve DNS and validate IP before connecting (SSRF protection)
|
|
135
|
+
resolved_ip = resolve_and_validate_ip(parsed_uri.host)
|
|
136
|
+
|
|
137
|
+
http = Net::HTTP.new(parsed_uri.host, parsed_uri.port)
|
|
138
|
+
http.use_ssl = true
|
|
139
|
+
http.open_timeout = config.client_metadata_document_connect_timeout
|
|
140
|
+
http.read_timeout = config.client_metadata_document_read_timeout
|
|
141
|
+
http.ipaddr = resolved_ip # Use resolved IP to prevent DNS rebinding
|
|
142
|
+
|
|
143
|
+
request = Net::HTTP::Get.new(parsed_uri.request_uri)
|
|
144
|
+
request["Accept"] = "application/json"
|
|
145
|
+
|
|
146
|
+
response = http.request(request)
|
|
147
|
+
|
|
148
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
149
|
+
raise ClientMetadataDocumentFetchError, "HTTP #{response.code}: #{response.message}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Check response size
|
|
153
|
+
max_size = config.client_metadata_document_max_response_size
|
|
154
|
+
if response.body.bytesize > max_size
|
|
155
|
+
raise ClientMetadataDocumentFetchError, "Response exceeds maximum size of #{max_size} bytes"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
JSON.parse(response.body)
|
|
159
|
+
rescue URI::InvalidURIError => e
|
|
160
|
+
raise ClientMetadataDocumentFetchError, "Invalid URI: #{e.message}"
|
|
161
|
+
rescue JSON::ParserError => e
|
|
162
|
+
raise ClientMetadataDocumentFetchError, "Invalid JSON: #{e.message}"
|
|
163
|
+
rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::ENETUNREACH,
|
|
164
|
+
Net::OpenTimeout, Net::ReadTimeout, SocketError => e
|
|
165
|
+
raise ClientMetadataDocumentFetchError, "Connection error: #{e.message}"
|
|
166
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
167
|
+
raise ClientMetadataDocumentFetchError, "SSL error: #{e.message}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def resolve_and_validate_ip(host)
|
|
171
|
+
addresses = Resolv.getaddresses(host)
|
|
172
|
+
|
|
173
|
+
if addresses.empty?
|
|
174
|
+
raise ClientMetadataDocumentFetchError, "Could not resolve host: #{host}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Validate all resolved IPs are not private
|
|
178
|
+
addresses.each do |addr|
|
|
179
|
+
ip = IPAddr.new(addr)
|
|
180
|
+
if private_ip?(ip)
|
|
181
|
+
raise ClientMetadataDocumentFetchError, "Host resolves to private IP address"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Prefer IPv4 addresses over IPv6 for better compatibility
|
|
186
|
+
ipv4_addresses = addresses.select { |addr| IPAddr.new(addr).ipv4? }
|
|
187
|
+
ipv4_addresses.first || addresses.first
|
|
188
|
+
rescue IPAddr::InvalidAddressError => e
|
|
189
|
+
raise ClientMetadataDocumentFetchError, "Invalid IP address: #{e.message}"
|
|
190
|
+
rescue Resolv::ResolvError => e
|
|
191
|
+
raise ClientMetadataDocumentFetchError, "DNS resolution failed: #{e.message}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def private_ip?(ip)
|
|
195
|
+
PRIVATE_IP_RANGES.any? { |range| range.include?(ip) }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def validate_metadata!(uri, metadata)
|
|
199
|
+
# client_id in metadata must match the URL
|
|
200
|
+
if metadata["client_id"] != uri
|
|
201
|
+
raise InvalidClientMetadataDocumentError,
|
|
202
|
+
"client_id in metadata (#{metadata["client_id"]}) does not match URL (#{uri})"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Must not contain client_secret (public clients only)
|
|
206
|
+
if metadata["client_secret"].present?
|
|
207
|
+
raise InvalidClientMetadataDocumentError, "Client metadata document must not contain client_secret"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Must have at least one redirect_uri
|
|
211
|
+
redirect_uris = metadata["redirect_uris"]
|
|
212
|
+
if redirect_uris.blank? || !redirect_uris.is_a?(Array) || redirect_uris.empty?
|
|
213
|
+
raise InvalidClientMetadataDocumentError, "Client metadata document must have redirect_uris"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Validate redirect_uris are valid URLs
|
|
217
|
+
redirect_uris.each do |redirect_uri|
|
|
218
|
+
parsed = URI.parse(redirect_uri)
|
|
219
|
+
unless parsed.is_a?(URI::HTTP) || parsed.is_a?(URI::HTTPS)
|
|
220
|
+
raise InvalidClientMetadataDocumentError,
|
|
221
|
+
"Invalid redirect_uri scheme: #{redirect_uri}"
|
|
222
|
+
end
|
|
223
|
+
rescue URI::InvalidURIError
|
|
224
|
+
raise InvalidClientMetadataDocumentError, "Invalid redirect_uri: #{redirect_uri}"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
true
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def store_in_cache(uri, metadata)
|
|
231
|
+
ttl = TokenAuthority.config.client_metadata_document_cache_ttl
|
|
232
|
+
uri_hash = ClientMetadataDocumentCache.hash_uri(uri)
|
|
233
|
+
|
|
234
|
+
ClientMetadataDocumentCache.find_or_initialize_by(uri_hash: uri_hash).tap do |cache|
|
|
235
|
+
cache.uri = uri
|
|
236
|
+
cache.metadata = metadata
|
|
237
|
+
cache.expires_at = Time.current + ttl
|
|
238
|
+
cache.save!
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def debug_event(event_name, **payload)
|
|
243
|
+
return unless event_logging_enabled?
|
|
244
|
+
return unless debug_events_enabled?
|
|
245
|
+
return unless rails_event_available?
|
|
246
|
+
|
|
247
|
+
full_payload = {timestamp: Time.current.iso8601(6)}.merge(payload)
|
|
248
|
+
Rails.event.debug("token_authority.#{event_name}", **full_payload)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def event_logging_enabled?
|
|
252
|
+
TokenAuthority.config.respond_to?(:event_logging_enabled) &&
|
|
253
|
+
TokenAuthority.config.event_logging_enabled
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def debug_events_enabled?
|
|
257
|
+
TokenAuthority.config.respond_to?(:event_logging_debug_events) &&
|
|
258
|
+
TokenAuthority.config.event_logging_debug_events
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def rails_event_available?
|
|
262
|
+
Rails.respond_to?(:event) && Rails.event.present?
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenAuthority
|
|
4
|
+
##
|
|
5
|
+
# Service object for validating and processing dynamic client registration requests (RFC 7591)
|
|
6
|
+
class ClientRegistrationRequest
|
|
7
|
+
include ActiveModel::Model
|
|
8
|
+
include ActiveModel::Validations
|
|
9
|
+
|
|
10
|
+
VALID_AUTH_METHODS = TokenAuthority::Client::SUPPORTED_AUTH_METHODS
|
|
11
|
+
|
|
12
|
+
attr_accessor :redirect_uris,
|
|
13
|
+
:token_endpoint_auth_method,
|
|
14
|
+
:grant_types,
|
|
15
|
+
:response_types,
|
|
16
|
+
:client_name,
|
|
17
|
+
:client_uri,
|
|
18
|
+
:logo_uri,
|
|
19
|
+
:tos_uri,
|
|
20
|
+
:policy_uri,
|
|
21
|
+
:contacts,
|
|
22
|
+
:scope,
|
|
23
|
+
:jwks_uri,
|
|
24
|
+
:jwks,
|
|
25
|
+
:software_id,
|
|
26
|
+
:software_version,
|
|
27
|
+
:software_statement
|
|
28
|
+
|
|
29
|
+
validates :redirect_uris, presence: true
|
|
30
|
+
validate :redirect_uris_are_valid
|
|
31
|
+
validate :token_endpoint_auth_method_is_allowed
|
|
32
|
+
validate :grant_types_are_allowed
|
|
33
|
+
validate :response_types_are_consistent
|
|
34
|
+
validate :scopes_are_allowed
|
|
35
|
+
validate :contacts_are_valid_emails
|
|
36
|
+
validate :jwks_and_jwks_uri_mutually_exclusive
|
|
37
|
+
validate :jwks_required_for_private_key_jwt
|
|
38
|
+
validate :software_statement_is_valid
|
|
39
|
+
|
|
40
|
+
def initialize(attributes = {})
|
|
41
|
+
super
|
|
42
|
+
@token_endpoint_auth_method ||= "client_secret_basic"
|
|
43
|
+
@grant_types ||= ["authorization_code"]
|
|
44
|
+
@response_types ||= ["code"]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def create_client!
|
|
48
|
+
raise TokenAuthority::InvalidClientMetadataError, errors.full_messages.join(", ") unless valid?
|
|
49
|
+
|
|
50
|
+
merged_attrs = merge_software_statement_claims
|
|
51
|
+
build_client(merged_attrs).tap(&:save!)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def redirect_uris_are_valid
|
|
57
|
+
return if redirect_uris.blank?
|
|
58
|
+
return unless redirect_uris.is_a?(Array)
|
|
59
|
+
|
|
60
|
+
redirect_uris.each do |uri|
|
|
61
|
+
parsed = URI.parse(uri)
|
|
62
|
+
unless parsed.is_a?(URI::HTTP) || parsed.is_a?(URI::HTTPS)
|
|
63
|
+
errors.add(:redirect_uris, "contains invalid URI scheme: #{uri}")
|
|
64
|
+
break
|
|
65
|
+
end
|
|
66
|
+
rescue URI::InvalidURIError
|
|
67
|
+
errors.add(:redirect_uris, "contains invalid URI: #{uri}")
|
|
68
|
+
break
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def token_endpoint_auth_method_is_allowed
|
|
73
|
+
return if token_endpoint_auth_method.blank?
|
|
74
|
+
|
|
75
|
+
allowed = TokenAuthority.config.rfc_7591_allowed_token_endpoint_auth_methods
|
|
76
|
+
unless allowed.include?(token_endpoint_auth_method)
|
|
77
|
+
errors.add(:token_endpoint_auth_method, "is not allowed: #{token_endpoint_auth_method}")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def grant_types_are_allowed
|
|
82
|
+
return if grant_types.blank?
|
|
83
|
+
return unless grant_types.is_a?(Array)
|
|
84
|
+
|
|
85
|
+
allowed = TokenAuthority.config.rfc_7591_allowed_grant_types
|
|
86
|
+
disallowed = grant_types - allowed
|
|
87
|
+
errors.add(:grant_types, "contains disallowed types: #{disallowed.join(", ")}") if disallowed.any?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def response_types_are_consistent
|
|
91
|
+
return if response_types.blank? || grant_types.blank?
|
|
92
|
+
|
|
93
|
+
if grant_types.include?("authorization_code") && !response_types.include?("code")
|
|
94
|
+
errors.add(:response_types, "must include 'code' when grant_types includes 'authorization_code'")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def scopes_are_allowed
|
|
99
|
+
return if scope.blank?
|
|
100
|
+
|
|
101
|
+
allowed = TokenAuthority.config.rfc_7591_allowed_scopes
|
|
102
|
+
return if allowed.blank? # If no restrictions configured, allow all
|
|
103
|
+
|
|
104
|
+
requested_scopes = scope.to_s.split(/\s+/).reject(&:blank?)
|
|
105
|
+
disallowed = requested_scopes - allowed
|
|
106
|
+
errors.add(:scope, "contains disallowed scopes: #{disallowed.join(", ")}") if disallowed.any?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def contacts_are_valid_emails
|
|
110
|
+
return if contacts.blank?
|
|
111
|
+
return unless contacts.is_a?(Array)
|
|
112
|
+
|
|
113
|
+
email_regex = /\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i
|
|
114
|
+
contacts.each do |email|
|
|
115
|
+
unless email.match?(email_regex)
|
|
116
|
+
errors.add(:contacts, "contains invalid email: #{email}")
|
|
117
|
+
break
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def jwks_and_jwks_uri_mutually_exclusive
|
|
123
|
+
if jwks.present? && jwks_uri.present?
|
|
124
|
+
errors.add(:base, "jwks and jwks_uri are mutually exclusive")
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def jwks_required_for_private_key_jwt
|
|
129
|
+
return unless token_endpoint_auth_method == "private_key_jwt"
|
|
130
|
+
|
|
131
|
+
if jwks.blank? && jwks_uri.blank?
|
|
132
|
+
errors.add(:base, "jwks or jwks_uri is required when token_endpoint_auth_method is private_key_jwt")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def software_statement_is_valid
|
|
137
|
+
return if software_statement.blank?
|
|
138
|
+
|
|
139
|
+
begin
|
|
140
|
+
@parsed_software_statement = parse_software_statement
|
|
141
|
+
rescue TokenAuthority::InvalidSoftwareStatementError => e
|
|
142
|
+
errors.add(:software_statement, e.message)
|
|
143
|
+
rescue TokenAuthority::UnapprovedSoftwareStatementError => e
|
|
144
|
+
errors.add(:software_statement, e.message)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def parse_software_statement
|
|
149
|
+
jwks = TokenAuthority.config.rfc_7591_software_statement_jwks
|
|
150
|
+
if jwks.present?
|
|
151
|
+
SoftwareStatement.decode_and_verify(software_statement, jwks: jwks)
|
|
152
|
+
elsif TokenAuthority.config.rfc_7591_software_statement_required
|
|
153
|
+
raise TokenAuthority::UnapprovedSoftwareStatementError
|
|
154
|
+
else
|
|
155
|
+
SoftwareStatement.decode(software_statement)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def merge_software_statement_claims
|
|
160
|
+
base_attrs = registration_attributes
|
|
161
|
+
return base_attrs unless @parsed_software_statement
|
|
162
|
+
|
|
163
|
+
# Software statement claims take precedence per RFC 7591
|
|
164
|
+
@parsed_software_statement.claims.compact.merge(base_attrs.compact) do |_key, ss_val, req_val|
|
|
165
|
+
ss_val.present? ? ss_val : req_val
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def registration_attributes
|
|
170
|
+
{
|
|
171
|
+
redirect_uris: redirect_uris,
|
|
172
|
+
token_endpoint_auth_method: token_endpoint_auth_method,
|
|
173
|
+
grant_types: grant_types,
|
|
174
|
+
response_types: response_types,
|
|
175
|
+
client_name: client_name,
|
|
176
|
+
client_uri: client_uri,
|
|
177
|
+
logo_uri: logo_uri,
|
|
178
|
+
tos_uri: tos_uri,
|
|
179
|
+
policy_uri: policy_uri,
|
|
180
|
+
contacts: contacts,
|
|
181
|
+
scope: scope,
|
|
182
|
+
jwks_uri: jwks_uri,
|
|
183
|
+
jwks: jwks,
|
|
184
|
+
software_id: software_id,
|
|
185
|
+
software_version: software_version
|
|
186
|
+
}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def build_client(attrs)
|
|
190
|
+
client_type = (attrs[:token_endpoint_auth_method] == "none") ? "public" : "confidential"
|
|
191
|
+
|
|
192
|
+
TokenAuthority::Client.new(
|
|
193
|
+
name: attrs[:client_name] || "Dynamic Client",
|
|
194
|
+
redirect_uris: attrs[:redirect_uris],
|
|
195
|
+
client_type: client_type,
|
|
196
|
+
token_endpoint_auth_method: attrs[:token_endpoint_auth_method] || "client_secret_basic",
|
|
197
|
+
grant_types: attrs[:grant_types],
|
|
198
|
+
response_types: attrs[:response_types],
|
|
199
|
+
scope: attrs[:scope],
|
|
200
|
+
client_uri: attrs[:client_uri],
|
|
201
|
+
logo_uri: attrs[:logo_uri],
|
|
202
|
+
tos_uri: attrs[:tos_uri],
|
|
203
|
+
policy_uri: attrs[:policy_uri],
|
|
204
|
+
contacts: attrs[:contacts],
|
|
205
|
+
jwks_uri: attrs[:jwks_uri],
|
|
206
|
+
jwks: attrs[:jwks],
|
|
207
|
+
software_id: attrs[:software_id],
|
|
208
|
+
software_version: attrs[:software_version],
|
|
209
|
+
software_statement: software_statement,
|
|
210
|
+
dynamically_registered: true
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenAuthority
|
|
4
|
+
##
|
|
5
|
+
# Value object for building RFC 7591-compliant client registration response
|
|
6
|
+
class ClientRegistrationResponse
|
|
7
|
+
attr_reader :client
|
|
8
|
+
|
|
9
|
+
def initialize(client:)
|
|
10
|
+
@client = client
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_h
|
|
14
|
+
response = {
|
|
15
|
+
client_id: client.public_id,
|
|
16
|
+
client_id_issued_at: client.client_id_issued_at.to_i
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# Only include client_secret for confidential clients
|
|
20
|
+
if client.confidential_client_type?
|
|
21
|
+
response[:client_secret] = client.client_secret
|
|
22
|
+
response[:client_secret_expires_at] = client.client_secret_expires_at&.to_i || 0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Echo back all registered metadata
|
|
26
|
+
response.merge!(metadata)
|
|
27
|
+
|
|
28
|
+
response.compact
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_json(*)
|
|
32
|
+
to_h.to_json
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def metadata
|
|
38
|
+
{
|
|
39
|
+
redirect_uris: client.redirect_uris,
|
|
40
|
+
token_endpoint_auth_method: client.token_endpoint_auth_method,
|
|
41
|
+
grant_types: client.grant_types,
|
|
42
|
+
response_types: client.response_types,
|
|
43
|
+
client_name: client.name,
|
|
44
|
+
client_uri: client.client_uri,
|
|
45
|
+
logo_uri: client.logo_uri,
|
|
46
|
+
tos_uri: client.tos_uri,
|
|
47
|
+
policy_uri: client.policy_uri,
|
|
48
|
+
contacts: client.contacts,
|
|
49
|
+
scope: client.scope,
|
|
50
|
+
jwks_uri: client.jwks_uri,
|
|
51
|
+
jwks: client.jwks,
|
|
52
|
+
software_id: client.software_id,
|
|
53
|
+
software_version: client.software_version
|
|
54
|
+
# Note: software_statement is intentionally NOT echoed per RFC 7591
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenAuthority
|
|
4
|
+
##
|
|
5
|
+
# Stores cached JWKS fetched from remote URIs
|
|
6
|
+
class JwksCache < ApplicationRecord
|
|
7
|
+
validates :uri_hash, presence: true, uniqueness: true
|
|
8
|
+
validates :uri, presence: true
|
|
9
|
+
validates :jwks, presence: true
|
|
10
|
+
validates :expires_at, presence: true
|
|
11
|
+
|
|
12
|
+
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
|
13
|
+
scope :valid, -> { where("expires_at > ?", Time.current) }
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def find_by_uri(uri)
|
|
17
|
+
find_by(uri_hash: hash_uri(uri))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def hash_uri(uri)
|
|
21
|
+
Digest::SHA256.hexdigest(uri)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def cleanup_expired!
|
|
25
|
+
expired.delete_all
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def expired?
|
|
30
|
+
expires_at <= Time.current
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_jwk_set
|
|
34
|
+
JWT::JWK::Set.new(jwks)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TokenAuthority
|
|
4
|
+
##
|
|
5
|
+
# Service for fetching and caching JWKS from a remote URI using database storage
|
|
6
|
+
class JwksFetcher
|
|
7
|
+
extend TokenAuthority::Instrumentation
|
|
8
|
+
|
|
9
|
+
class FetchError < StandardError; end
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def fetch(uri)
|
|
13
|
+
instrument("jwks.fetch", uri: uri) do |payload|
|
|
14
|
+
cached = JwksCache.find_by_uri(uri)
|
|
15
|
+
|
|
16
|
+
if cached && !cached.expired?
|
|
17
|
+
payload[:cache_hit] = true
|
|
18
|
+
return cached.to_jwk_set
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
payload[:cache_hit] = false
|
|
22
|
+
|
|
23
|
+
# Fetch fresh data and update/create cache entry
|
|
24
|
+
jwks_data = fetch_from_uri(uri)
|
|
25
|
+
store_in_cache(uri, jwks_data)
|
|
26
|
+
|
|
27
|
+
JWT::JWK::Set.new(jwks_data)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def clear_cache(uri)
|
|
32
|
+
JwksCache.find_by_uri(uri)&.destroy
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def cleanup_expired!
|
|
36
|
+
JwksCache.cleanup_expired!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def fetch_from_uri(uri)
|
|
42
|
+
parsed_uri = URI.parse(uri)
|
|
43
|
+
raise FetchError, "Invalid URI scheme: #{uri}" unless parsed_uri.is_a?(URI::HTTPS)
|
|
44
|
+
|
|
45
|
+
response = Net::HTTP.get_response(parsed_uri)
|
|
46
|
+
raise FetchError, "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
47
|
+
|
|
48
|
+
JSON.parse(response.body)
|
|
49
|
+
rescue URI::InvalidURIError => e
|
|
50
|
+
raise FetchError, "Invalid JWKS URI: #{e.message}"
|
|
51
|
+
rescue JSON::ParserError => e
|
|
52
|
+
raise FetchError, "Invalid JWKS JSON: #{e.message}"
|
|
53
|
+
rescue => e
|
|
54
|
+
raise FetchError, "Failed to fetch JWKS: #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def store_in_cache(uri, jwks_data)
|
|
58
|
+
ttl = TokenAuthority.config.rfc_7591_jwks_cache_ttl
|
|
59
|
+
uri_hash = JwksCache.hash_uri(uri)
|
|
60
|
+
|
|
61
|
+
JwksCache.find_or_initialize_by(uri_hash: uri_hash).tap do |cache|
|
|
62
|
+
cache.uri = uri
|
|
63
|
+
cache.jwks = jwks_data
|
|
64
|
+
cache.expires_at = Time.current + ttl
|
|
65
|
+
cache.save!
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|