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.
- checksums.yaml +7 -0
- data/.rspec +1 -0
- data/.rubocop.yml +62 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +35 -0
- data/CODE_OF_CONDUCT.md +17 -0
- data/CONTRIBUTION.md +283 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +186 -0
- data/LICENSE +201 -0
- data/README.md +159 -0
- data/ROADMAP.md +54 -0
- data/Rakefile +26 -0
- data/docs/.gitignore +5 -0
- data/docs/404.html +25 -0
- data/docs/Gemfile +37 -0
- data/docs/Gemfile.lock +195 -0
- data/docs/_config.yml +103 -0
- data/docs/_includes/footer_custom.html +6 -0
- data/docs/_includes/head_custom.html +14 -0
- data/docs/_sass/custom/custom.scss +108 -0
- data/docs/adr/ADR-001-activesupport-dependency.md +50 -0
- data/docs/adr/ADR-002-facade-and-forwardable.md +79 -0
- data/docs/adr/ADR-003-protocol-vs-client-type.md +67 -0
- data/docs/adr/ADR-004-clientconfig-immutability-and-entity-masking.md +59 -0
- data/docs/adr/ADR-005-per-client-http-ownership.md +58 -0
- data/docs/adr/ADR-006-lazy-discovery.md +83 -0
- data/docs/adr/ADR-007-https-only-redirects-and-localhost-exception.md +59 -0
- data/docs/adr/ADR-008-warn-return-false-for-compliance-validation.md +74 -0
- data/docs/adr/index.md +22 -0
- data/docs/advanced.md +284 -0
- data/docs/configuration/client-setup.md +158 -0
- data/docs/configuration/index.md +60 -0
- data/docs/configuration/logging.md +86 -0
- data/docs/index.md +64 -0
- data/docs/installation.md +96 -0
- data/docs/security.md +256 -0
- data/docs/smart-on-fhir/confidential-asymmetric/authorization.md +72 -0
- data/docs/smart-on-fhir/confidential-asymmetric/index.md +162 -0
- data/docs/smart-on-fhir/confidential-asymmetric/token-exchange.md +250 -0
- data/docs/smart-on-fhir/confidential-symmetric/authorization.md +75 -0
- data/docs/smart-on-fhir/confidential-symmetric/index.md +69 -0
- data/docs/smart-on-fhir/confidential-symmetric/token-exchange.md +215 -0
- data/docs/smart-on-fhir/discovery/capability-checks.md +142 -0
- data/docs/smart-on-fhir/discovery/index.md +96 -0
- data/docs/smart-on-fhir/discovery/metadata.md +147 -0
- data/docs/smart-on-fhir/index.md +72 -0
- data/docs/smart-on-fhir/post-based-authorization.md +190 -0
- data/docs/smart-on-fhir/public-client/authorization.md +112 -0
- data/docs/smart-on-fhir/public-client/index.md +80 -0
- data/docs/smart-on-fhir/public-client/token-exchange.md +249 -0
- data/docs/troubleshooting/auth-errors.md +124 -0
- data/docs/troubleshooting/client-errors.md +130 -0
- data/docs/troubleshooting/index.md +99 -0
- data/docs/troubleshooting/token-errors.md +99 -0
- data/docs/udap.md +78 -0
- data/lib/safire/client.rb +195 -0
- data/lib/safire/client_config.rb +169 -0
- data/lib/safire/client_config_builder.rb +72 -0
- data/lib/safire/entity.rb +26 -0
- data/lib/safire/errors.rb +247 -0
- data/lib/safire/http_client.rb +87 -0
- data/lib/safire/jwt_assertion.rb +237 -0
- data/lib/safire/middleware/https_only_redirects.rb +39 -0
- data/lib/safire/pkce.rb +39 -0
- data/lib/safire/protocols/behaviours.rb +54 -0
- data/lib/safire/protocols/smart.rb +378 -0
- data/lib/safire/protocols/smart_metadata.rb +231 -0
- data/lib/safire/version.rb +4 -0
- data/lib/safire.rb +54 -0
- data/safire.gemspec +36 -0
- metadata +184 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module Safire
|
|
2
|
+
# ClientConfigBuilder helps to build a Safire::ClientConfig instance
|
|
3
|
+
class ClientConfigBuilder
|
|
4
|
+
def initialize
|
|
5
|
+
@config = {}
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def base_url(url)
|
|
9
|
+
@config[:base_url] = url
|
|
10
|
+
self
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def issuer(issuer)
|
|
14
|
+
@config[:issuer] = issuer
|
|
15
|
+
self
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def client_id(client_id)
|
|
19
|
+
@config[:client_id] = client_id
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def client_secret(client_secret)
|
|
24
|
+
@config[:client_secret] = client_secret
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def redirect_uri(uri)
|
|
29
|
+
@config[:redirect_uri] = uri
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def scopes(scopes)
|
|
34
|
+
@config[:scopes] = scopes
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def authorization_endpoint(authorization_endpoint)
|
|
39
|
+
@config[:authorization_endpoint] = authorization_endpoint
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def token_endpoint(token_endpoint)
|
|
44
|
+
@config[:token_endpoint] = token_endpoint
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def private_key(private_key)
|
|
49
|
+
@config[:private_key] = private_key
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def kid(kid)
|
|
54
|
+
@config[:kid] = kid
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def jwt_algorithm(jwt_algorithm)
|
|
59
|
+
@config[:jwt_algorithm] = jwt_algorithm
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def jwks_uri(jwks_uri)
|
|
64
|
+
@config[:jwks_uri] = jwks_uri
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def build
|
|
69
|
+
Safire::ClientConfig.new(@config)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Safire
|
|
2
|
+
class Entity
|
|
3
|
+
def initialize(params, attributes)
|
|
4
|
+
attributes.each { |name| instance_variable_set("@#{name}", params[name] || params[name.to_s]) }
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def to_hash
|
|
8
|
+
hash = {}
|
|
9
|
+
instance_variables.each do |var|
|
|
10
|
+
key = var.to_s.delete_prefix('@').to_sym
|
|
11
|
+
value = instance_variable_get(var)
|
|
12
|
+
hash[key] = sensitive_attributes.include?(key) && !value.nil? ? '[FILTERED]' : value
|
|
13
|
+
end
|
|
14
|
+
hash.deep_symbolize_keys
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
protected
|
|
18
|
+
|
|
19
|
+
# Returns attribute names whose values are masked as '[FILTERED]' in #to_hash.
|
|
20
|
+
#
|
|
21
|
+
# @return [Array<Symbol>]
|
|
22
|
+
def sensitive_attributes
|
|
23
|
+
[]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
module Safire
|
|
2
|
+
# Namespace for all Safire error classes.
|
|
3
|
+
#
|
|
4
|
+
# Every Safire error inherits from {Error} so consumers can rescue
|
|
5
|
+
# all Safire errors with a single +rescue Safire::Errors::Error+.
|
|
6
|
+
# Each subclass exposes typed, domain-specific attributes and builds
|
|
7
|
+
# its own human-readable message.
|
|
8
|
+
#
|
|
9
|
+
# @example Rescuing a specific error
|
|
10
|
+
# begin
|
|
11
|
+
# tokens = client.request_access_token(code: code, code_verifier: verifier)
|
|
12
|
+
# rescue Safire::Errors::TokenError => e
|
|
13
|
+
# puts e.message # "Token request failed — HTTP 401 — invalid_grant — Code expired"
|
|
14
|
+
# puts e.status # 401
|
|
15
|
+
# puts e.error_code # "invalid_grant"
|
|
16
|
+
# rescue Safire::Errors::Error => e
|
|
17
|
+
# puts e.message # catch-all for any other Safire error
|
|
18
|
+
# end
|
|
19
|
+
module Errors
|
|
20
|
+
# Base class — rescue anchor only. All Safire errors inherit from this.
|
|
21
|
+
class Error < StandardError; end
|
|
22
|
+
|
|
23
|
+
# Raised when client configuration is missing or invalid.
|
|
24
|
+
#
|
|
25
|
+
# @!attribute [r] missing_attributes
|
|
26
|
+
# @return [Array<Symbol>] required attributes that are absent
|
|
27
|
+
# @!attribute [r] invalid_attribute
|
|
28
|
+
# @return [Symbol, nil] attribute whose value is not acceptable
|
|
29
|
+
# @!attribute [r] invalid_value
|
|
30
|
+
# @return [Object, nil] the offending value
|
|
31
|
+
# @!attribute [r] valid_values
|
|
32
|
+
# @return [Array, nil] acceptable values for the attribute
|
|
33
|
+
# @!attribute [r] invalid_uri_attributes
|
|
34
|
+
# @return [Array<Symbol>] attributes whose URIs are malformed
|
|
35
|
+
# @!attribute [r] non_https_uri_attributes
|
|
36
|
+
# @return [Array<Symbol>] attributes whose URIs use HTTP on a non-localhost host
|
|
37
|
+
class ConfigurationError < Error
|
|
38
|
+
attr_reader :missing_attributes, :invalid_attribute, :invalid_value, :valid_values,
|
|
39
|
+
:invalid_uri_attributes, :non_https_uri_attributes
|
|
40
|
+
|
|
41
|
+
def initialize(missing_attributes: [], invalid_attribute: nil, invalid_value: nil,
|
|
42
|
+
valid_values: nil, invalid_uri_attributes: [], non_https_uri_attributes: [])
|
|
43
|
+
@missing_attributes = Array(missing_attributes)
|
|
44
|
+
@invalid_attribute = invalid_attribute
|
|
45
|
+
@invalid_value = invalid_value
|
|
46
|
+
@valid_values = valid_values
|
|
47
|
+
@invalid_uri_attributes = Array(invalid_uri_attributes)
|
|
48
|
+
@non_https_uri_attributes = Array(non_https_uri_attributes)
|
|
49
|
+
super(build_message)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def build_message
|
|
55
|
+
if @missing_attributes.any?
|
|
56
|
+
"Configuration missing: #{@missing_attributes.join(', ')}"
|
|
57
|
+
elsif @invalid_attribute
|
|
58
|
+
"Invalid #{@invalid_attribute}: #{@invalid_value.inspect}; valid: #{@valid_values&.join(', ')}"
|
|
59
|
+
else
|
|
60
|
+
build_uri_message
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_uri_message
|
|
65
|
+
parts = []
|
|
66
|
+
parts << "Configuration has invalid URIs: #{@invalid_uri_attributes.join(', ')}" if @invalid_uri_attributes.any?
|
|
67
|
+
if @non_https_uri_attributes.any?
|
|
68
|
+
parts << "Configuration requires HTTPS for: #{@non_https_uri_attributes.join(', ')} " \
|
|
69
|
+
'(SMART App Launch 2.2.0 requires TLS; HTTP is only allowed for localhost)'
|
|
70
|
+
end
|
|
71
|
+
parts.any? ? parts.join('. ') : 'Configuration error'
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Raised when SMART configuration discovery fails.
|
|
76
|
+
#
|
|
77
|
+
# @!attribute [r] endpoint
|
|
78
|
+
# @return [String] the discovery endpoint URL that was requested
|
|
79
|
+
# @!attribute [r] status
|
|
80
|
+
# @return [Integer, nil] HTTP status code returned by the server
|
|
81
|
+
# @!attribute [r] error_description
|
|
82
|
+
# @return [String, nil] description of why discovery failed (e.g. unexpected response format)
|
|
83
|
+
class DiscoveryError < Error
|
|
84
|
+
attr_reader :endpoint, :status, :error_description
|
|
85
|
+
|
|
86
|
+
def initialize(endpoint:, status: nil, error_description: nil)
|
|
87
|
+
@endpoint = endpoint
|
|
88
|
+
@status = status
|
|
89
|
+
@error_description = error_description
|
|
90
|
+
super(build_message)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def build_message
|
|
96
|
+
msg = "Failed to discover SMART configuration from #{@endpoint}"
|
|
97
|
+
msg += " (HTTP #{@status})" if @status
|
|
98
|
+
msg += ": #{@error_description}" if @error_description
|
|
99
|
+
msg
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Raised for token exchange or refresh failures.
|
|
104
|
+
#
|
|
105
|
+
# Two usage paths:
|
|
106
|
+
# - HTTP failure: provide +status+, +error_code+, and/or +error_description+
|
|
107
|
+
# - Structural failure (missing +access_token+): provide +received_fields+
|
|
108
|
+
#
|
|
109
|
+
# @!attribute [r] status
|
|
110
|
+
# @return [Integer, nil] HTTP status code
|
|
111
|
+
# @!attribute [r] error_code
|
|
112
|
+
# @return [String, nil] OAuth2 +error+ field (e.g. +"invalid_grant"+)
|
|
113
|
+
# @!attribute [r] error_description
|
|
114
|
+
# @return [String, nil] OAuth2 +error_description+ field
|
|
115
|
+
# @!attribute [r] received_fields
|
|
116
|
+
# @return [Array<String>, nil] field names present in an invalid token response (no values)
|
|
117
|
+
class TokenError < Error
|
|
118
|
+
attr_reader :status, :error_code, :error_description, :received_fields
|
|
119
|
+
|
|
120
|
+
def initialize(status: nil, error_code: nil, error_description: nil, received_fields: nil)
|
|
121
|
+
@status = status
|
|
122
|
+
@error_code = error_code
|
|
123
|
+
@error_description = error_description
|
|
124
|
+
@received_fields = received_fields
|
|
125
|
+
super(build_message)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def build_message
|
|
131
|
+
if @received_fields
|
|
132
|
+
"Missing access token in response; received fields: #{@received_fields.join(', ')}"
|
|
133
|
+
else
|
|
134
|
+
parts = ['Token request failed']
|
|
135
|
+
parts << "HTTP #{@status}" if @status
|
|
136
|
+
parts << @error_code if @error_code
|
|
137
|
+
parts << @error_description if @error_description
|
|
138
|
+
parts.join(' — ')
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Raised when an authorization request fails.
|
|
144
|
+
#
|
|
145
|
+
# @!attribute [r] status
|
|
146
|
+
# @return [Integer, nil] HTTP status code
|
|
147
|
+
# @!attribute [r] error_code
|
|
148
|
+
# @return [String, nil] OAuth2 +error+ field
|
|
149
|
+
# @!attribute [r] error_description
|
|
150
|
+
# @return [String, nil] OAuth2 +error_description+ field
|
|
151
|
+
class AuthError < Error
|
|
152
|
+
attr_reader :status, :error_code, :error_description
|
|
153
|
+
|
|
154
|
+
def initialize(status: nil, error_code: nil, error_description: nil)
|
|
155
|
+
@status = status
|
|
156
|
+
@error_code = error_code
|
|
157
|
+
@error_description = error_description
|
|
158
|
+
super(build_message)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def build_message
|
|
164
|
+
parts = ['Authorization request failed']
|
|
165
|
+
parts << "HTTP #{@status}" if @status
|
|
166
|
+
parts << @error_code if @error_code
|
|
167
|
+
parts << @error_description if @error_description
|
|
168
|
+
parts.join(' — ')
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Raised for X.509 certificate errors (e.g., in UDAP flows).
|
|
173
|
+
#
|
|
174
|
+
# @!attribute [r] reason
|
|
175
|
+
# @return [String, nil] why the certificate is invalid (e.g. +"expired"+, +"untrusted"+)
|
|
176
|
+
# @!attribute [r] subject
|
|
177
|
+
# @return [String, nil] certificate subject string (safe to log)
|
|
178
|
+
class CertificateError < Error
|
|
179
|
+
attr_reader :reason, :subject
|
|
180
|
+
|
|
181
|
+
def initialize(reason: nil, subject: nil)
|
|
182
|
+
@reason = reason
|
|
183
|
+
@subject = subject
|
|
184
|
+
super(build_message)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def build_message
|
|
190
|
+
parts = ['Certificate error']
|
|
191
|
+
parts << @reason if @reason
|
|
192
|
+
parts << "(subject: #{@subject})" if @subject
|
|
193
|
+
parts.join(' — ')
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Raised when an HTTP request fails at the network or transport level
|
|
198
|
+
# (connection refused, timeout, SSL handshake failure, etc.).
|
|
199
|
+
#
|
|
200
|
+
# @!attribute [r] error_description
|
|
201
|
+
# @return [String, nil] the underlying transport error message
|
|
202
|
+
class NetworkError < Error
|
|
203
|
+
attr_reader :error_description
|
|
204
|
+
|
|
205
|
+
def initialize(error_description: nil)
|
|
206
|
+
@error_description = error_description
|
|
207
|
+
super(build_message)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
def build_message
|
|
213
|
+
return 'HTTP request failed' unless @error_description
|
|
214
|
+
|
|
215
|
+
"HTTP request failed: #{@error_description}"
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Raised for input validation errors.
|
|
220
|
+
#
|
|
221
|
+
# @!attribute [r] attribute
|
|
222
|
+
# @return [Symbol, nil] the attribute that failed validation
|
|
223
|
+
# @!attribute [r] reason
|
|
224
|
+
# @return [String, nil] why validation failed
|
|
225
|
+
class ValidationError < Error
|
|
226
|
+
attr_reader :attribute, :reason
|
|
227
|
+
|
|
228
|
+
def initialize(attribute: nil, reason: nil)
|
|
229
|
+
@attribute = attribute
|
|
230
|
+
@reason = reason
|
|
231
|
+
super(build_message)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private
|
|
235
|
+
|
|
236
|
+
def build_message
|
|
237
|
+
if @attribute && @reason
|
|
238
|
+
"Validation failed for #{@attribute}: #{@reason}"
|
|
239
|
+
elsif @attribute
|
|
240
|
+
"Validation failed for #{@attribute}"
|
|
241
|
+
else
|
|
242
|
+
'Validation error'
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require 'active_support/all'
|
|
2
|
+
require 'faraday'
|
|
3
|
+
require 'faraday/follow_redirects'
|
|
4
|
+
|
|
5
|
+
module Safire
|
|
6
|
+
# HTTP client wrapper for Safire
|
|
7
|
+
class HTTPClient
|
|
8
|
+
def initialize(base_url: nil, adapter: nil, request_format: :url_encoded, ssl_options: {})
|
|
9
|
+
@options = {
|
|
10
|
+
url: normalize_base_url(base_url),
|
|
11
|
+
ssl: ssl_options,
|
|
12
|
+
headers: {
|
|
13
|
+
'User-Agent' => Safire.configuration&.user_agent || "Safire v#{Safire::VERSION}",
|
|
14
|
+
'Accept' => 'application/json'
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
@adapter = adapter || Faraday.default_adapter
|
|
18
|
+
@request_format = request_format.to_sym
|
|
19
|
+
warn_if_ssl_verification_disabled(ssl_options)
|
|
20
|
+
@connection = build_connection
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def get(path = '', params: {}, headers: {})
|
|
24
|
+
request(:get, path, params:, headers:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def post(path = '', body: nil, params: {}, headers: {})
|
|
28
|
+
request(:post, path, body:, params:, headers:)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def put(path = '', body: nil, params: {}, headers: {})
|
|
32
|
+
request(:put, path, body:, params:, headers:)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def delete(path = '', params: {}, headers: {})
|
|
36
|
+
request(:delete, path, params:, headers:)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def build_connection
|
|
42
|
+
Faraday.new(@options) do |builder|
|
|
43
|
+
builder.request @request_format
|
|
44
|
+
builder.response :follow_redirects
|
|
45
|
+
builder.use Safire::Middleware::HttpsOnlyRedirects
|
|
46
|
+
builder.response :json
|
|
47
|
+
builder.response :raise_error
|
|
48
|
+
configure_logger(builder)
|
|
49
|
+
builder.adapter @adapter
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def configure_logger(builder)
|
|
54
|
+
return if Safire.configuration&.log_http == false
|
|
55
|
+
|
|
56
|
+
builder.response :logger, Safire.logger, { headers: { request: true, response: true }, bodies: false } do |logger|
|
|
57
|
+
logger.filter(/(Authorization: )(.+)/, '\1[FILTERED]')
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def request(method, path, body: nil, params: {}, headers: {})
|
|
62
|
+
@connection.send(method) do |req|
|
|
63
|
+
req.url path.sub(%r{^/}, '') # Remove leading slash if present since base_url ends with slash
|
|
64
|
+
req.params.update(params) if params.present?
|
|
65
|
+
req.headers.update(headers) if headers.present?
|
|
66
|
+
req.body = body if body
|
|
67
|
+
end
|
|
68
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError => e
|
|
69
|
+
raise Safire::Errors::NetworkError.new(error_description: e.message)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def warn_if_ssl_verification_disabled(ssl_options)
|
|
73
|
+
return unless ssl_options.is_a?(Hash) && ssl_options[:verify] == false
|
|
74
|
+
|
|
75
|
+
Safire.logger.warn(
|
|
76
|
+
'[Safire] ssl_options: { verify: false } disables TLS certificate verification — ' \
|
|
77
|
+
'do not use in production'
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def normalize_base_url(url)
|
|
82
|
+
return '' unless url
|
|
83
|
+
|
|
84
|
+
url.ends_with?('/') ? url : "#{url}/"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
require 'jwt'
|
|
2
|
+
require 'openssl'
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Safire
|
|
6
|
+
# Generates JWT client assertions for SMART on FHIR confidential asymmetric authentication.
|
|
7
|
+
#
|
|
8
|
+
# This class creates signed JWTs according to the SMART App Launch STU 2.2.0 specification
|
|
9
|
+
# for private_key_jwt client authentication.
|
|
10
|
+
#
|
|
11
|
+
# @see https://hl7.org/fhir/smart-app-launch/client-confidential-asymmetric.html
|
|
12
|
+
#
|
|
13
|
+
# @example Creating a JWT assertion with RSA key
|
|
14
|
+
# assertion = Safire::JWTAssertion.new(
|
|
15
|
+
# client_id: 'my_app',
|
|
16
|
+
# token_endpoint: 'https://auth.example.com/token',
|
|
17
|
+
# private_key: OpenSSL::PKey::RSA.new(File.read('private.pem')),
|
|
18
|
+
# kid: 'key-id-123'
|
|
19
|
+
# )
|
|
20
|
+
# jwt = assertion.to_jwt # => signed JWT string
|
|
21
|
+
#
|
|
22
|
+
# @example With explicit algorithm and jku header
|
|
23
|
+
# assertion = Safire::JWTAssertion.new(
|
|
24
|
+
# client_id: 'my_app',
|
|
25
|
+
# token_endpoint: 'https://auth.example.com/token',
|
|
26
|
+
# private_key: private_key,
|
|
27
|
+
# kid: 'key-id-123',
|
|
28
|
+
# algorithm: 'RS384',
|
|
29
|
+
# jku: 'https://app.example.com/.well-known/jwks.json'
|
|
30
|
+
# )
|
|
31
|
+
#
|
|
32
|
+
class JWTAssertion
|
|
33
|
+
# Maximum expiration time allowed per SMART specification (5 minutes)
|
|
34
|
+
MAX_EXPIRATION_SECONDS = 300
|
|
35
|
+
|
|
36
|
+
# Default expiration time (5 minutes)
|
|
37
|
+
DEFAULT_EXPIRATION_SECONDS = 300
|
|
38
|
+
|
|
39
|
+
# Supported signing algorithms (required by SMART specification)
|
|
40
|
+
SUPPORTED_ALGORITHMS = %w[RS384 ES384].freeze
|
|
41
|
+
|
|
42
|
+
# Required parameters for JWT assertion
|
|
43
|
+
REQUIRED_PARAMS = %i[client_id token_endpoint kid].freeze
|
|
44
|
+
|
|
45
|
+
# EC curve names that support ES384 algorithm
|
|
46
|
+
SUPPORTED_EC_CURVES = %w[secp384r1 P-384].freeze
|
|
47
|
+
|
|
48
|
+
# Default algorithm for RSA keys (required by SMART spec)
|
|
49
|
+
DEFAULT_RSA_ALGORITHM = 'RS384'.freeze
|
|
50
|
+
|
|
51
|
+
# Default algorithm for EC keys (required by SMART spec)
|
|
52
|
+
DEFAULT_EC_ALGORITHM = 'ES384'.freeze
|
|
53
|
+
|
|
54
|
+
# @!attribute [r] client_id
|
|
55
|
+
# @return [String] the client_id used as iss and sub claims in the JWT
|
|
56
|
+
# @!attribute [r] token_endpoint
|
|
57
|
+
# @return [String] the token endpoint URL used as aud claim in the JWT
|
|
58
|
+
# @!attribute [r] private_key
|
|
59
|
+
# @return [OpenSSL::PKey::RSA, OpenSSL::PKey::EC] the private key for signing the JWT
|
|
60
|
+
# @!attribute [r] kid
|
|
61
|
+
# @return [String] the key ID matching the public key registered with the authorization server
|
|
62
|
+
# @!attribute [r] algorithm
|
|
63
|
+
# @return [String] the signing algorithm (RS384 or ES384)
|
|
64
|
+
# @!attribute [r] jku
|
|
65
|
+
# @return [String, nil] the optional JWKS URL included in the JWT header
|
|
66
|
+
# @!attribute [r] expiration_seconds
|
|
67
|
+
# @return [Integer] the JWT expiration time in seconds (max 300 per SMART spec)
|
|
68
|
+
attr_reader :client_id, :token_endpoint, :private_key, :kid, :algorithm, :jku, :expiration_seconds
|
|
69
|
+
|
|
70
|
+
# Creates a new JWT assertion generator.
|
|
71
|
+
#
|
|
72
|
+
# @param client_id [String] the client_id to use as iss and sub claims
|
|
73
|
+
# @param token_endpoint [String] the token endpoint URL to use as aud claim
|
|
74
|
+
# @param private_key [OpenSSL::PKey::RSA, OpenSSL::PKey::EC, String] the private key for signing
|
|
75
|
+
# (can be a PEM-encoded string)
|
|
76
|
+
# @param kid [String] the key ID matching the registered public key
|
|
77
|
+
# @param algorithm [String, nil] the signing algorithm (auto-detected from key type if nil)
|
|
78
|
+
# @param jku [String, nil] optional JWKS URL for jku header (must be HTTPS)
|
|
79
|
+
# @param expiration_seconds [Integer] expiration time in seconds (default: 300, max: 300)
|
|
80
|
+
#
|
|
81
|
+
# @raise [ArgumentError] if required parameters are missing or invalid
|
|
82
|
+
def initialize(client_id:, token_endpoint:, private_key:, kid:, algorithm: nil, jku: nil,
|
|
83
|
+
expiration_seconds: DEFAULT_EXPIRATION_SECONDS)
|
|
84
|
+
@client_id = client_id
|
|
85
|
+
@token_endpoint = token_endpoint
|
|
86
|
+
@private_key = parse_private_key(private_key)
|
|
87
|
+
@kid = kid
|
|
88
|
+
@algorithm = algorithm || detect_algorithm(@private_key)
|
|
89
|
+
@jku = jku
|
|
90
|
+
@expiration_seconds = [expiration_seconds, MAX_EXPIRATION_SECONDS].min
|
|
91
|
+
|
|
92
|
+
validate!
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Generates a signed JWT assertion.
|
|
96
|
+
#
|
|
97
|
+
# @return [String] the signed JWT string
|
|
98
|
+
def to_jwt
|
|
99
|
+
JWT.encode(payload, private_key, algorithm, header)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Returns the JWT header.
|
|
103
|
+
#
|
|
104
|
+
# @return [Hash] the JWT header with typ, kid, alg, and optional jku
|
|
105
|
+
def header
|
|
106
|
+
h = { typ: 'JWT', kid: kid, alg: algorithm }
|
|
107
|
+
h[:jku] = jku if jku.present?
|
|
108
|
+
h
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Returns the JWT payload.
|
|
112
|
+
#
|
|
113
|
+
# @return [Hash] the JWT payload with iss, sub, aud, exp, and jti claims
|
|
114
|
+
def payload
|
|
115
|
+
now = Time.now.to_i
|
|
116
|
+
{ iss: client_id, sub: client_id, aud: token_endpoint, exp: now + expiration_seconds, jti: generate_jti }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# Parses the private key from various formats.
|
|
122
|
+
#
|
|
123
|
+
# @param key [OpenSSL::PKey::RSA, OpenSSL::PKey::EC, String, nil] the private key
|
|
124
|
+
# @return [OpenSSL::PKey::RSA, OpenSSL::PKey::EC] the parsed private key
|
|
125
|
+
# @raise [ArgumentError] if the key is invalid or unsupported
|
|
126
|
+
def parse_private_key(key)
|
|
127
|
+
case key
|
|
128
|
+
when OpenSSL::PKey::RSA, OpenSSL::PKey::EC
|
|
129
|
+
key
|
|
130
|
+
when String
|
|
131
|
+
parse_pem_key(key)
|
|
132
|
+
else
|
|
133
|
+
raise ArgumentError, 'private_key must be an OpenSSL::PKey::RSA, OpenSSL::PKey::EC, or PEM string'
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Parses a PEM-encoded private key string.
|
|
138
|
+
#
|
|
139
|
+
# @param pem [String] the PEM-encoded private key
|
|
140
|
+
# @return [OpenSSL::PKey::RSA, OpenSSL::PKey::EC] the parsed private key
|
|
141
|
+
# @raise [ArgumentError] if the PEM string is invalid
|
|
142
|
+
def parse_pem_key(pem)
|
|
143
|
+
OpenSSL::PKey.read(pem)
|
|
144
|
+
rescue OpenSSL::PKey::PKeyError => e
|
|
145
|
+
raise ArgumentError, "Invalid private key: #{e.message}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Detects the appropriate signing algorithm based on the key type.
|
|
149
|
+
# For RSA keys, uses RS384 (required by SMART spec).
|
|
150
|
+
# For EC keys, uses ES384 (required by SMART spec) - requires P-384 curve.
|
|
151
|
+
#
|
|
152
|
+
# @param key [OpenSSL::PKey::RSA, OpenSSL::PKey::EC] the private key
|
|
153
|
+
# @return [String] the detected algorithm
|
|
154
|
+
# @raise [ArgumentError] if the key type or EC curve is unsupported
|
|
155
|
+
def detect_algorithm(key)
|
|
156
|
+
case key
|
|
157
|
+
when OpenSSL::PKey::RSA
|
|
158
|
+
DEFAULT_RSA_ALGORITHM
|
|
159
|
+
when OpenSSL::PKey::EC
|
|
160
|
+
validate_ec_curve!(key)
|
|
161
|
+
DEFAULT_EC_ALGORITHM
|
|
162
|
+
else
|
|
163
|
+
raise ArgumentError, "Unsupported key type: #{key.class}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Validates that the EC key uses a supported curve (P-384 for ES384).
|
|
168
|
+
#
|
|
169
|
+
# @param key [OpenSSL::PKey::EC] the EC private key
|
|
170
|
+
# @raise [ArgumentError] if the curve is not supported
|
|
171
|
+
def validate_ec_curve!(key)
|
|
172
|
+
curve = key.group.curve_name
|
|
173
|
+
return if SUPPORTED_EC_CURVES.include?(curve)
|
|
174
|
+
|
|
175
|
+
raise ArgumentError, "Unsupported EC curve: #{curve}. ES384 requires P-384 (secp384r1) curve"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Generates a unique JWT ID (jti) for replay protection.
|
|
179
|
+
#
|
|
180
|
+
# @return [String] a unique UUID identifier
|
|
181
|
+
def generate_jti
|
|
182
|
+
SecureRandom.uuid
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Validates the assertion configuration.
|
|
186
|
+
#
|
|
187
|
+
# @raise [ArgumentError] if validation fails
|
|
188
|
+
def validate!
|
|
189
|
+
validate_required_params!
|
|
190
|
+
validate_algorithm!
|
|
191
|
+
validate_key_algorithm_match!
|
|
192
|
+
validate_jku! if jku.present?
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Validates that required parameters are present.
|
|
196
|
+
#
|
|
197
|
+
# @raise [ArgumentError] if required parameters are missing
|
|
198
|
+
def validate_required_params!
|
|
199
|
+
missing = REQUIRED_PARAMS.select { |param| send(param).blank? }
|
|
200
|
+
return if missing.empty?
|
|
201
|
+
|
|
202
|
+
raise ArgumentError, "Missing required parameters: #{missing.to_sentence}"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Validates the algorithm is supported.
|
|
206
|
+
#
|
|
207
|
+
# @raise [ArgumentError] if the algorithm is not supported
|
|
208
|
+
def validate_algorithm!
|
|
209
|
+
return if SUPPORTED_ALGORITHMS.include?(algorithm)
|
|
210
|
+
|
|
211
|
+
raise ArgumentError, "Unsupported algorithm: #{algorithm}. Supported: #{SUPPORTED_ALGORITHMS.to_sentence}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Validates the key type matches the algorithm.
|
|
215
|
+
#
|
|
216
|
+
# @raise [ArgumentError] if there is a mismatch
|
|
217
|
+
def validate_key_algorithm_match!
|
|
218
|
+
rsa_algorithm = algorithm.start_with?('RS')
|
|
219
|
+
expected_key_class = rsa_algorithm ? OpenSSL::PKey::RSA : OpenSSL::PKey::EC
|
|
220
|
+
return if private_key.is_a?(expected_key_class)
|
|
221
|
+
|
|
222
|
+
raise ArgumentError, "Algorithm #{algorithm} requires an #{rsa_algorithm ? 'RSA' : 'EC'} key"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Validates the jku URL format.
|
|
226
|
+
#
|
|
227
|
+
# @raise [ArgumentError] if the jku is not a valid HTTPS URL
|
|
228
|
+
def validate_jku!
|
|
229
|
+
uri = URI.parse(jku)
|
|
230
|
+
return if uri.scheme == 'https'
|
|
231
|
+
|
|
232
|
+
raise ArgumentError, 'jku must be an HTTPS URL'
|
|
233
|
+
rescue URI::InvalidURIError
|
|
234
|
+
raise ArgumentError, 'jku must be a valid URL'
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|