Class: Safire::Client

Inherits:
Object
  • Object
show all
Extended by:
Forwardable
Defined in:
lib/safire/client.rb

Overview

Note:

Future kwargs (not yet implemented for UDAP):

flow: [Symbol] the authorization flow for UDAP clients (protocol: :udap):

:b2b          — client_credentials grant, server-to-server
:b2c          — authorization_code grant, user-facing
:tiered_oauth — authorization_code + IdP identity delegation

Unified facade client for SMART and UDAP protocol support.

This class is the main entry point for integrating SMART authorization and UDAP discovery or registration via Safire. It supports discovery of server metadata and provides a unified interface for building authorization URLs, exchanging authorization codes, refreshing tokens, requesting backend services access tokens, and registering clients when the selected protocol implements Dynamic Client Registration.

Configuration is provided via ClientConfig or a Hash. Key attributes:

  • :base_url [String] FHIR base URL used for SMART discovery
  • :client_id [String, nil] OAuth2 client identifier — optional at initialization; required by all authorization flows and validated at call time
  • :redirect_uri [String] redirect URI registered with the authorization server; required for app launch, not required for backend services
  • :scopes [Array] default scopes; falls back to ["system/*.rs"] for backend services when not provided
  • :client_secret [String, optional] required for confidential_symmetric clients
  • :private_key [OpenSSL::PKey, String, optional] private key for SMART asymmetric clients, backend services, and UDAP software-statement signing
  • :certificate_chain [Array<String, OpenSSL::X509::Certificate>, optional] leaf-first, issuer-ordered X.509 certificate chain for UDAP software-statement signing
  • :kid [String, optional] key ID matching the registered public key for asymmetric clients and backend services
  • :jwt_algorithm [String, optional] JWT signing algorithm. SMART supports RS384 or ES384; UDAP software-statement signing supports RS256, RS384, ES256, or ES384 subject to key compatibility and server discovery. Selected automatically when omitted
  • :jwks_uri [String, optional] URL to client's JWKS for jku header in JWT assertions

The protocol: keyword selects the authorization protocol:

  • :smart (default) — SMART App Launch 2.2.0
  • :udap — UDAP Security STU2

The client_type: keyword controls how the SMART client authenticates at the token endpoint. Defaults to nil, which resolves to :public for SMART. For UDAP, client_type: is not applicable — passing any explicit value raises ConfigurationError.

  • :public — no client authentication; client_id sent in request body (SMART default)
  • :confidential_symmetric — HTTP Basic auth using client_secret
  • :confidential_asymmetric — private_key_jwt assertion (JWT signed with private key)

UDAP clients authenticate via signed JWT assertions (Authentication Token / AnT) with an X.509 certificate chain in the x5c JOSE header; the authentication method is not user-configurable for UDAP. DCR is typically performed once to obtain a client_id, which is then reused as iss/sub in every subsequent AnT. The unregistered client flow (§8.1) allows client_credentials grant without prior DCR when identity can be fully determined from certificate attributes alone.

Examples:

Step 0 – Initialize configuration

config = Safire::ClientConfig.new(
  base_url: 'https://fhir.example.com',
  client_id: 'my_client_id',
  redirect_uri: 'https://myapp.example.com/callback',
  scopes: ['openid', 'profile', 'patient/*.read']
)

Step 1 – /launch route (authorization request)

client = Safire::Client.new(config)  # defaults to protocol: :smart, client_type: :public
auth_data = client.authorization_url

session[:state] = auth_data[:state]
session[:code_verifier] = auth_data[:code_verifier]

redirect_to auth_data[:auth_url]

Step 2 – /callback route (token exchange)

return head :unauthorized unless params[:state] == session[:state]

client = Safire::Client.new(config)
token_data = client.request_access_token(
  code: params[:code],
  code_verifier: session[:code_verifier]
)

Step 3 – Refreshing an access token

client = Safire::Client.new(config)
new_tokens = client.refresh_token(refresh_token: stored_refresh_token)

Backend Services – system-to-system access token (client_credentials grant)

config = Safire::ClientConfig.new(
  base_url:   'https://fhir.example.com',
  client_id:  'my_client_id',
  private_key: OpenSSL::PKey::RSA.new(File.read('private_key.pem')),
  kid:        'my-key-id',
  scopes:     ['system/Patient.rs']
)
client = Safire::Client.new(config, client_type: :confidential_asymmetric)
token_data = client.request_backend_token

SMART Dynamic Client Registration – obtain a client_id before authorization flows

# Step 1 – create a temporary client (no client_id required)
temp_client = Safire::Client.new({ base_url: 'https://fhir.example.com' })

# Step 2 – register and receive credentials
registration = temp_client.register_client(
  {
    client_name:                'My FHIR App',
    redirect_uris:              ['https://myapp.example.com/callback'],
    grant_types:                ['authorization_code'],
    token_endpoint_auth_method: 'private_key_jwt',
    jwks_uri:                   'https://myapp.example.com/.well-known/jwks.json'
  }
)

# Step 3 – persist credentials durably (database, secrets manager, etc.)
client_id = registration['client_id']

# Step 4 – build a properly configured client for subsequent authorization flows
client = Safire::Client.new(
  {
    base_url:     'https://fhir.example.com',
    client_id:    client_id,
    redirect_uri: 'https://myapp.example.com/callback',
    scopes:       ['openid', 'profile', 'patient/*.read']
  }
)

UDAP Dynamic Client Registration – signed STU2 registration

temp_client = Safire::Client.new(
  {
    base_url: 'https://fhir.example.com',
    private_key: File.read('client-key.pem'),
    certificate_chain: [
      File.read('client-cert.pem'),
      File.read('issuing-ca.pem')
    ]
  },
  protocol: :udap
)

registration = temp_client.register_client(
  {
    client_name: 'Example Backend Service',
    contacts: ['mailto:security@example.com'],
    grant_types: ['client_credentials'],
    scope: 'system/Patient.rs system/Observation.rs'
  },
  client_uri: 'https://client.example.com',
  trusted_anchors: [udap_ca],
  crls: [udap_crl]
)

UDAP registration cancellation – signed STU2 cancellation

cancellation = temp_client.cancel_registration(
  {
    client_name: 'Example Backend Service',
    contacts: ['mailto:security@example.com'],
    scope: 'system/Patient.rs system/Observation.rs'
  },
  client_uri: 'https://client.example.com',
  trusted_anchors: [udap_ca],
  crls: [udap_crl]
)
cancellation['grant_types'] # => []

See Also:

Constant Summary collapse

VALID_PROTOCOLS =
%i[smart udap].freeze
PROTOCOL_CLIENT_TYPES =

Valid client_type values per protocol. nil means client_type is not applicable for that protocol; any explicit value raises ConfigurationError.

{
  smart: %i[public confidential_symmetric confidential_asymmetric],
  udap: nil # UDAP authenticates via signed JWT assertions (AnT) with X.509 certificate chain
}.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(config, protocol: :smart, client_type: nil) ⇒ Client

Returns a new instance of Client.



199
200
201
202
203
204
205
206
207
# File 'lib/safire/client.rb', line 199

def initialize(config, protocol: :smart, client_type: nil)
  @protocol    = protocol.to_sym
  @client_type = normalize_client_type(client_type)
  @config      = build_config(config)

  validate_protocol!
  resolve_client_type!
  validate_client_type!
end

Instance Attribute Details

#client_typeSymbol?

Returns the SMART client authentication method (:public, :confidential_symmetric, or :confidential_asymmetric); nil for protocol: :udap where client authentication is not user-configurable.

Returns:

  • (Symbol, nil)

    the SMART client authentication method (:public, :confidential_symmetric, or :confidential_asymmetric); nil for protocol: :udap where client authentication is not user-configurable



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'lib/safire/client.rb', line 178

class Client
  extend Forwardable

  VALID_PROTOCOLS = %i[smart udap].freeze

  # Valid client_type values per protocol.
  # nil means client_type is not applicable for that protocol; any explicit value raises ConfigurationError.
  PROTOCOL_CLIENT_TYPES = {
    smart: %i[public confidential_symmetric confidential_asymmetric],
    udap: nil # UDAP authenticates via signed JWT assertions (AnT) with X.509 certificate chain
  }.freeze

  def_delegators :protocol_client,
                 :server_metadata, :authorization_url,
                 :request_access_token, :refresh_token,
                 :request_backend_token,
                 :token_response_valid?, :register_client,
                 :cancel_registration

  attr_reader :config, :protocol, :client_type

  def initialize(config, protocol: :smart, client_type: nil)
    @protocol    = protocol.to_sym
    @client_type = normalize_client_type(client_type)
    @config      = build_config(config)

    validate_protocol!
    resolve_client_type!
    validate_client_type!
  end

  # Changes the client type for this client.
  #
  # Updates the underlying protocol client in place — server metadata already
  # fetched is preserved and no re-discovery occurs.
  #
  # @param new_client_type [Symbol, String] the new client type
  # @return [Symbol] the new client type
  # @raise [Safire::Errors::ConfigurationError] if the client type is not valid for this protocol
  #
  # @example Discover then switch client type
  #   client = Safire::Client.new(config)  # defaults to :public
  #   metadata = client.server_metadata
  #
  #   if metadata.supports_symmetric_auth?
  #     client.client_type = :confidential_symmetric
  #   end
  def client_type=(new_client_type)
    raise_client_type_not_applicable!(new_client_type) if PROTOCOL_CLIENT_TYPES[@protocol].nil?

    @client_type = normalize_client_type(new_client_type)
    validate_client_type!
    @protocol_client&.client_type = @client_type
  end

  private

  def protocol_client
    @protocol_client ||= build_protocol_client
  end

  def build_protocol_client
    case @protocol
    when :smart then Protocols::Smart.new(config, client_type:)
    when :udap  then Protocols::Udap.new(config)
    end
  end

  def build_config(config)
    return config if config.is_a?(Safire::ClientConfig)

    Safire::ClientConfig.new(config)
  end

  def validate_protocol!
    return if VALID_PROTOCOLS.include?(@protocol)

    raise Errors::ConfigurationError.new(
      invalid_attribute: :protocol,
      invalid_value: @protocol,
      valid_values: VALID_PROTOCOLS
    )
  end

  def resolve_client_type!
    @client_type = :public if @protocol == :smart && @client_type.nil?
  end

  def validate_client_type!
    valid_types = PROTOCOL_CLIENT_TYPES[@protocol]
    if valid_types.nil?
      return if @client_type.nil?

      raise_client_type_not_applicable!(@client_type)
    end
    return if valid_types.include?(@client_type)

    raise Errors::ConfigurationError.new(
      invalid_attribute: :client_type,
      invalid_value: @client_type,
      valid_values: valid_types
    )
  end

  def raise_client_type_not_applicable!(value)
    raise Errors::ConfigurationError.new(
      invalid_attribute: :client_type,
      invalid_value: value,
      valid_values: ["N/A (client_type is not applicable for protocol :#{@protocol})"]
    )
  end

  def normalize_client_type(value)
    return nil if value.nil?
    return value.to_sym if value.is_a?(Symbol) || value.is_a?(String)

    raise Errors::ConfigurationError.new(
      invalid_attribute: :client_type,
      invalid_value: value,
      valid_values: PROTOCOL_CLIENT_TYPES[:smart]
    )
  end
end

#configSafire::ClientConfig (readonly)

Returns the resolved client configuration.

Returns:



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'lib/safire/client.rb', line 178

class Client
  extend Forwardable

  VALID_PROTOCOLS = %i[smart udap].freeze

  # Valid client_type values per protocol.
  # nil means client_type is not applicable for that protocol; any explicit value raises ConfigurationError.
  PROTOCOL_CLIENT_TYPES = {
    smart: %i[public confidential_symmetric confidential_asymmetric],
    udap: nil # UDAP authenticates via signed JWT assertions (AnT) with X.509 certificate chain
  }.freeze

  def_delegators :protocol_client,
                 :server_metadata, :authorization_url,
                 :request_access_token, :refresh_token,
                 :request_backend_token,
                 :token_response_valid?, :register_client,
                 :cancel_registration

  attr_reader :config, :protocol, :client_type

  def initialize(config, protocol: :smart, client_type: nil)
    @protocol    = protocol.to_sym
    @client_type = normalize_client_type(client_type)
    @config      = build_config(config)

    validate_protocol!
    resolve_client_type!
    validate_client_type!
  end

  # Changes the client type for this client.
  #
  # Updates the underlying protocol client in place — server metadata already
  # fetched is preserved and no re-discovery occurs.
  #
  # @param new_client_type [Symbol, String] the new client type
  # @return [Symbol] the new client type
  # @raise [Safire::Errors::ConfigurationError] if the client type is not valid for this protocol
  #
  # @example Discover then switch client type
  #   client = Safire::Client.new(config)  # defaults to :public
  #   metadata = client.server_metadata
  #
  #   if metadata.supports_symmetric_auth?
  #     client.client_type = :confidential_symmetric
  #   end
  def client_type=(new_client_type)
    raise_client_type_not_applicable!(new_client_type) if PROTOCOL_CLIENT_TYPES[@protocol].nil?

    @client_type = normalize_client_type(new_client_type)
    validate_client_type!
    @protocol_client&.client_type = @client_type
  end

  private

  def protocol_client
    @protocol_client ||= build_protocol_client
  end

  def build_protocol_client
    case @protocol
    when :smart then Protocols::Smart.new(config, client_type:)
    when :udap  then Protocols::Udap.new(config)
    end
  end

  def build_config(config)
    return config if config.is_a?(Safire::ClientConfig)

    Safire::ClientConfig.new(config)
  end

  def validate_protocol!
    return if VALID_PROTOCOLS.include?(@protocol)

    raise Errors::ConfigurationError.new(
      invalid_attribute: :protocol,
      invalid_value: @protocol,
      valid_values: VALID_PROTOCOLS
    )
  end

  def resolve_client_type!
    @client_type = :public if @protocol == :smart && @client_type.nil?
  end

  def validate_client_type!
    valid_types = PROTOCOL_CLIENT_TYPES[@protocol]
    if valid_types.nil?
      return if @client_type.nil?

      raise_client_type_not_applicable!(@client_type)
    end
    return if valid_types.include?(@client_type)

    raise Errors::ConfigurationError.new(
      invalid_attribute: :client_type,
      invalid_value: @client_type,
      valid_values: valid_types
    )
  end

  def raise_client_type_not_applicable!(value)
    raise Errors::ConfigurationError.new(
      invalid_attribute: :client_type,
      invalid_value: value,
      valid_values: ["N/A (client_type is not applicable for protocol :#{@protocol})"]
    )
  end

  def normalize_client_type(value)
    return nil if value.nil?
    return value.to_sym if value.is_a?(Symbol) || value.is_a?(String)

    raise Errors::ConfigurationError.new(
      invalid_attribute: :client_type,
      invalid_value: value,
      valid_values: PROTOCOL_CLIENT_TYPES[:smart]
    )
  end
end

#protocolSymbol (readonly)

Returns the selected protocol (:smart or :udap).

Returns:

  • (Symbol)

    the selected protocol (:smart or :udap)



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'lib/safire/client.rb', line 178

class Client
  extend Forwardable

  VALID_PROTOCOLS = %i[smart udap].freeze

  # Valid client_type values per protocol.
  # nil means client_type is not applicable for that protocol; any explicit value raises ConfigurationError.
  PROTOCOL_CLIENT_TYPES = {
    smart: %i[public confidential_symmetric confidential_asymmetric],
    udap: nil # UDAP authenticates via signed JWT assertions (AnT) with X.509 certificate chain
  }.freeze

  def_delegators :protocol_client,
                 :server_metadata, :authorization_url,
                 :request_access_token, :refresh_token,
                 :request_backend_token,
                 :token_response_valid?, :register_client,
                 :cancel_registration

  attr_reader :config, :protocol, :client_type

  def initialize(config, protocol: :smart, client_type: nil)
    @protocol    = protocol.to_sym
    @client_type = normalize_client_type(client_type)
    @config      = build_config(config)

    validate_protocol!
    resolve_client_type!
    validate_client_type!
  end

  # Changes the client type for this client.
  #
  # Updates the underlying protocol client in place — server metadata already
  # fetched is preserved and no re-discovery occurs.
  #
  # @param new_client_type [Symbol, String] the new client type
  # @return [Symbol] the new client type
  # @raise [Safire::Errors::ConfigurationError] if the client type is not valid for this protocol
  #
  # @example Discover then switch client type
  #   client = Safire::Client.new(config)  # defaults to :public
  #   metadata = client.server_metadata
  #
  #   if metadata.supports_symmetric_auth?
  #     client.client_type = :confidential_symmetric
  #   end
  def client_type=(new_client_type)
    raise_client_type_not_applicable!(new_client_type) if PROTOCOL_CLIENT_TYPES[@protocol].nil?

    @client_type = normalize_client_type(new_client_type)
    validate_client_type!
    @protocol_client&.client_type = @client_type
  end

  private

  def protocol_client
    @protocol_client ||= build_protocol_client
  end

  def build_protocol_client
    case @protocol
    when :smart then Protocols::Smart.new(config, client_type:)
    when :udap  then Protocols::Udap.new(config)
    end
  end

  def build_config(config)
    return config if config.is_a?(Safire::ClientConfig)

    Safire::ClientConfig.new(config)
  end

  def validate_protocol!
    return if VALID_PROTOCOLS.include?(@protocol)

    raise Errors::ConfigurationError.new(
      invalid_attribute: :protocol,
      invalid_value: @protocol,
      valid_values: VALID_PROTOCOLS
    )
  end

  def resolve_client_type!
    @client_type = :public if @protocol == :smart && @client_type.nil?
  end

  def validate_client_type!
    valid_types = PROTOCOL_CLIENT_TYPES[@protocol]
    if valid_types.nil?
      return if @client_type.nil?

      raise_client_type_not_applicable!(@client_type)
    end
    return if valid_types.include?(@client_type)

    raise Errors::ConfigurationError.new(
      invalid_attribute: :client_type,
      invalid_value: @client_type,
      valid_values: valid_types
    )
  end

  def raise_client_type_not_applicable!(value)
    raise Errors::ConfigurationError.new(
      invalid_attribute: :client_type,
      invalid_value: value,
      valid_values: ["N/A (client_type is not applicable for protocol :#{@protocol})"]
    )
  end

  def normalize_client_type(value)
    return nil if value.nil?
    return value.to_sym if value.is_a?(Symbol) || value.is_a?(String)

    raise Errors::ConfigurationError.new(
      invalid_attribute: :client_type,
      invalid_value: value,
      valid_values: PROTOCOL_CLIENT_TYPES[:smart]
    )
  end
end