Skip to content

Commit

Permalink
Merge branch 'main' into fi-3336-us-core-tests
Browse files Browse the repository at this point in the history
  • Loading branch information
elsaperelli authored Nov 8, 2024
2 parents 314ddd8 + 355816d commit 755f922
Show file tree
Hide file tree
Showing 10 changed files with 205 additions and 41 deletions.
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# v0.13.0
* Fix wording in SMART App Launch description by @tstrass in https://github.com/inferno-framework/davinci-dtr-test-kit/pull/18
* FI-3069: Full EHR QuestionnaireResponse verification by @tstrass in https://github.com/inferno-framework/davinci-dtr-test-kit/pull/19
* Fi-3231: Add SMART app tests to Light DTR EHR suite by @elsaperelli in https://github.com/inferno-framework/davinci-dtr-test-kit/pull/21
* FI-3270: CI, Rubocop by @tstrass in https://github.com/inferno-framework/davinci-dtr-test-kit/pull/23
* FI-3161: ID Token by @tstrass in https://github.com/inferno-framework/davinci-dtr-test-kit/pull/24
* FI-3410: Update inferno core requirement by @Jammjammjamm in https://github.com/inferno-framework/davinci-dtr-test-kit/pull/25

# v0.12.0
* rm extra assert by @rpassas in https://github.com/inferno-framework/davinci-dtr-test-kit/pull/12
* Full ehr tests by @karlnaden in https://github.com/inferno-framework/davinci-dtr-test-kit/pull/15
* FI-3094: Example JWT fix by @tstrass in https://github.com/inferno-framework/davinci-dtr-test-kit/pull/16
* Release 0.12.0 by @karlnaden in https://github.com/inferno-framework/davinci-dtr-test-kit/pull/17

# v0.11.1
* Pre connectathon fixes by @karlnaden in https://github.com/inferno-framework/davinci-dtr-test-kit/pull/14

# v0.11.0
* FI-2732: Add SMART App Launch capability by @tstrass in https://github.com/inferno-framework/davinci-dtr-test-kit/pull/9

# v0.10.0
* Postman Tweaks by @tstrass in https://github.com/inferno-framework/davinci-dtr-test-kit/pull/5
* Fi 2821 payer spec tests by @rpassas in https://github.com/inferno-framework/davinci-dtr-test-kit/pull/7
* migrate to new validator by @karlnaden in https://github.com/inferno-framework/davinci-dtr-test-kit/pull/6
* fix validator URL for spec tests by @rpassas in https://github.com/inferno-framework/davinci-dtr-test-kit/pull/10
* Dependency Updates 2024-07-03 by @Jammjammjamm in https://github.com/inferno-framework/davinci-dtr-test-kit/pull/8


# v0.9.0

* Initial public release.
8 changes: 4 additions & 4 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
PATH
remote: .
specs:
davinci_dtr_test_kit (0.12.0)
inferno_core (~> 0.4.42)
davinci_dtr_test_kit (0.13.0)
inferno_core (~> 0.5.0)
jwt (~> 2.6)
smart_app_launch_test_kit (~> 0.4.4)
tls_test_kit (= 0.2.2)
Expand Down Expand Up @@ -141,7 +141,7 @@ GEM
httpclient (2.8.3)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
inferno_core (0.4.44)
inferno_core (0.5.0)
activesupport (~> 6.1.7.5)
base62-rb (= 0.3.1)
blueprinter (= 0.25.2)
Expand Down Expand Up @@ -188,7 +188,7 @@ GEM
mime-types (3.6.0)
logger
mime-types-data (~> 3.2015)
mime-types-data (3.2024.1105)
mime-types-data (3.2024.1001)
mini_portile2 (2.8.7)
minitest (5.25.1)
multi_json (1.15.0)
Expand Down
62 changes: 53 additions & 9 deletions config/DTR SMART App Tests Postman Demo.postman_collection.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"info": {
"_postman_id": "53717a07-9778-43ae-a28a-b436d74ba1b2",
"_postman_id": "7541fc56-d4d2-4126-b100-60416d01c5d5",
"name": "DTR SMART App Tests Postman Demo",
"description": "The variables tab in this collection controls port for inferno and token used to establish a session.\n\n- url_prefix: points to a running instance of inferno. Typical values will be\n \n - Inferno production: [https://inferno.healthit.gov/suites](https://inferno.healthit.gov/suites)\n \n - Inferno QA: [https://inferno-qa.healthit.gov/suites](https://inferno-qa.healthit.gov/suites)\n \n - Local docker: [http://localhost](http://localhost)\n \n - Local development: [http://localhost:4657](http://localhost:4657)\n \n- jwt_with_inferno_client_id: a non-standard jwt with the client_id value for the test present in the \"inferno_client_id\" entry of the payload. See [https://jwt.io/#debugger-io?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpbmZlcm5vX2NsaWVudF9pZCI6InRlc3QifQ.VvPENMXJqEfG3ShJ4hp4MQblfxcwJuX5o-3wSrGXEi0](https://jwt.io/#debugger-io?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpbmZlcm5vX2NsaWVudF9pZCI6InRlc3QifQ.VvPENMXJqEfG3ShJ4hp4MQblfxcwJuX5o-3wSrGXEi0) for an example where the client id is `sample` (NOTE: no other details matter). Sent on all questionnaire and clinical requests in the Authorization header as a bearer token (Bearer {{jwt_with_inferno_client_id}} - configured on the Authorization tab).",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "32597978"
"_exporter_id": "2481890"
},
"item": [
{
Expand Down Expand Up @@ -34,6 +34,48 @@
},
"response": []
},
{
"name": "OpenID Configuration",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/custom/dtr_smart_app/fhir/.well-known/openid-configuration",
"host": [
"{{base_url}}"
],
"path": [
"custom",
"dtr_smart_app",
"fhir",
".well-known",
"openid-configuration"
]
}
},
"response": []
},
{
"name": "JWKS",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/custom/dtr_smart_app/fhir/.well-known/jwks.json",
"host": [
"{{base_url}}"
],
"path": [
"custom",
"dtr_smart_app",
"fhir",
".well-known",
"jwks.json"
]
}
},
"response": []
},
{
"name": "Authorize",
"request": {
Expand All @@ -43,15 +85,15 @@
"method": "GET",
"header": [],
"url": {
"raw": "{{base_url}}/custom/dtr_smart_app/fhir/mock_auth/authorize?response_type=code&client_id=sample&redirect_uri=http://localhost&launch=1234&scope=launch+patient%2F%2A.rs&state=394a5f8b-735c-4930-8f11-8f4034e3a483&aud={{base_url}}/custom/dtr_smart_app&code_challenge=59acb885e0a8b485df1a48c4d61544e5a0e6aa0209fe131bc64a336cb12aa95f&code_challenge_method=S256",
"raw": "{{base_url}}/custom/dtr_smart_app/fhir/mock_ehr_auth/authorize?response_type=code&client_id=sample&redirect_uri=http://localhost&launch=1234&scope=launch+patient%2F%2A.rs&state=394a5f8b-735c-4930-8f11-8f4034e3a483&aud={{base_url}}/custom/dtr_smart_app&code_challenge=59acb885e0a8b485df1a48c4d61544e5a0e6aa0209fe131bc64a336cb12aa95f&code_challenge_method=S256",
"host": [
"{{base_url}}"
],
"path": [
"custom",
"dtr_smart_app",
"fhir",
"mock_auth",
"mock_ehr_auth",
"authorize"
],
"query": [
Expand Down Expand Up @@ -111,7 +153,7 @@
},
{
"key": "client_id",
"value": "1",
"value": "sample",
"type": "text"
},
{
Expand All @@ -126,7 +168,7 @@
},
{
"key": "scope",
"value": "openid+user%2F%2A.rs+launch%2Fpatient",
"value": "openid+user%2F%2A.rs+launch%2Fpatient+fhirUser",
"type": "text"
},
{
Expand All @@ -152,13 +194,14 @@
]
},
"url": {
"raw": "{{base_url}}/custom/dtr_smart_app/mock_ehr_auth/authorize",
"raw": "{{base_url}}/custom/dtr_smart_app/fhir/mock_ehr_auth/authorize",
"host": [
"{{base_url}}"
],
"path": [
"custom",
"dtr_smart_app",
"fhir",
"mock_ehr_auth",
"authorize"
]
Expand Down Expand Up @@ -207,19 +250,20 @@
},
{
"key": "client_id",
"value": "1",
"value": "sample",
"type": "text"
}
]
},
"url": {
"raw": "{{base_url}}/custom/dtr_smart_app/mock_ehr_auth/token",
"raw": "{{base_url}}/custom/dtr_smart_app/fhir/mock_ehr_auth/token",
"host": [
"{{base_url}}"
],
"path": [
"custom",
"dtr_smart_app",
"fhir",
"mock_ehr_auth",
"token"
]
Expand Down
2 changes: 1 addition & 1 deletion davinci_dtr_test_kit.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Gem::Specification.new do |spec|
spec.description = 'Test Kit for the Da Vinci Documentation Templates and Rules (DTR) FHIR Implementation Guide'
spec.homepage = 'https://github.com/inferno-framework/davinci-dtr-test-kit'
spec.license = 'Apache-2.0'
spec.add_dependency 'inferno_core', '~> 0.4.42'
spec.add_dependency 'inferno_core', '~> 0.5.0'
spec.add_dependency 'jwt', '~> 2.6'
spec.add_dependency 'smart_app_launch_test_kit', '~> 0.4.4'
spec.add_dependency 'tls_test_kit', '0.2.2'
Expand Down
9 changes: 6 additions & 3 deletions lib/davinci_dtr_test_kit/dtr_smart_app_suite.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,21 @@ class DTRSmartAppSuite < Inferno::TestSuite
end

allow_cors QUESTIONNAIRE_PACKAGE_PATH, QUESTIONNAIRE_RESPONSE_PATH, FHIR_RESOURCE_PATH, FHIR_SEARCH_PATH,
EHR_AUTHORIZE_PATH, EHR_TOKEN_PATH
EHR_AUTHORIZE_PATH, EHR_TOKEN_PATH, JKWS_PATH, OPENID_CONFIG_PATH

route(:get, '/fhir/metadata', method(:metadata_handler))

route(:get, SMART_CONFIG_PATH, method(:ehr_smart_config))
route(:get, OPENID_CONFIG_PATH, method(:ehr_openid_config))

record_response_route :get, EHR_AUTHORIZE_PATH, 'dtr_smart_app_ehr_authorize', method(:ehr_authorize),
route(:get, JKWS_PATH, method(:auth_server_jwks))

record_response_route :get, EHR_AUTHORIZE_PATH, EHR_AUTHORIZE_TAG, method(:ehr_authorize),
resumes: ->(_) { false } do |request|
DTRSmartAppSuite.extract_client_id_from_query_params(request)
end

record_response_route :post, EHR_AUTHORIZE_PATH, 'dtr_smart_app_ehr_authorize', method(:ehr_authorize),
record_response_route :post, EHR_AUTHORIZE_PATH, EHR_AUTHORIZE_TAG, method(:ehr_authorize),
resumes: ->(_) { false } do |request|
DTRSmartAppSuite.extract_client_id_from_form_params(request)
end
Expand Down
107 changes: 95 additions & 12 deletions lib/davinci_dtr_test_kit/mock_auth_server.rb
Original file line number Diff line number Diff line change
@@ -1,21 +1,45 @@
# frozen_string_literal: true

require_relative 'urls'

module DaVinciDTRTestKit
module MockAuthServer
AUTHORIZED_PRACTITIONER_ID = 'pra1234' # Must exist on the FHIR_REFERENCE_SERVER (env var)

RSA_PRIVATE_KEY = OpenSSL::PKey::RSA.generate(2048)
RSA_PUBLIC_KEY = RSA_PRIVATE_KEY.public_key
SUPPORTED_SCOPES = ['launch', 'patient/*.rs', 'user/*.rs', 'offline_access', 'openid', 'fhirUser'].freeze

def requests_repo
@requests_repo ||= Inferno::Repositories::Requests.new
end

def auth_server_jwks(_env)
response_body = {
keys: [
{
kty: 'RSA',
alg: 'RS256',
n: Base64.urlsafe_encode64(RSA_PUBLIC_KEY.n.to_s(2), padding: false),
e: Base64.urlsafe_encode64(RSA_PUBLIC_KEY.e.to_s(2), padding: false),
use: 'sig'
}
]
}.to_json

[200, { 'Content-Type' => 'application/json', 'Access-Control-Allow-Origin' => '*' }, [response_body]]
end

def ehr_smart_config(env)
protocol = env['rack.url_scheme']
host = env['HTTP_HOST']
path = env['REQUEST_PATH'] || env['PATH_INFO']
path.gsub!(%r{#{SMART_CONFIG_PATH}(/)?}, '')
base_url = "#{protocol}://#{host + path}"
base_url = env_base_url(env, SMART_CONFIG_PATH)
response_body =
{
authorization_endpoint: base_url + EHR_AUTHORIZE_PATH,
token_endpoint: base_url + EHR_TOKEN_PATH,
token_endpoint_auth_methods_supported: ['private_key_jwt'],
token_endpoint_auth_signing_alg_values_supported: ['RS256'],
grant_types_supported: ['authorization_code'],
scopes_supported: ['launch', 'patient/*.rs', 'user/*.rs', 'offline_access'],
scopes_supported: SUPPORTED_SCOPES,
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256'],
capabilities: [
Expand All @@ -31,9 +55,23 @@ def ehr_smart_config(env)
[200, { 'Content-Type' => 'application/json', 'Access-Control-Allow-Origin' => '*' }, [response_body]]
end

def ehr_openid_config(env)
base_url = env_base_url(env, OPENID_CONFIG_PATH)
response_body = {
issuer: base_url + FHIR_BASE_PATH,
authorization_endpoint: base_url + EHR_AUTHORIZE_PATH,
token_endpoint: base_url + EHR_TOKEN_PATH,
jwks_uri: base_url + JKWS_PATH,
response_types_supported: ['id_token'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256']
}.to_json
[200, { 'Content-Type' => 'application/json', 'Access-Control-Allow-Origin' => '*' }, [response_body]]
end

def ehr_authorize(request, _test = nil, _test_result = nil)
# Authorization requests can bet GET or POST
params = request.verb == 'get' ? request.query_parameters : URI.decode_www_form(request.request_body)&.to_h
params = params_hash(request)
if params['redirect_uri'].present?
redirect_uri = "#{params['redirect_uri']}?" \
"code=#{SecureRandom.hex}&" \
Expand All @@ -54,8 +92,14 @@ def ehr_authorize(request, _test = nil, _test_result = nil)

def ehr_token_response(request, _test = nil, test_result = nil)
client_id = extract_client_id_from_token_request(request)
token = JWT.encode({ inferno_client_id: client_id }, nil, 'none')
response = { access_token: token, token_type: 'bearer', expires_in: 3600 }
access_token = JWT.encode({ inferno_client_id: client_id }, nil, 'none')
granted_scopes = SUPPORTED_SCOPES & requested_scopes(test_result.test_session_id)

response = { access_token:, scope: granted_scopes.join(' '), token_type: 'bearer', expires_in: 3600 }

if granted_scopes.include?('openid')
response.merge!(id_token: create_id_token(request, client_id, fhir_user: granted_scopes.include?('fhirUser')))
end

fhir_context_input = find_test_input(test_result, 'smart_fhir_context')
fhir_context_input_value = fhir_context_input['value'] if fhir_context_input
Expand All @@ -64,14 +108,18 @@ def ehr_token_response(request, _test = nil, test_result = nil)
rescue StandardError
fhir_context = nil
end
response.merge!({ fhirContext: fhir_context }) if fhir_context
response.merge!(fhirContext: fhir_context) if fhir_context

smart_patient_input = find_test_input(test_result, 'smart_patient_id')
smart_patient_input_value = smart_patient_input['value'] if smart_patient_input.present?
response.merge!({ patient: smart_patient_input_value }) if smart_patient_input_value
response.merge!(patient: smart_patient_input_value) if smart_patient_input_value

request.response_body = response.to_json
request.response_headers = { 'Access-Control-Allow-Origin' => '*' }
request.response_headers = {
'Cache-Control' => 'no-store',
'Pragma' => 'no-cache',
'Access-Control-Allow-Origin' => '*'
}
request.status = 200
end

Expand Down Expand Up @@ -138,8 +186,43 @@ def extract_token_from_query_params(request)
request.query_parameters['token']
end

def create_id_token(request, client_id, fhir_user: false)
# No point in mocking an identity provider, just always use known Practitioner as the authorized user
suite_base_url = request.url.split(EHR_TOKEN_PATH).first
id_token_hash = {
iss: suite_base_url + FHIR_BASE_PATH,
sub: AUTHORIZED_PRACTITIONER_ID,
aud: client_id,
exp: Time.now.to_i + (24 * 60 * 60), # 24 hrs
iat: Time.now.to_i
}
id_token_hash.merge!(fhirUser: "#{suite_base_url}/fhir/Practitioner/#{AUTHORIZED_PRACTITIONER_ID}") if fhir_user

JWT.encode(id_token_hash, RSA_PRIVATE_KEY, 'RS256')
end

def requested_scopes(test_session_id)
auth_request = requests_repo.tagged_requests(test_session_id, [EHR_AUTHORIZE_TAG]).last
return [] unless auth_request

scope_str = params_hash(auth_request)&.dig('scope')
scope_str ? URI.decode_www_form_component(scope_str).split : []
end

def find_test_input(test_result, input_name)
JSON.parse(test_result.input_json)&.find { |input| input['name'] == input_name }
end

def params_hash(request)
request.verb == 'get' ? request.query_parameters : URI.decode_www_form(request.request_body)&.to_h
end

def env_base_url(env, endpoint_path)
protocol = env['rack.url_scheme']
host = env['HTTP_HOST']
path = env['REQUEST_PATH'] || env['PATH_INFO']
path.gsub!(%r{#{endpoint_path}(/)?}, '')
"#{protocol}://#{host + path}"
end
end
end
1 change: 1 addition & 0 deletions lib/davinci_dtr_test_kit/tags.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ module DaVinciDTRTestKit
NEXT_TAG = 'payer_server_adaptive_questionnaire_package'
QUESTIONNAIRE_PACKAGE_TAG = 'dtr_questionnaire_package'
SMART_APP_EHR_REQUEST_TAG = 'dtr_smart_app_ehr_request'
EHR_AUTHORIZE_TAG = 'dtr_smart_app_ehr_authorize'
end
Loading

0 comments on commit 755f922

Please sign in to comment.