Skip to content

Commit

Permalink
FI-2865: FHIR Client Auth Info Refresh (#513)
Browse files Browse the repository at this point in the history
* Added support for auth_info to fhir_client

Signed-off-by: Vanessa Fotso <[email protected]>

* Added unit test for auth info

Signed-off-by: Vanessa Fotso <[email protected]>

* WIP:perform refresh using auth_info

Signed-off-by: Vanessa Fotso <[email protected]>

* support for backend services authentication using auth_info

Signed-off-by: Vanessa Fotso <[email protected]>

* WIP auth info unit test

Signed-off-by: Vanessa Fotso <[email protected]>

* Updated auth_info_spec

Signed-off-by: Vanessa Fotso <[email protected]>

* Extracted the auth info sample data to its own fixture file"

Signed-off-by: Vanessa Fotso <[email protected]>

* Updated fhir_client_spec

Signed-off-by: Vanessa Fotso <[email protected]>

* Freeze constants

Signed-off-by: Vanessa Fotso <[email protected]>

* Updated ffhir client spec

Signed-off-by: Vanessa Fotso <[email protected]>

* Update based on PR comments

Signed-off-by: Vanessa Fotso <[email protected]>

* Update to return  for  and

Signed-off-by: Vanessa Fotso <[email protected]>

* Update ENV name for the inferno core jwks path

Co-authored-by: Stephen MacVicar <[email protected]>

* Added doc to JWKS class

Signed-off-by: Vanessa Fotso <[email protected]>

* proofread JWKs doc

Co-authored-by: Stephen MacVicar <[email protected]>

* extract reading content of jwks file into its own method

Signed-off-by: Vanessa Fotso <[email protected]>

---------

Signed-off-by: Vanessa Fotso <[email protected]>
Co-authored-by: Stephen MacVicar <[email protected]>
  • Loading branch information
vanessuniq and Jammjammjamm committed Jul 19, 2024
1 parent 6eef3b3 commit 3e96653
Show file tree
Hide file tree
Showing 8 changed files with 812 additions and 46 deletions.
136 changes: 136 additions & 0 deletions lib/inferno/dsl/auth_info.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require_relative '../entities/attributes'
require_relative 'jwks'

module Inferno
module DSL
Expand Down Expand Up @@ -168,6 +169,141 @@ def add_to_client(client)

client.set_bearer_token(access_token)
end

# @private
def need_to_refresh?
return false if access_token.blank? || (!backend_services? && refresh_token.blank?)

return true if expires_in.blank?

issue_time.to_i + expires_in.to_i - DateTime.now.to_i < 60
end

# @private
def able_to_refresh?
token_url.present? && (backend_services? || refresh_token.present?)
end

# @private
def backend_services?
auth_type == 'backend_services'
end

# @private
def oauth2_refresh_params
case auth_type
when 'public'
public_auth_refresh_params
when 'symmetric'
symmetric_auth_refresh_params
when 'asymmetric'
asymmetric_auth_refresh_params
when 'backend_services'
backend_services_auth_refresh_params
end
end

# @private
def symmetric_auth_refresh_params
{
'grant_type' => 'refresh_token',
'refresh_token' => refresh_token
}
end

# @private
def public_auth_refresh_params
symmetric_auth_refresh_params.merge('client_id' => client_id)
end

# @private
def asymmetric_auth_refresh_params
symmetric_auth_refresh_params.merge(
'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
'client_assertion' => client_assertion
)
end

# @private
def backend_services_auth_refresh_params
{
'grant_type' => 'client_credentials',
'scope' => requested_scopes,
'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
'client_assertion' => client_assertion
}
end

# @private
def oauth2_refresh_headers
base_headers = { 'Content-Type' => 'application/x-www-form-urlencoded' }

return base_headers unless auth_type == 'symmetric'

credentials = "#{client_id}:#{client_secret}"

base_headers.merge(
'Authorization' => "Basic #{Base64.strict_encode64(credentials)}"
)
end

# @private
def private_key
@private_key ||= JWKS.jwks(user_jwks: jwks)
.select { |key| key[:key_ops]&.include?('sign') }
.select { |key| key[:alg] == encryption_algorithm }
.find { |key| !kid || key[:kid] == kid }
end

# @private
def signing_key
if private_key.nil?
raise Inferno::Exceptions::AssertionException,
"No signing key found for inputs: encryption method = '#{encryption_algorithm}' and kid = '#{kid}'"
end

@private_key.signing_key
end

# @private
def auth_jwt_header
{
'alg' => encryption_algorithm,
'kid' => private_key['kid'],
'typ' => 'JWT'
}
end

# @private
def auth_jwt_claims
{
'iss' => client_id,
'sub' => client_id,
'aud' => token_url,
'exp' => 5.minutes.from_now.to_i,
'jti' => SecureRandom.hex(32)
}
end

# @private
def client_assertion
JWT.encode auth_jwt_claims, signing_key, encryption_algorithm, auth_jwt_header
end

# @private
def update_from_response_body(request)
token_response_body = JSON.parse(request.response_body)

expires_in = token_response_body['expires_in'].is_a?(Numeric) ? token_response_body['expires_in'] : nil

self.access_token = token_response_body['access_token']
self.refresh_token = token_response_body['refresh_token'] if token_response_body['refresh_token'].present?
self.expires_in = expires_in
self.issue_time = DateTime.now

add_to_client(client)
self
end
end
end
end
4 changes: 2 additions & 2 deletions lib/inferno/dsl/fhir_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ def store_request_and_refresh_token(client, name, tags, &block)

# @private
def perform_refresh(client)
credentials = client.oauth_credentials
credentials = client.auth_info || client.oauth_credentials

post(
credentials.token_url,
Expand All @@ -363,7 +363,7 @@ def perform_refresh(client)
Inferno::Repositories::SessionData.new.save(
name: credentials.name,
value: credentials,
type: 'oauth_credentials',
type: credentials.is_a?(Inferno::DSL::AuthInfo) ? 'auth_info' : 'oauth_credentials',
test_session_id:
)
end
Expand Down
59 changes: 59 additions & 0 deletions lib/inferno/dsl/jwks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"keys": [
{
"kty": "EC",
"crv": "P-384",
"x": "JQKTsV6PT5Szf4QtDA1qrs0EJ1pbimQmM2SKvzOlIAqlph3h1OHmZ2i7MXahIF2C",
"y": "bRWWQRJBgDa6CTgwofYrHjVGcO-A7WNEnu4oJA5OUJPPPpczgx1g2NsfinK-D2Rw",
"use": "sig",
"key_ops": [
"verify"
],
"ext": true,
"kid": "4b49a739d1eb115b3225f4cf9beb6d1b",
"alg": "ES384"
},
{
"kty": "EC",
"crv": "P-384",
"d": "kDkn55p7gryKk2tj6z2ij7ExUnhi0ngxXosvqa73y7epwgthFqaJwApmiXXU2yhK",
"x": "JQKTsV6PT5Szf4QtDA1qrs0EJ1pbimQmM2SKvzOlIAqlph3h1OHmZ2i7MXahIF2C",
"y": "bRWWQRJBgDa6CTgwofYrHjVGcO-A7WNEnu4oJA5OUJPPPpczgx1g2NsfinK-D2Rw",
"key_ops": [
"sign"
],
"ext": true,
"kid": "4b49a739d1eb115b3225f4cf9beb6d1b",
"alg": "ES384"
},
{
"kty": "RSA",
"alg": "RS384",
"n": "vjbIzTqiY8K8zApeNng5ekNNIxJfXAue9BjoMrZ9Qy9m7yIA-tf6muEupEXWhq70tC7vIGLqJJ4O8m7yiH8H2qklX2mCAMg3xG3nbykY2X7JXtW9P8VIdG0sAMt5aZQnUGCgSS3n0qaooGn2LUlTGIR88Qi-4Nrao9_3Ki3UCiICeCiAE224jGCg0OlQU6qj2gEB3o-DWJFlG_dz1y-Mxo5ivaeM0vWuodjDrp-aiabJcSF_dx26sdC9dZdBKXFDq0t19I9S9AyGpGDJwzGRtWHY6LsskNHLvo8Zb5AsJ9eRZKpnh30SYBZI9WHtzU85M9WQqdScR69Vyp-6Uhfbvw",
"e": "AQAB",
"use": "sig",
"key_ops": [
"verify"
],
"ext": true,
"kid": "b41528b6f37a9500edb8a905a595bdd7"
},
{
"kty": "RSA",
"alg": "RS384",
"n": "vjbIzTqiY8K8zApeNng5ekNNIxJfXAue9BjoMrZ9Qy9m7yIA-tf6muEupEXWhq70tC7vIGLqJJ4O8m7yiH8H2qklX2mCAMg3xG3nbykY2X7JXtW9P8VIdG0sAMt5aZQnUGCgSS3n0qaooGn2LUlTGIR88Qi-4Nrao9_3Ki3UCiICeCiAE224jGCg0OlQU6qj2gEB3o-DWJFlG_dz1y-Mxo5ivaeM0vWuodjDrp-aiabJcSF_dx26sdC9dZdBKXFDq0t19I9S9AyGpGDJwzGRtWHY6LsskNHLvo8Zb5AsJ9eRZKpnh30SYBZI9WHtzU85M9WQqdScR69Vyp-6Uhfbvw",
"e": "AQAB",
"d": "rriV9GYimi5by7TOW4xNh6_gYBHVRDBsft2OFF8qapdVHt2GNuRDDxc_B6ga6TY2Enh2MLKLTr1dD3W4FIdTCJiMerrorp07FJS7nJEMgWQDxrfgkX4_EqrhW42L5d4vypYnRXEEW6u4gzkx5uFOkdvJBIK7CsIdSaBFYhochnynNgvbKWasi4rl2hayEH8tdf3B7Z6OIH9alspBTaq3j_zJt_KkrpYEzIUb4UgALB5NTWn5YKr0Avk_asOg8YfjViQwO9ASGaWjQeJ2Rx8OEQwBMQHSDMCSWNiWmYOu9PcwSZFc1vLxqzyIM8QrQSJHCCMo_wGYgke_r0CLeONHEQ",
"p": "5hH_QApWGeobRi1n7XbMfJYohB8K3JDPa0MspfplHpJ-17JiGG2sNoBdBcpaPRf9OX48P8VqO0qrSSRAk-I-uO6OO9BHbIukXJILqnY2JmurYzbcYbt5FVbknlHRJojkF6-7sFBazpueUlOnXCw7X7Z_SkfNE4QX5Ejm2Zm5mek",
"q": "06bZz7c7K9s1-aEZsxYnLJ9eTpKlt1tIBDA_LwIh5W3w259pes2kUtimbnkyOf-V2ZIERsFCh5s-S9IOEMvAIa6M5j9GW1ILNT7AcHIUfcyFcH-FF8BU_KJdRP5PXnIXFdYcylvsdoIdchy1AaUIzyiKRCU3HBYI75hez0l_F2c",
"dp": "h_sVIXW6hCCRND48EedIX06k7conMkxIu_39ErDXOWWeoMAnKIcR5TijQnviL__QxD1vQMXezuKIMHfDz2RGbClbWdD1lhtG7wvG515tDPJQXxia0wzqOQmdoFF9S8hXAAT26vPjaAAkaEZXQaxG_4Au5elgNWu6b0wDXZN1Vpk",
"dq": "GqS0YpuUTU8JGmWXUJ4HTGy7eHSpe8134V8ZdRd1oOYYHe2RX64nc25mdR24nuh3uq3Q7_9AGsYGL5E_yAl-JD9O6WUpvDE1y_wcSYty3Os0GRdUb8r8Z9kgmKDS6Pa_xTXw5eBwgfKbNlQ6zPwzgbB-x1lP-K8lbNPni3ybDR0",
"qi": "cqQfoi0sM5Su8ZOhznmdWrDIQB28H6fBKiabgaIKkbWZV4e0nwFvLquHjPOvv4Ao8iEGU5dyhvg0n5BKYPi-4mp6M6OA1sy0NrTr7EsKSYGyu2pBq9rw4oAYTM2LXKg6K-awgUUlkc451IwxHBAe15PWCBM3kvLQeijNid0Vz5I",
"key_ops": [
"sign"
],
"ext": true,
"kid": "b41528b6f37a9500edb8a905a595bdd7"
}
]
}
79 changes: 79 additions & 0 deletions lib/inferno/dsl/jwks.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
module Inferno
module DSL
# The JWKS class provides methods to handle JSON Web Key Sets (JWKS)
# within Inferno.
#
# This class allows users to fetch, parse, and manage JWKS, ensuring
# that the necessary keys for verifying tokens are available.
class JWKS
class << self
# Returns a formatted JSON string of the JWKS public keys that are used for verification.
# This method filters out keys that do not have the 'verify' operation.
#
# @return [String] The formatted JSON string of the JWKS public keys.
#
# @example
# jwks_json = Inferno::JWKS.jwks_json
# puts jwks_json
def jwks_json
@jwks_json ||=
JSON.pretty_generate(
{ keys: jwks.export[:keys].select { |key| key[:key_ops]&.include?('verify') } }
)
end

# Provides the default file path to the JWKS file.
# This method is primarily used internally to locate the default JWKS file.
#
# @return [String] The default JWKS file path.
#
# @private
def default_jwks_path
@default_jwks_path ||= File.join(__dir__, 'jwks.json')
end

# Fetches the JWKS file path from the environment variable `INFERNO_JWKS_PATH`.
# If the environment variable is not set, it falls back to the default path
# provided by `.default_jwks_path`.
#
# @return [String] The JWKS file path.
#
# @private
def jwks_path
@jwks_path ||=
ENV.fetch('INFERNO_JWKS_PATH', default_jwks_path)
end

# Reads the JWKS content from the file located at the JWKS path.
#
# @return [String] The json content of the JWKS file.
#
# @private
def default_jwks_json
@default_jwks_json ||= File.read(jwks_path)
end

# Parses and returns a `JWT::JWK::Set` object from the provided JWKS string
# or from the file located at the JWKS path. If a user-provided JWKS string
# is not available, it reads the JWKS from the file.
#
# @param user_jwks [String, nil] An optional json containing the JWKS.
# If not provided, the method reads from the file.
# @return [JWT::JWK::Set] The parsed JWKS set.
#
# @example
# # Using a user-provided JWKS string
# user_jwks = '{"keys":[...]}'
# jwks_set = Inferno::JWKS.jwks(user_jwks: user_jwks)
#
# # Using the default JWKS file
# jwks_set = Inferno::JWKS.jwks
def jwks(user_jwks: nil)
JWT::JWK::Set.new(JSON.parse(user_jwks.presence || default_jwks_json))
end
end
end
end

JWKS = DSL::JWKS
end
4 changes: 2 additions & 2 deletions lib/inferno/ext/fhir_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ class Client
attr_accessor :oauth_credentials, :auth_info

def need_to_refresh?
oauth_credentials&.need_to_refresh?
!!(auth_info&.need_to_refresh? || oauth_credentials&.need_to_refresh?)
end

def able_to_refresh?
oauth_credentials&.able_to_refresh?
!!(auth_info&.able_to_refresh? || oauth_credentials&.able_to_refresh?)
end
end
end
71 changes: 71 additions & 0 deletions spec/fixtures/auth_info_constants.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
module AuthInfoConstants
AUTH_URL = 'http://example.com/authorization'.freeze
TOKEN_URL = 'http://example.com/token'.freeze
REQUESTED_SCOPES = 'launch/patient openid fhirUser patient/*.*'.freeze
ENCRYPTION_ALGORITHM = 'ES384'.freeze
KID = '4b49a739d1eb115b3225f4cf9beb6d1b'.freeze
JWKS = File.read(File.join('lib', 'inferno', 'dsl', 'jwks.json')).freeze
class << self
def token_info
{
access_token: 'SAMPLE_TOKEN',
refresh_token: 'SAMPLE_REFRESH_TOKEN',
expires_in: '3600',
issue_time: Time.now.iso8601
}
end

def public_access_default
{
auth_type: 'public',
token_url: TOKEN_URL,
client_id: 'SAMPLE_PUBLIC_CLIENT_ID',
requested_scopes: REQUESTED_SCOPES,
pkce_support: 'enabled',
pkce_code_challenge_method: 'S256',
auth_request_method: 'GET'
}.merge(token_info)
end

def symmetric_confidential_access_default
{
auth_type: 'symmetric',
token_url: TOKEN_URL,
client_id: 'SAMPLE_CONFIDENTIAL_CLIENT_ID',
client_secret: 'SAMPLE_CONFIDENTIAL_CLIENT_SECRET',
auth_url: AUTH_URL,
requested_scopes: REQUESTED_SCOPES,
pkce_support: 'enabled',
pkce_code_challenge_method: 'S256',
auth_request_method: 'POST',
use_discovery: 'false'
}.merge(token_info)
end

def asymmetric_confidential_access_default
{
auth_type: 'asymmetric',
token_url: TOKEN_URL,
client_id: 'SAMPLE_CONFIDENTIAL_CLIENT_ID',
requested_scopes: REQUESTED_SCOPES,
pkce_support: 'disabled',
auth_request_method: 'POST',
encryption_algorithm: ENCRYPTION_ALGORITHM,
jwks: JWKS,
kid: KID
}.merge(token_info)
end

def backend_services_access_default
{
auth_type: 'backend_services',
token_url: TOKEN_URL,
client_id: 'SAMPLE_CONFIDENTIAL_CLIENT_ID',
requested_scopes: REQUESTED_SCOPES,
encryption_algorithm: ENCRYPTION_ALGORITHM,
jwks: JWKS,
kid: KID
}.merge(token_info)
end
end
end
Loading

0 comments on commit 3e96653

Please sign in to comment.