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,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