Class: Safire::Client

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

Overview

Note:

Future kwargs (not yet implemented):

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

When protocol: :udap is fully implemented, client_type: will default to nil (not applicable) and the flow: kwarg will drive B2B vs B2C selection.

Unified facade client for SMART on FHIR and (future) UDAP authorization flows.

This class is the main entry point for integrating SMART on FHIR authorization via Safire. It supports discovery of server metadata and provides a unified interface for building authorization URLs, exchanging authorization codes, refreshing tokens, and requesting backend services access tokens (client_credentials grant).

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

  • :base_url [String] FHIR base URL used for SMART discovery

  • :client_id [String] OAuth2 client identifier

  • :redirect_uri [String] redirect URI registered with the authorization server; required for app launch, not required for backend services

  • :scopes [Array<String>] 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 asymmetric clients and backend services

  • :kid [String, optional] key ID matching the registered public key for asymmetric clients and backend services

  • :jwt_algorithm [String, optional] JWT signing algorithm (RS384 or ES384). Auto-detected if not provided

  • :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 (future; not yet implemented)

The client_type: keyword controls how the SMART client authenticates at the token endpoint:

  • :public (default) — no client authentication; client_id sent in request body

  • :confidential_symmetric — HTTP Basic auth using client_secret

  • :confidential_asymmetric — private_key_jwt assertion (JWT signed with private key)

client_type is validated for :smart and ignored for :udap. 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

See Also:

Constant Summary collapse

VALID_PROTOCOLS =
%i[smart udap].freeze
PROTOCOL_CLASSES =
{
  smart: Protocols::Smart
  # udap: Protocols::Udap  # future
}.freeze
PROTOCOL_CLIENT_TYPES =

Valid client_type values per protocol. nil means the protocol does not use client_type (e.g. UDAP authenticates via signed JWT assertions with an X.509 certificate chain; the authentication method is not user-configurable for UDAP).

{
  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: :public) ⇒ Client

Returns a new instance of Client.



132
133
134
135
136
137
138
139
# File 'lib/safire/client.rb', line 132

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

  validate_protocol!
  validate_client_type!
end

Instance Attribute Details

#client_typeSymbol

Returns the client authentication method (:public, :confidential_symmetric, or :confidential_asymmetric).

Returns:

  • (Symbol)

    the client authentication method (:public, :confidential_symmetric, or :confidential_asymmetric)



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
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
# File 'lib/safire/client.rb', line 105

class Client
  extend Forwardable

  VALID_PROTOCOLS = %i[smart udap].freeze

  PROTOCOL_CLASSES = {
    smart: Protocols::Smart
    # udap: Protocols::Udap  # future
  }.freeze

  # Valid client_type values per protocol.
  # nil means the protocol does not use client_type (e.g. UDAP authenticates via signed
  # JWT assertions with an X.509 certificate chain; the authentication method is not
  # user-configurable for UDAP).
  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

  attr_reader :config, :protocol, :client_type

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

    validate_protocol!
    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)
    if PROTOCOL_CLIENT_TYPES[@protocol].nil?
      Safire.logger.warn(
        "client_type is not configurable for protocol: :#{@protocol}; " \
        'UDAP clients authenticate via signed JWT assertions — ignoring'
      )
      return
    end

    @client_type = new_client_type.to_sym
    validate_client_type!
    @protocol_client&.client_type = @client_type
  end

  private

  def protocol_client
    @protocol_client ||= PROTOCOL_CLASSES.fetch(@protocol).new(config, client_type:)
  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 validate_client_type!
    valid_types = PROTOCOL_CLIENT_TYPES[@protocol]
    return if valid_types.nil? || valid_types.include?(@client_type)

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

#configSafire::ClientConfig (readonly)

Returns the resolved client configuration.

Returns:



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
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
# File 'lib/safire/client.rb', line 105

class Client
  extend Forwardable

  VALID_PROTOCOLS = %i[smart udap].freeze

  PROTOCOL_CLASSES = {
    smart: Protocols::Smart
    # udap: Protocols::Udap  # future
  }.freeze

  # Valid client_type values per protocol.
  # nil means the protocol does not use client_type (e.g. UDAP authenticates via signed
  # JWT assertions with an X.509 certificate chain; the authentication method is not
  # user-configurable for UDAP).
  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

  attr_reader :config, :protocol, :client_type

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

    validate_protocol!
    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)
    if PROTOCOL_CLIENT_TYPES[@protocol].nil?
      Safire.logger.warn(
        "client_type is not configurable for protocol: :#{@protocol}; " \
        'UDAP clients authenticate via signed JWT assertions — ignoring'
      )
      return
    end

    @client_type = new_client_type.to_sym
    validate_client_type!
    @protocol_client&.client_type = @client_type
  end

  private

  def protocol_client
    @protocol_client ||= PROTOCOL_CLASSES.fetch(@protocol).new(config, client_type:)
  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 validate_client_type!
    valid_types = PROTOCOL_CLIENT_TYPES[@protocol]
    return if valid_types.nil? || valid_types.include?(@client_type)

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

#protocolSymbol (readonly)

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

Returns:

  • (Symbol)

    the selected protocol (:smart or :udap)



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
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
# File 'lib/safire/client.rb', line 105

class Client
  extend Forwardable

  VALID_PROTOCOLS = %i[smart udap].freeze

  PROTOCOL_CLASSES = {
    smart: Protocols::Smart
    # udap: Protocols::Udap  # future
  }.freeze

  # Valid client_type values per protocol.
  # nil means the protocol does not use client_type (e.g. UDAP authenticates via signed
  # JWT assertions with an X.509 certificate chain; the authentication method is not
  # user-configurable for UDAP).
  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

  attr_reader :config, :protocol, :client_type

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

    validate_protocol!
    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)
    if PROTOCOL_CLIENT_TYPES[@protocol].nil?
      Safire.logger.warn(
        "client_type is not configurable for protocol: :#{@protocol}; " \
        'UDAP clients authenticate via signed JWT assertions — ignoring'
      )
      return
    end

    @client_type = new_client_type.to_sym
    validate_client_type!
    @protocol_client&.client_type = @client_type
  end

  private

  def protocol_client
    @protocol_client ||= PROTOCOL_CLASSES.fetch(@protocol).new(config, client_type:)
  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 validate_client_type!
    valid_types = PROTOCOL_CLIENT_TYPES[@protocol]
    return if valid_types.nil? || valid_types.include?(@client_type)

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