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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +199 -7
  4. data/app/controllers/concerns/token_authority/client_authentication.rb +141 -0
  5. data/app/controllers/concerns/token_authority/controller_event_logging.rb +98 -0
  6. data/app/controllers/concerns/token_authority/initial_access_token_authentication.rb +35 -0
  7. data/app/controllers/concerns/token_authority/token_authentication.rb +128 -0
  8. data/app/controllers/token_authority/authorization_grants_controller.rb +119 -0
  9. data/app/controllers/token_authority/authorizations_controller.rb +105 -0
  10. data/app/controllers/token_authority/clients_controller.rb +99 -0
  11. data/app/controllers/token_authority/metadata_controller.rb +12 -0
  12. data/app/controllers/token_authority/resource_metadata_controller.rb +12 -0
  13. data/app/controllers/token_authority/sessions_controller.rb +228 -0
  14. data/app/helpers/token_authority/authorization_grants_helper.rb +27 -0
  15. data/app/models/concerns/token_authority/claim_validatable.rb +95 -0
  16. data/app/models/concerns/token_authority/event_logging.rb +144 -0
  17. data/app/models/concerns/token_authority/resourceable.rb +111 -0
  18. data/app/models/concerns/token_authority/scopeable.rb +105 -0
  19. data/app/models/concerns/token_authority/session_creatable.rb +101 -0
  20. data/app/models/token_authority/access_token.rb +127 -0
  21. data/app/models/token_authority/access_token_request.rb +193 -0
  22. data/app/models/token_authority/authorization_grant.rb +119 -0
  23. data/app/models/token_authority/authorization_request.rb +276 -0
  24. data/app/models/token_authority/authorization_server_metadata.rb +101 -0
  25. data/app/models/token_authority/client.rb +263 -0
  26. data/app/models/token_authority/client_id_resolver.rb +114 -0
  27. data/app/models/token_authority/client_metadata_document.rb +164 -0
  28. data/app/models/token_authority/client_metadata_document_cache.rb +33 -0
  29. data/app/models/token_authority/client_metadata_document_fetcher.rb +266 -0
  30. data/app/models/token_authority/client_registration_request.rb +214 -0
  31. data/app/models/token_authority/client_registration_response.rb +58 -0
  32. data/app/models/token_authority/jwks_cache.rb +37 -0
  33. data/app/models/token_authority/jwks_fetcher.rb +70 -0
  34. data/app/models/token_authority/protected_resource_metadata.rb +74 -0
  35. data/app/models/token_authority/refresh_token.rb +110 -0
  36. data/app/models/token_authority/refresh_token_request.rb +116 -0
  37. data/app/models/token_authority/session.rb +193 -0
  38. data/app/models/token_authority/software_statement.rb +70 -0
  39. data/app/views/token_authority/authorization_grants/new.html.erb +25 -0
  40. data/app/views/token_authority/client_error.html.erb +8 -0
  41. data/config/locales/token_authority.en.yml +248 -0
  42. data/config/routes.rb +29 -0
  43. data/lib/generators/token_authority/install/install_generator.rb +61 -0
  44. data/lib/generators/token_authority/install/templates/create_token_authority_tables.rb.erb +116 -0
  45. data/lib/generators/token_authority/install/templates/token_authority.rb +247 -0
  46. data/lib/token_authority/configuration.rb +397 -0
  47. data/lib/token_authority/engine.rb +34 -0
  48. data/lib/token_authority/errors.rb +221 -0
  49. data/lib/token_authority/instrumentation.rb +80 -0
  50. data/lib/token_authority/instrumentation_log_subscriber.rb +62 -0
  51. data/lib/token_authority/json_web_token.rb +78 -0
  52. data/lib/token_authority/log_event_subscriber.rb +43 -0
  53. data/lib/token_authority/routing/constraints.rb +71 -0
  54. data/lib/token_authority/routing/routes.rb +39 -0
  55. data/lib/token_authority/version.rb +4 -1
  56. data/lib/token_authority.rb +30 -1
  57. metadata +65 -5
  58. data/app/assets/stylesheets/token_authority/application.css +0 -15
  59. data/app/controllers/token_authority/application_controller.rb +0 -4
  60. data/app/helpers/token_authority/application_helper.rb +0 -4
  61. 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