safire 0.1.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 (72) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +1 -0
  3. data/.rubocop.yml +62 -0
  4. data/.tool-versions +1 -0
  5. data/CHANGELOG.md +35 -0
  6. data/CODE_OF_CONDUCT.md +17 -0
  7. data/CONTRIBUTION.md +283 -0
  8. data/Gemfile +26 -0
  9. data/Gemfile.lock +186 -0
  10. data/LICENSE +201 -0
  11. data/README.md +159 -0
  12. data/ROADMAP.md +54 -0
  13. data/Rakefile +26 -0
  14. data/docs/.gitignore +5 -0
  15. data/docs/404.html +25 -0
  16. data/docs/Gemfile +37 -0
  17. data/docs/Gemfile.lock +195 -0
  18. data/docs/_config.yml +103 -0
  19. data/docs/_includes/footer_custom.html +6 -0
  20. data/docs/_includes/head_custom.html +14 -0
  21. data/docs/_sass/custom/custom.scss +108 -0
  22. data/docs/adr/ADR-001-activesupport-dependency.md +50 -0
  23. data/docs/adr/ADR-002-facade-and-forwardable.md +79 -0
  24. data/docs/adr/ADR-003-protocol-vs-client-type.md +67 -0
  25. data/docs/adr/ADR-004-clientconfig-immutability-and-entity-masking.md +59 -0
  26. data/docs/adr/ADR-005-per-client-http-ownership.md +58 -0
  27. data/docs/adr/ADR-006-lazy-discovery.md +83 -0
  28. data/docs/adr/ADR-007-https-only-redirects-and-localhost-exception.md +59 -0
  29. data/docs/adr/ADR-008-warn-return-false-for-compliance-validation.md +74 -0
  30. data/docs/adr/index.md +22 -0
  31. data/docs/advanced.md +284 -0
  32. data/docs/configuration/client-setup.md +158 -0
  33. data/docs/configuration/index.md +60 -0
  34. data/docs/configuration/logging.md +86 -0
  35. data/docs/index.md +64 -0
  36. data/docs/installation.md +96 -0
  37. data/docs/security.md +256 -0
  38. data/docs/smart-on-fhir/confidential-asymmetric/authorization.md +72 -0
  39. data/docs/smart-on-fhir/confidential-asymmetric/index.md +162 -0
  40. data/docs/smart-on-fhir/confidential-asymmetric/token-exchange.md +250 -0
  41. data/docs/smart-on-fhir/confidential-symmetric/authorization.md +75 -0
  42. data/docs/smart-on-fhir/confidential-symmetric/index.md +69 -0
  43. data/docs/smart-on-fhir/confidential-symmetric/token-exchange.md +215 -0
  44. data/docs/smart-on-fhir/discovery/capability-checks.md +142 -0
  45. data/docs/smart-on-fhir/discovery/index.md +96 -0
  46. data/docs/smart-on-fhir/discovery/metadata.md +147 -0
  47. data/docs/smart-on-fhir/index.md +72 -0
  48. data/docs/smart-on-fhir/post-based-authorization.md +190 -0
  49. data/docs/smart-on-fhir/public-client/authorization.md +112 -0
  50. data/docs/smart-on-fhir/public-client/index.md +80 -0
  51. data/docs/smart-on-fhir/public-client/token-exchange.md +249 -0
  52. data/docs/troubleshooting/auth-errors.md +124 -0
  53. data/docs/troubleshooting/client-errors.md +130 -0
  54. data/docs/troubleshooting/index.md +99 -0
  55. data/docs/troubleshooting/token-errors.md +99 -0
  56. data/docs/udap.md +78 -0
  57. data/lib/safire/client.rb +195 -0
  58. data/lib/safire/client_config.rb +169 -0
  59. data/lib/safire/client_config_builder.rb +72 -0
  60. data/lib/safire/entity.rb +26 -0
  61. data/lib/safire/errors.rb +247 -0
  62. data/lib/safire/http_client.rb +87 -0
  63. data/lib/safire/jwt_assertion.rb +237 -0
  64. data/lib/safire/middleware/https_only_redirects.rb +39 -0
  65. data/lib/safire/pkce.rb +39 -0
  66. data/lib/safire/protocols/behaviours.rb +54 -0
  67. data/lib/safire/protocols/smart.rb +378 -0
  68. data/lib/safire/protocols/smart_metadata.rb +231 -0
  69. data/lib/safire/version.rb +4 -0
  70. data/lib/safire.rb +54 -0
  71. data/safire.gemspec +36 -0
  72. metadata +184 -0
@@ -0,0 +1,39 @@
1
+ require 'faraday'
2
+ require 'uri'
3
+
4
+ module Safire
5
+ module Middleware
6
+ # Faraday middleware that blocks redirects to non-HTTPS URLs.
7
+ #
8
+ # Sits inside the follow_redirects middleware's app stack so it sees every
9
+ # intermediate 3xx response before the redirect is followed. HTTP redirects
10
+ # to localhost/127.0.0.1 are allowed (consistent with ClientConfig's
11
+ # localhost exception for local development).
12
+ class HttpsOnlyRedirects < Faraday::Middleware
13
+ LOCALHOST = %w[localhost 127.0.0.1].freeze
14
+
15
+ def call(env)
16
+ @app.call(env).on_complete do |response_env|
17
+ check_redirect_safety!(response_env)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def check_redirect_safety!(env)
24
+ return unless (300..308).cover?(env.status)
25
+
26
+ location = env.response_headers['location']
27
+ return unless location
28
+
29
+ uri = URI.parse(location)
30
+ return if uri.scheme == 'https'
31
+ return if LOCALHOST.include?(uri.host)
32
+
33
+ raise Safire::Errors::NetworkError.new(
34
+ error_description: "Redirect to non-HTTPS URL blocked: #{location}"
35
+ )
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,39 @@
1
+ module Safire
2
+ # PKCE (Proof Key for Code Exchange) implementation
3
+ # This class generates a code verifier and corresponding code challenge for use in OAuth2 authorization flows.
4
+ # It supports the S256 code challenge method.
5
+ # @see https://datatracker.ietf.org/doc/html/rfc7636
6
+ class PKCE
7
+ class << self
8
+ def generate_code_verifier
9
+ # Using 96 bytes will produce a 128-character URL-safe base64 string which is the max length allowed
10
+ SecureRandom.urlsafe_base64(96).tr('=', '')
11
+ end
12
+
13
+ # Generates a code challenge from the given code verifier using SHA256 and base64url encoding
14
+ # @param code_verifier [String] the code verifier
15
+ # @return [String] the generated code challenge
16
+ # @raise [ArgumentError] if the code verifier is invalid
17
+ def generate_code_challenge(code_verifier)
18
+ validate_verifier(code_verifier)
19
+
20
+ digest = Digest::SHA256.digest(code_verifier)
21
+ Base64.urlsafe_encode64(digest).tr('=', '')
22
+ end
23
+
24
+ private
25
+
26
+ def validate_verifier(code_verifier)
27
+ length = code_verifier.length
28
+ unless length.between?(43, 128)
29
+ raise ArgumentError, "Code verifier must be between 43 and 128 characters long, got #{length}"
30
+ end
31
+
32
+ # RFC 7636: unreserved characters only
33
+ return if code_verifier.match?(/\A[A-Za-z0-9\-._~]+\z/)
34
+
35
+ raise ArgumentError, 'Code verifier contains invalid characters. Only unreserved characters are allowed.'
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,54 @@
1
+ module Safire
2
+ module Protocols
3
+ # Abstract contract that all Safire protocol implementations must satisfy.
4
+ #
5
+ # Include this module in a protocol class to declare conformance with the
6
+ # Safire protocol interface. Each method raises +NotImplementedError+ by
7
+ # default; concrete protocol classes must override every method.
8
+ #
9
+ # @abstract
10
+ # @api private
11
+ module Behaviours
12
+ # Returns protocol-specific server metadata from discovery.
13
+ # @abstract
14
+ def server_metadata(...)
15
+ raise NotImplementedError, "#{self.class}#server_metadata is not implemented"
16
+ end
17
+
18
+ # Builds the authorization request URL/data.
19
+ # @abstract
20
+ def authorization_url(...)
21
+ raise NotImplementedError, "#{self.class}#authorization_url is not implemented"
22
+ end
23
+
24
+ # Exchanges an authorization code for an access token.
25
+ # @abstract
26
+ def request_access_token(...)
27
+ raise NotImplementedError, "#{self.class}#request_access_token is not implemented"
28
+ end
29
+
30
+ # Exchanges a refresh token for a new access token.
31
+ # @abstract
32
+ def refresh_token(...)
33
+ raise NotImplementedError, "#{self.class}#refresh_token is not implemented"
34
+ end
35
+
36
+ # Validates a token response for compliance with this protocol's specification.
37
+ # @abstract
38
+ def token_response_valid?(...)
39
+ raise NotImplementedError, "#{self.class}#token_response_valid? is not implemented"
40
+ end
41
+
42
+ # Dynamically registers this client with the authorization server (RFC 7591).
43
+ #
44
+ # SMART App Launch 2.2.0 encourages implementers to consider the OAuth 2.0
45
+ # Dynamic Client Registration Protocol for an out-of-the-box solution.
46
+ # Implementations should override this method when registration is supported.
47
+ #
48
+ # @abstract
49
+ def register_client(...)
50
+ raise NotImplementedError, "#{self.class}#register_client is not implemented"
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,378 @@
1
+ module Safire
2
+ module Protocols
3
+ # SMART on FHIR OAuth2 implementation for authorization code, access token, and refresh token flows.
4
+ #
5
+ # This is an internal class used exclusively by {Safire::Client}. Do not instantiate it directly —
6
+ # use {Safire::Client} instead.
7
+ #
8
+ # Accepts a {Safire::ClientConfig} and a +client_type+ symbol. Reads all configuration
9
+ # attributes directly from the +ClientConfig+ object. Discovery of authorization and token
10
+ # endpoints from the FHIR server's +/.well-known/smart-configuration+ metadata is performed
11
+ # automatically when those endpoints are not present in the config.
12
+ #
13
+ # @note For internal use by {Safire::Client} only.
14
+ # @api private
15
+ #
16
+ # @raise [Safire::Errors::ConfigurationError]
17
+ # if required configuration attributes are missing or invalid
18
+ class Smart
19
+ include Behaviours
20
+
21
+ ATTRIBUTES = %i[
22
+ base_url client_id client_secret redirect_uri scopes issuer
23
+ authorization_endpoint token_endpoint
24
+ private_key kid jwt_algorithm jwks_uri
25
+ ].freeze
26
+
27
+ # Attributes that are not required during validation
28
+ OPTIONAL_ATTRIBUTES = %i[scopes client_secret private_key kid jwt_algorithm jwks_uri].freeze
29
+
30
+ WELL_KNOWN_PATH = '/.well-known/smart-configuration'.freeze
31
+
32
+ attr_reader(*ATTRIBUTES)
33
+ attr_accessor :client_type
34
+
35
+ # @api private
36
+ def initialize(config, client_type: :public)
37
+ ATTRIBUTES.each { |attr| instance_variable_set("@#{attr}", config.public_send(attr)) }
38
+
39
+ @client_type = client_type.to_sym
40
+ @http_client = Safire::HTTPClient.new
41
+ @issuer ||= base_url
42
+
43
+ validate!
44
+ end
45
+
46
+ def authorization_endpoint
47
+ @authorization_endpoint ||= server_metadata.authorization_endpoint
48
+ end
49
+
50
+ def token_endpoint
51
+ @token_endpoint ||= server_metadata.token_endpoint
52
+ end
53
+
54
+ # Retrieves and parses SMART on FHIR configuration metadata from the FHIR server.
55
+ #
56
+ # This method sends a GET request to the server's
57
+ # +/.well-known/smart-configuration+ endpoint, validates the response format,
58
+ # and builds a {Safire::Protocols::SmartMetadata} object containing the
59
+ # authorization and token endpoints, among other SMART metadata fields.
60
+ #
61
+ # The result is cached after the first successful discovery and reused on
62
+ # subsequent calls within the same instance.
63
+ #
64
+ # @return [Safire::Protocols::SmartMetadata]
65
+ # Parsed SMART configuration metadata object.
66
+ # @raise [Safire::Errors::DiscoveryError]
67
+ # If the discovery request fails or the response is not a valid JSON object.
68
+ def server_metadata
69
+ return @server_metadata if @server_metadata
70
+
71
+ response = @http_client.get(well_known_endpoint)
72
+ @server_metadata = SmartMetadata.new(parse_discovery_body(response.body))
73
+ rescue Faraday::Error => e
74
+ status = e.response&.dig(:status)
75
+ Safire.logger.error("SMART discovery failed for `#{well_known_endpoint}`: HTTP #{status}")
76
+ raise Errors::DiscoveryError.new(endpoint: well_known_endpoint, status: status)
77
+ end
78
+
79
+ # Builds the authorization request data for the authorization code flow.
80
+ #
81
+ # @param launch [String, nil] optional launch parameter
82
+ # @param custom_scopes [Array<String>, nil] optional custom scopes to override the configured ones
83
+ # @param method [Symbol, String] authorization request method; +:get+ (default) or +:post+.
84
+ # Both symbol and string forms are accepted (e.g. +method: :post+ or +method: 'post'+).
85
+ # * +:get+ — builds a redirect URL with all parameters in the query string (standard flow)
86
+ # * +:post+ — returns the endpoint and parameters separately for POST-based authorization
87
+ # (SMART App Launch 2.2.0 +authorize-post+ capability)
88
+ # @return [Hash] containing:
89
+ # * :auth_url [String] authorization URL (GET) or bare endpoint URL (POST)
90
+ # * :state [String] state parameter for CSRF protection; store and verify on callback
91
+ # * :code_verifier [String] PKCE code verifier for the token exchange
92
+ # * :params [Hash] (POST only) authorization parameters to submit as the request body
93
+ # @raise [Errors::ConfigurationError] if no scopes are configured or if method is invalid
94
+ def authorization_url(launch: nil, custom_scopes: nil, method: :get)
95
+ method = method.to_sym
96
+ validate_presence_of_scopes(custom_scopes)
97
+ validate_authorization_method(method)
98
+
99
+ Safire.logger.info("Generating authorization URL for SMART #{client_type} (method: #{method})...")
100
+
101
+ code_verifier = PKCE.generate_code_verifier
102
+ params = authorization_params(launch:, custom_scopes:, code_verifier:)
103
+
104
+ build_authorization_response(method, params, code_verifier)
105
+ end
106
+
107
+ # Exchanges the authorization code for an access token.
108
+ #
109
+ # @param code [String] authorization code from the authorization server
110
+ # @param code_verifier [String] PKCE code verifier from the authorization step
111
+ # @param client_secret [String, nil] optional; used for confidential symmetric clients when not already configured
112
+ # @param private_key [OpenSSL::PKey, String, nil] optional; private key for asymmetric auth (overrides configured)
113
+ # @param kid [String, nil] optional; key ID for asymmetric auth (overrides configured)
114
+ # @return [Hash] token response parsed from the authorization server, including:
115
+ # * "access_token" [String] new access token issued by the authorization server (required)
116
+ # * "token_type" [String] token type, fixed value "bearer" (required)
117
+ # * "expires_in" [Integer] lifetime of the access token in seconds (required)
118
+ # * "scope" [String] authorized scopes for this token (required)
119
+ # * "refresh_token" [String] refresh token, if issued (optional)
120
+ # * "authorization_details" [Hash] additional authorization details, if provided (optional)
121
+ # * Context parameters such as "patient" or "encounter" MAY be present, depending on server behavior.
122
+ # @raise [Safire::Errors::TokenError] if the request fails or response is invalid.
123
+ def request_access_token(code:, code_verifier:, client_secret: self.client_secret,
124
+ private_key: self.private_key, kid: self.kid)
125
+ Safire.logger.info('Requesting access token using authorization code...')
126
+
127
+ response = @http_client.post(
128
+ token_endpoint,
129
+ body: access_token_params(code, code_verifier, private_key:, kid:),
130
+ headers: oauth2_headers(client_secret)
131
+ )
132
+
133
+ parse_token_response(response.body)
134
+ rescue Faraday::Error => e
135
+ raise token_error_from(e)
136
+ end
137
+
138
+ # Exchanges a refresh token for a new access token.
139
+ #
140
+ # @param refresh_token [String] the refresh token issued by the authorization server (required)
141
+ # @param scopes [Array<String>, nil] optional reduced scope list;
142
+ # if omitted, the same scopes as the original token are requested
143
+ # @param client_secret [String, nil] optional; used for confidential symmetric clients when not already configured
144
+ # @param private_key [OpenSSL::PKey, String, nil] optional; private key for asymmetric auth (overrides configured)
145
+ # @param kid [String, nil] optional; key ID for asymmetric auth (overrides configured)
146
+ # @return [Hash] token response parsed from the authorization server.
147
+ # See {#request_access_token} for token response format.
148
+ # @raise [Safire::Errors::TokenError] if the refresh request fails or the response is invalid.
149
+ def refresh_token(refresh_token:, scopes: nil, client_secret: self.client_secret,
150
+ private_key: self.private_key, kid: self.kid)
151
+ Safire.logger.info('Refreshing access token...')
152
+
153
+ response = @http_client.post(
154
+ token_endpoint,
155
+ body: refresh_token_params(refresh_token:, scopes:, private_key:, kid:),
156
+ headers: oauth2_headers(client_secret)
157
+ )
158
+
159
+ parse_token_response(response.body)
160
+ rescue Faraday::Error => e
161
+ raise token_error_from(e)
162
+ end
163
+
164
+ # Validates a token response for SMART App Launch 2.2.0 compliance.
165
+ #
166
+ # Checks all required token response fields per SMART App Launch 2.2.0 §Token Response:
167
+ # - +access_token+ must be present (SHALL)
168
+ # - +token_type+ must be present and exactly +"Bearer"+ (SHALL, case-sensitive)
169
+ # - +scope+ must be present (SHALL)
170
+ #
171
+ # Logs a warning via {Safire.logger} for each violation found and returns false.
172
+ # Never raises an exception.
173
+ #
174
+ # @param response [Hash] the token response returned by the server
175
+ # @return [Boolean] true if the response is compliant, false otherwise
176
+ def token_response_valid?(response)
177
+ unless response.is_a?(Hash)
178
+ Safire.logger.warn('SMART token response non-compliance: response is not a JSON object')
179
+ return false
180
+ end
181
+
182
+ valid = true
183
+
184
+ %w[access_token scope].each do |field|
185
+ next if response[field].present?
186
+
187
+ Safire.logger.warn(
188
+ "SMART token response non-compliance: required field '#{field}' is missing"
189
+ )
190
+ valid = false
191
+ end
192
+
193
+ token_type_valid?(response) && valid
194
+ end
195
+
196
+ private
197
+
198
+ def validate!
199
+ missing = (ATTRIBUTES - OPTIONAL_ATTRIBUTES).select { |attr| send(attr).blank? }
200
+ return if missing.empty?
201
+
202
+ raise Errors::ConfigurationError.new(missing_attributes: missing)
203
+ end
204
+
205
+ def validate_authorization_method(method)
206
+ return if %i[get post].include?(method)
207
+
208
+ raise Errors::ConfigurationError.new(
209
+ invalid_attribute: :method,
210
+ invalid_value: method,
211
+ valid_values: %i[get post]
212
+ )
213
+ end
214
+
215
+ def build_authorization_response(method, params, code_verifier)
216
+ if method == :post
217
+ { auth_url: authorization_endpoint, params:, state: params[:state], code_verifier: }
218
+ else
219
+ uri = Addressable::URI.parse(authorization_endpoint)
220
+ uri.query_values = params
221
+ { auth_url: uri.to_s, state: uri.query_values['state'], code_verifier: }
222
+ end
223
+ end
224
+
225
+ def validate_presence_of_scopes(custom_scopes = nil)
226
+ return if (scopes || custom_scopes).present?
227
+
228
+ raise Errors::ConfigurationError.new(missing_attributes: [:scopes])
229
+ end
230
+
231
+ def validate_client_secret(secret)
232
+ return if secret.present?
233
+
234
+ raise Errors::ConfigurationError.new(missing_attributes: [:client_secret])
235
+ end
236
+
237
+ def parse_discovery_body(body)
238
+ return body if body.is_a?(Hash)
239
+
240
+ raise Errors::DiscoveryError.new(
241
+ endpoint: well_known_endpoint,
242
+ error_description: 'response is not a JSON object'
243
+ )
244
+ end
245
+
246
+ def parse_token_response(token_response)
247
+ unless token_response.is_a?(Hash)
248
+ raise Errors::TokenError.new(error_description: 'response is not a JSON object')
249
+ end
250
+
251
+ return token_response if token_response['access_token'].present?
252
+
253
+ raise Errors::TokenError.new(received_fields: token_response.keys)
254
+ end
255
+
256
+ def token_type_valid?(response)
257
+ if response['token_type'].blank?
258
+ Safire.logger.warn(
259
+ "SMART token response non-compliance: required field 'token_type' is missing"
260
+ )
261
+ return false
262
+ end
263
+
264
+ return true if response['token_type'] == 'Bearer'
265
+
266
+ Safire.logger.warn(
267
+ "SMART token response non-compliance: token_type is #{response['token_type'].inspect}; " \
268
+ "expected 'Bearer' (SMART App Launch 2.2.0 requires token_type \"Bearer\")"
269
+ )
270
+ false
271
+ end
272
+
273
+ def authorization_params(launch:, custom_scopes:, code_verifier:)
274
+ {
275
+ response_type: 'code',
276
+ client_id:,
277
+ redirect_uri:,
278
+ launch:,
279
+ scope: [custom_scopes || scopes].flatten.join(' '),
280
+ state: SecureRandom.hex(16),
281
+ aud: issuer.to_s,
282
+ code_challenge_method: 'S256',
283
+ code_challenge: PKCE.generate_code_challenge(code_verifier)
284
+ }.compact
285
+ end
286
+
287
+ def access_token_params(code, code_verifier, private_key:, kid:)
288
+ {
289
+ grant_type: 'authorization_code',
290
+ code:,
291
+ redirect_uri:,
292
+ code_verifier:
293
+ }.merge(client_auth_params(private_key:, kid:))
294
+ end
295
+
296
+ def refresh_token_params(refresh_token:, scopes:, private_key:, kid:)
297
+ params = {
298
+ grant_type: 'refresh_token',
299
+ refresh_token:
300
+ }
301
+ params[:scope] = [scopes].flatten.join(' ') if scopes.present?
302
+ params.merge(client_auth_params(private_key:, kid:))
303
+ end
304
+
305
+ def client_auth_params(private_key:, kid:)
306
+ case client_type
307
+ when :public
308
+ { client_id: client_id }
309
+ when :confidential_asymmetric
310
+ jwt_assertion_params(private_key:, kid:)
311
+ else
312
+ {}
313
+ end
314
+ end
315
+
316
+ def oauth2_headers(secret)
317
+ headers = { content_type: 'application/x-www-form-urlencoded' }
318
+
319
+ if client_type == :confidential_symmetric
320
+ headers[:Authorization] = authentication_header(secret.presence || client_secret)
321
+ end
322
+
323
+ headers
324
+ end
325
+
326
+ def authentication_header(secret)
327
+ validate_client_secret(secret)
328
+
329
+ "Basic #{Base64.strict_encode64("#{client_id}:#{secret}")}"
330
+ end
331
+
332
+ def jwt_assertion_params(private_key:, kid:)
333
+ validate_asymmetric_credentials!(private_key, kid)
334
+
335
+ assertion = Safire::JWTAssertion.new(
336
+ client_id: client_id,
337
+ token_endpoint: token_endpoint,
338
+ private_key: private_key,
339
+ kid: kid,
340
+ algorithm: jwt_algorithm,
341
+ jku: jwks_uri
342
+ )
343
+
344
+ {
345
+ client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
346
+ client_assertion: assertion.to_jwt
347
+ }
348
+ end
349
+
350
+ def validate_asymmetric_credentials!(private_key, kid)
351
+ missing = []
352
+ missing << :private_key if private_key.blank?
353
+ missing << :kid if kid.blank?
354
+ return if missing.empty?
355
+
356
+ raise Errors::ConfigurationError.new(missing_attributes: missing)
357
+ end
358
+
359
+ def token_error_from(faraday_error)
360
+ response = faraday_error.response
361
+ status = response&.dig(:status)
362
+ body = JSON.parse(response&.dig(:body))
363
+
364
+ Errors::TokenError.new(
365
+ status:,
366
+ error_code: body.is_a?(Hash) ? body['error'] : nil,
367
+ error_description: body.is_a?(Hash) ? body['error_description'] : nil
368
+ )
369
+ rescue JSON::ParserError
370
+ Errors::TokenError.new(status:)
371
+ end
372
+
373
+ def well_known_endpoint
374
+ "#{base_url.to_s.chomp('/')}#{WELL_KNOWN_PATH}"
375
+ end
376
+ end
377
+ end
378
+ end