smart_app_launch_test_kit 0.6.0 → 0.6.1
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.
- checksums.yaml +4 -4
- data/config/presets/SMART_RunClientAgainstServer.json.erb +31 -0
- data/config/presets/SMART_RunServerAgainstClient.json.erb +42 -0
- data/lib/smart_app_launch/backend_services_authorization_group.rb +0 -2
- data/lib/smart_app_launch/backend_services_authorization_request_success_test.rb +5 -2
- data/lib/smart_app_launch/backend_services_authorization_response_body_test.rb +6 -2
- data/lib/smart_app_launch/client_stu2_2_suite.rb +79 -0
- data/lib/smart_app_launch/client_suite/client_access_group.rb +26 -0
- data/lib/smart_app_launch/client_suite/client_access_interaction_test.rb +64 -0
- data/lib/smart_app_launch/client_suite/client_registration_group.rb +15 -0
- data/lib/smart_app_launch/client_suite/client_registration_verification_test.rb +52 -0
- data/lib/smart_app_launch/client_suite/client_token_request_verification_test.rb +146 -0
- data/lib/smart_app_launch/client_suite/client_token_use_verification_test.rb +47 -0
- data/lib/smart_app_launch/docs/demo/FHIR Request.postman_collection.json +81 -0
- data/lib/smart_app_launch/docs/smart_stu2_2_client_suite_description.md +121 -0
- data/lib/smart_app_launch/endpoints/echoing_fhir_responder.rb +52 -0
- data/lib/smart_app_launch/endpoints/mock_smart_server/token.rb +27 -0
- data/lib/smart_app_launch/endpoints/mock_smart_server.rb +217 -0
- data/lib/smart_app_launch/metadata.rb +2 -2
- data/lib/smart_app_launch/smart_stu2_2_suite.rb +2 -1
- data/lib/smart_app_launch/smart_stu2_suite.rb +2 -1
- data/lib/smart_app_launch/tags.rb +7 -0
- data/lib/smart_app_launch/token_introspection_response_group.rb +1 -1
- data/lib/smart_app_launch/urls.rb +40 -0
- data/lib/smart_app_launch/version.rb +2 -2
- data/lib/smart_app_launch_test_kit.rb +1 -0
- metadata +18 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2608337f4d0d2651ba2395ecda2f89263ebe14d039f3d730ae2f82df3fe49855
|
4
|
+
data.tar.gz: b7a480f55c94dec25865151faefd76045096d21b5333a9c5bf56cf73eb7310bd
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 853bd240f1fccdfd72ab79ff89b420b9b1edebd1d1fbd01e9dd3f1e206a29fae3f84f79371e0c7406dc9f3f5bde6ddf7ef51167cf2537f97b56bc36d245d7ef8
|
7
|
+
data.tar.gz: 5cf5840b0061bb6605e68e5d2ce4bf8e07c3039b4e53d8678b2d5cad19e27c352c751eadc8325dfd6ba3df02ddc28f9a7a8e5664eda85aa6cb77c93d1485b52a
|
@@ -0,0 +1,31 @@
|
|
1
|
+
{
|
2
|
+
"title": "Demo: Run Against the SMART Server Suite",
|
3
|
+
"id": "smart_run_client_against_server_v2_2",
|
4
|
+
"test_suite_id": "smart_client_stu2_2",
|
5
|
+
"inputs": [
|
6
|
+
{
|
7
|
+
"name": "smart_jwk_set",
|
8
|
+
"description": "The SMART client's JSON Web Key Set. May be provided as either a publicly accessible url containing the JWKS, or the raw JWKS.",
|
9
|
+
"optional": true,
|
10
|
+
"title": "SMART JSON Web Key Set (JWKS)",
|
11
|
+
"type": "textarea",
|
12
|
+
"value": "<%= Inferno::Application['base_url'] %>/custom/smart_stu2_2/.well-known/jwks.json"
|
13
|
+
},
|
14
|
+
{
|
15
|
+
"name": "client_id",
|
16
|
+
"description": "If a particular client id is desired, put it here. Otherwise a default of the Inferno session id will be used.",
|
17
|
+
"optional": true,
|
18
|
+
"title": "Client Id",
|
19
|
+
"type": "text",
|
20
|
+
"value": "smart_client_test_demo"
|
21
|
+
},
|
22
|
+
{
|
23
|
+
"name": "echoed_fhir_response",
|
24
|
+
"description": "JSON representation of a FHIR resource for Inferno to echo when a request is made to the simulated FHIR server. The provided content will be echoed back exactly and no check will be made that it is appropriate for the request made. If nothing is provided, an OperationOutcome will be returned.",
|
25
|
+
"optional": true,
|
26
|
+
"title": "FHIR Response to Echo",
|
27
|
+
"type": "textarea",
|
28
|
+
"value": "{\n \"resourceType\": \"Patient\",\n \"id\": \"example\",\n \"name\": [\n {\n \"family\": \"Chalmers\",\n \"given\": [\n \"Peter\",\n \"James\"\n ]\n }\n ],\n \"gender\": \"male\",\n \"birthDate\": \"1974-12-25\",\n \"address\": [\n {\n \"line\": [\n \"534 Erewhon St\"\n ],\n \"city\": \"Ann Arbor\",\n \"state\": \"MI\",\n \"postalCode\": \"48108\"\n }\n ]\n}"
|
29
|
+
}
|
30
|
+
]
|
31
|
+
}
|
@@ -0,0 +1,42 @@
|
|
1
|
+
{
|
2
|
+
"title": "Demo: Run Against the SMART Client Suite",
|
3
|
+
"id": "smart_run_server_against_client_v2_2",
|
4
|
+
"test_suite_id": "smart_stu2_2",
|
5
|
+
"inputs": [
|
6
|
+
{
|
7
|
+
"name": "url",
|
8
|
+
"description": "URL of the FHIR endpoint used by SMART applications",
|
9
|
+
"title": "FHIR Endpoint",
|
10
|
+
"type": "text",
|
11
|
+
"value": "<%= Inferno::Application['base_url'] %>/custom/smart_client_stu2_2/fhir"
|
12
|
+
},
|
13
|
+
{
|
14
|
+
"name": "backend_services_smart_auth_info",
|
15
|
+
"options": {
|
16
|
+
"mode": "auth",
|
17
|
+
"components": [
|
18
|
+
{
|
19
|
+
"name": "auth_type",
|
20
|
+
"default": "backend_services",
|
21
|
+
"locked": "true"
|
22
|
+
},
|
23
|
+
{
|
24
|
+
"name": "use_discovery",
|
25
|
+
"locked": true
|
26
|
+
}
|
27
|
+
]
|
28
|
+
},
|
29
|
+
"title": "Backend Services Credentials",
|
30
|
+
"type": "auth_info",
|
31
|
+
"value": {
|
32
|
+
"encryption_algorithm": "ES384",
|
33
|
+
"auth_type": "backend_services",
|
34
|
+
"use_discovery": "true",
|
35
|
+
"token_url": "<%= Inferno::Application['base_url'] %>/custom/smart_client_stu2_2/auth/token",
|
36
|
+
"requested_scopes": "system/*.rs",
|
37
|
+
"client_id": "smart_client_test_demo"
|
38
|
+
},
|
39
|
+
"default": {}
|
40
|
+
}
|
41
|
+
]
|
42
|
+
}
|
@@ -23,7 +23,7 @@ module SMARTAppLaunch
|
|
23
23
|
]
|
24
24
|
}
|
25
25
|
|
26
|
-
output :authentication_response
|
26
|
+
output :authentication_response, :smart_auth_info
|
27
27
|
|
28
28
|
run do
|
29
29
|
post_request_content = BackendServicesAuthorizationRequestBuilder.build(
|
@@ -40,7 +40,10 @@ module SMARTAppLaunch
|
|
40
40
|
|
41
41
|
assert_response_status([200, 201])
|
42
42
|
|
43
|
-
|
43
|
+
smart_auth_info.issue_time = Time.now
|
44
|
+
|
45
|
+
output authentication_response: authentication_response.response_body,
|
46
|
+
smart_auth_info: smart_auth_info
|
44
47
|
end
|
45
48
|
end
|
46
49
|
end
|
@@ -29,7 +29,7 @@ module SMARTAppLaunch
|
|
29
29
|
}
|
30
30
|
]
|
31
31
|
}
|
32
|
-
output :bearer_token, :smart_auth_info
|
32
|
+
output :bearer_token, :smart_auth_info, :received_scopes
|
33
33
|
|
34
34
|
run do
|
35
35
|
skip_if authentication_response.blank?, 'No authentication response received.'
|
@@ -38,11 +38,15 @@ module SMARTAppLaunch
|
|
38
38
|
response_body = JSON.parse(authentication_response)
|
39
39
|
|
40
40
|
access_token = response_body['access_token']
|
41
|
+
received_scopes = response_body['scope']
|
42
|
+
expires_in = response_body['expires_in']
|
43
|
+
|
41
44
|
assert access_token.present?, 'Token response did not contain access_token as required'
|
42
45
|
|
43
46
|
smart_auth_info.access_token = access_token
|
47
|
+
smart_auth_info.expires_in = expires_in
|
44
48
|
|
45
|
-
output bearer_token: access_token, smart_auth_info: smart_auth_info
|
49
|
+
output bearer_token: access_token, smart_auth_info: smart_auth_info, received_scopes: received_scopes
|
46
50
|
|
47
51
|
required_keys = ['token_type', 'expires_in', 'scope']
|
48
52
|
|
@@ -0,0 +1,79 @@
|
|
1
|
+
require_relative 'endpoints/mock_smart_server/token'
|
2
|
+
require_relative 'endpoints/echoing_fhir_responder'
|
3
|
+
require_relative 'urls'
|
4
|
+
require_relative 'client_suite/client_registration_group'
|
5
|
+
require_relative 'client_suite/client_access_group'
|
6
|
+
|
7
|
+
module SMARTAppLaunch
|
8
|
+
class SMARTClientSTU22Suite < Inferno::TestSuite
|
9
|
+
id :smart_client_stu2_2 # rubocop:disable Naming/VariableNumber
|
10
|
+
title 'SMART App Launch STU2.2 Client'
|
11
|
+
description File.read(File.join(__dir__, 'docs', 'smart_stu2_2_client_suite_description.md'))
|
12
|
+
|
13
|
+
links [
|
14
|
+
{
|
15
|
+
type: 'source_code',
|
16
|
+
label: 'Open Source',
|
17
|
+
url: 'https://github.com/inferno-framework/smart-app-launch-test-kit/'
|
18
|
+
},
|
19
|
+
{
|
20
|
+
type: 'report_issue',
|
21
|
+
label: 'Report Issue',
|
22
|
+
url: 'https://github.com/inferno-framework/smart-app-launch-test-kit/issues/'
|
23
|
+
},
|
24
|
+
{
|
25
|
+
type: 'download',
|
26
|
+
label: 'Download',
|
27
|
+
url: 'https://github.com/inferno-framework/smart-app-launch-test-kit/releases/'
|
28
|
+
},
|
29
|
+
{
|
30
|
+
type: 'ig',
|
31
|
+
label: 'Implementation Guide',
|
32
|
+
url: 'https://hl7.org/fhir/smart-app-launch/STU2.2/'
|
33
|
+
}
|
34
|
+
]
|
35
|
+
|
36
|
+
route(:get, SMART_DISCOVERY_PATH, ->(_env) {MockSMARTServer.smart_server_metadata(id) })
|
37
|
+
suite_endpoint :post, TOKEN_PATH, MockSMARTServer::TokenEndpoint
|
38
|
+
suite_endpoint :get, FHIR_PATH, EchoingFHIRResponderEndpoint
|
39
|
+
suite_endpoint :post, FHIR_PATH, EchoingFHIRResponderEndpoint
|
40
|
+
suite_endpoint :put, FHIR_PATH, EchoingFHIRResponderEndpoint
|
41
|
+
suite_endpoint :delete, FHIR_PATH, EchoingFHIRResponderEndpoint
|
42
|
+
suite_endpoint :get, "#{FHIR_PATH}/:one", EchoingFHIRResponderEndpoint
|
43
|
+
suite_endpoint :post, "#{FHIR_PATH}/:one", EchoingFHIRResponderEndpoint
|
44
|
+
suite_endpoint :put, "#{FHIR_PATH}/:one", EchoingFHIRResponderEndpoint
|
45
|
+
suite_endpoint :delete, "#{FHIR_PATH}/:one", EchoingFHIRResponderEndpoint
|
46
|
+
suite_endpoint :get, "#{FHIR_PATH}/:one/:two", EchoingFHIRResponderEndpoint
|
47
|
+
suite_endpoint :post, "#{FHIR_PATH}/:one/:two", EchoingFHIRResponderEndpoint
|
48
|
+
suite_endpoint :put, "#{FHIR_PATH}/:one/:two", EchoingFHIRResponderEndpoint
|
49
|
+
suite_endpoint :delete, "#{FHIR_PATH}/:one/:two", EchoingFHIRResponderEndpoint
|
50
|
+
suite_endpoint :get, "#{FHIR_PATH}/:one/:two/:three", EchoingFHIRResponderEndpoint
|
51
|
+
suite_endpoint :post, "#{FHIR_PATH}/:one/:two/:three", EchoingFHIRResponderEndpoint
|
52
|
+
suite_endpoint :put, "#{FHIR_PATH}/:one/:two/:three", EchoingFHIRResponderEndpoint
|
53
|
+
suite_endpoint :delete, "#{FHIR_PATH}/:one/:two/:three", EchoingFHIRResponderEndpoint
|
54
|
+
|
55
|
+
resume_test_route :get, RESUME_PASS_PATH do |request|
|
56
|
+
request.query_parameters['token']
|
57
|
+
end
|
58
|
+
|
59
|
+
resume_test_route :get, RESUME_FAIL_PATH, result: 'fail' do |request|
|
60
|
+
request.query_parameters['token']
|
61
|
+
end
|
62
|
+
|
63
|
+
group do
|
64
|
+
title 'SMART Backend Services'
|
65
|
+
description %(
|
66
|
+
During these tests, the client will use SMART Backend Services
|
67
|
+
to access a FHIR API. Clients will provide registeration details,
|
68
|
+
obtain an access token, and use the access token when making a
|
69
|
+
request to a FHIR API.
|
70
|
+
)
|
71
|
+
|
72
|
+
input :smart_jwk_set,
|
73
|
+
optional: false
|
74
|
+
|
75
|
+
group from: :smart_client_registration
|
76
|
+
group from: :smart_client_access
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require_relative 'client_access_interaction_test'
|
2
|
+
require_relative 'client_token_request_verification_test'
|
3
|
+
require_relative 'client_token_use_verification_test'
|
4
|
+
|
5
|
+
module SMARTAppLaunch
|
6
|
+
class SMARTClientAccess < Inferno::TestGroup
|
7
|
+
id :smart_client_access
|
8
|
+
title 'Client Access'
|
9
|
+
description %(
|
10
|
+
During these tests, the client system will access Inferno's simulated
|
11
|
+
FHIR server by requesting an access token and making a FHIR request.
|
12
|
+
Inferno will then verify that any token requests made were conformant
|
13
|
+
and that a token returned from a token request was used on an access request.
|
14
|
+
)
|
15
|
+
|
16
|
+
run_as_group
|
17
|
+
|
18
|
+
input :smart_jwk_set,
|
19
|
+
optional: true,
|
20
|
+
locked: true
|
21
|
+
|
22
|
+
test from: :smart_client_access_interaction
|
23
|
+
test from: :smart_client_token_request_verification
|
24
|
+
test from: :smart_client_token_use_verification
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require_relative '../urls'
|
2
|
+
require_relative '../endpoints/mock_smart_server'
|
3
|
+
|
4
|
+
module SMARTAppLaunch
|
5
|
+
class SMARTClientAccessInteraction < Inferno::Test
|
6
|
+
include URLs
|
7
|
+
|
8
|
+
id :smart_client_access_interaction
|
9
|
+
title 'Perform SMART-secured Access'
|
10
|
+
description %(
|
11
|
+
During this test, Inferno will wait for the client to access data
|
12
|
+
using a SMART token obtained during earlier tests.
|
13
|
+
)
|
14
|
+
input :client_id,
|
15
|
+
title: 'Client Id',
|
16
|
+
type: 'text',
|
17
|
+
locked: true,
|
18
|
+
description: %(
|
19
|
+
The registered Client Id for use in obtaining access tokens.
|
20
|
+
Create a new session if you need to change this value.
|
21
|
+
)
|
22
|
+
input :smart_jwk_set,
|
23
|
+
title: 'JSON Web Key Set (JWKS)',
|
24
|
+
type: 'textarea',
|
25
|
+
optional: true,
|
26
|
+
locked: true,
|
27
|
+
description: %(
|
28
|
+
The SMART client's JSON Web Key Set in the form of either a publicly accessible url
|
29
|
+
containing the JWKS, or the raw JWKS JSON. Must include the key(s) Inferno will need to
|
30
|
+
verify signatures on token requests made by the client.
|
31
|
+
Create a new session if you need to change this value.
|
32
|
+
)
|
33
|
+
input :echoed_fhir_response,
|
34
|
+
title: 'FHIR Response to Echo',
|
35
|
+
type: 'textarea',
|
36
|
+
description: %(
|
37
|
+
JSON representation of a FHIR resource for Inferno to echo when a request
|
38
|
+
is made to the simulated FHIR server. The provided content will be echoed
|
39
|
+
back exactly and no check will be made that it is appropriate for the request
|
40
|
+
made. If nothing is provided, an OperationOutcome will be returned.
|
41
|
+
),
|
42
|
+
optional: true
|
43
|
+
|
44
|
+
run do
|
45
|
+
wait(
|
46
|
+
identifier: client_id,
|
47
|
+
message: %(
|
48
|
+
**Access**
|
49
|
+
|
50
|
+
Use the registered client id (#{client_id}) to obtain an access
|
51
|
+
token using SMART Backend Services
|
52
|
+
and use that token to access a FHIR endpoint under the simulated server's base URL
|
53
|
+
|
54
|
+
`#{client_fhir_base_url}`
|
55
|
+
|
56
|
+
Inferno will echo the response provided in the **FHIR Response to Echo** input.
|
57
|
+
|
58
|
+
[Click here](#{client_resume_pass_url}?token=#{client_id}) once you performed
|
59
|
+
the access.
|
60
|
+
)
|
61
|
+
)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require_relative 'client_registration_verification_test'
|
2
|
+
|
3
|
+
module SMARTAppLaunch
|
4
|
+
class SMARTClientRegistration < Inferno::TestGroup
|
5
|
+
id :smart_client_registration
|
6
|
+
title 'Client Registration'
|
7
|
+
description %(
|
8
|
+
During these tests, Inferno will verify the registration details provided as inputs,
|
9
|
+
including the client's JSON Web Key Set.
|
10
|
+
)
|
11
|
+
run_as_group
|
12
|
+
|
13
|
+
test from: :smart_client_registration_verification
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require_relative '../tags'
|
2
|
+
require_relative '../endpoints/mock_smart_server'
|
3
|
+
|
4
|
+
module SMARTAppLaunch
|
5
|
+
class SMARTClientRegistrationVerification < Inferno::Test
|
6
|
+
|
7
|
+
id :smart_client_registration_verification
|
8
|
+
title 'Verify SMART Registration'
|
9
|
+
description %(
|
10
|
+
During this test, Inferno will verify that the SMART registration details
|
11
|
+
provided are conformant.
|
12
|
+
)
|
13
|
+
input :smart_jwk_set,
|
14
|
+
title: 'SMART JSON Web Key Set (JWKS)',
|
15
|
+
type: 'textarea',
|
16
|
+
description: %(
|
17
|
+
The SMART client's JSON Web Key Set including the key(s) Inferno will need to
|
18
|
+
verify signatures on token requests made by the client. May be provided as either
|
19
|
+
a publicly accessible url containing the JWKS, or the raw JWKS JSON.
|
20
|
+
)
|
21
|
+
input :client_id,
|
22
|
+
title: 'Client Id',
|
23
|
+
type: 'text',
|
24
|
+
optional: true,
|
25
|
+
description: %(
|
26
|
+
If a particular client id is desired, put it here. Otherwise a
|
27
|
+
default of the Inferno session id will be used.
|
28
|
+
)
|
29
|
+
|
30
|
+
output :client_id
|
31
|
+
|
32
|
+
run do
|
33
|
+
omit_if smart_jwk_set.blank?, # for re-use: mark the smart_jwk_set input as optional when importing to enable
|
34
|
+
'Not configured for SMART authentication.'
|
35
|
+
|
36
|
+
if client_id.blank?
|
37
|
+
client_id = test_session_id
|
38
|
+
output(client_id:)
|
39
|
+
end
|
40
|
+
|
41
|
+
jwks_warnings = []
|
42
|
+
parsed_smart_jwk_set = MockSMARTServer.jwk_set(smart_jwk_set, jwks_warnings)
|
43
|
+
jwks_warnings.each { |warning| add_message('warning', warning) }
|
44
|
+
|
45
|
+
assert parsed_smart_jwk_set.length.positive?, 'JWKS content does not include any valid keys.'
|
46
|
+
|
47
|
+
# TODO: add key-specific verification per end of https://build.fhir.org/ig/HL7/smart-app-launch/client-confidential-asymmetric.html#registering-a-client-communicating-public-keys
|
48
|
+
|
49
|
+
assert messages.none? { |msg| msg[:type] == 'error' }, 'Invalid key set provided. See messages for details'
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,146 @@
|
|
1
|
+
require_relative '../tags'
|
2
|
+
require_relative '../urls'
|
3
|
+
require_relative '../endpoints/mock_smart_server'
|
4
|
+
|
5
|
+
module SMARTAppLaunch
|
6
|
+
class SMARTClientTokenRequestVerification < Inferno::Test
|
7
|
+
include URLs
|
8
|
+
|
9
|
+
id :smart_client_token_request_verification
|
10
|
+
title 'Verify SMART Token Requests'
|
11
|
+
description %(
|
12
|
+
Check that SMART token requests are conformant.
|
13
|
+
)
|
14
|
+
|
15
|
+
input :client_id,
|
16
|
+
title: 'Client Id',
|
17
|
+
type: 'text',
|
18
|
+
optional: false,
|
19
|
+
locked: true,
|
20
|
+
description: %(
|
21
|
+
The registered Client Id for use in obtaining access tokens.
|
22
|
+
Create a new session if you need to change this value.
|
23
|
+
)
|
24
|
+
input :smart_jwk_set,
|
25
|
+
title: 'JSON Web Key Set (JWKS)',
|
26
|
+
type: 'textarea',
|
27
|
+
optional: false,
|
28
|
+
locked: true,
|
29
|
+
description: %(
|
30
|
+
The SMART client's JSON Web Key Set in the form of either a publicly accessible url
|
31
|
+
containing the JWKS, or the raw JWKS JSON. Must include the key(s) Inferno will need to
|
32
|
+
verify signatures on token requests made by the client.
|
33
|
+
Create a new session if you need to change this value.
|
34
|
+
)
|
35
|
+
output :smart_tokens
|
36
|
+
|
37
|
+
run do
|
38
|
+
omit_if smart_jwk_set.blank?, # for re-use: mark the smart_jwk_set input as optional when importing to enable
|
39
|
+
'SMART Backend Services authentication not demonstrated as a part of this test session.'
|
40
|
+
|
41
|
+
load_tagged_requests(TOKEN_TAG, SMART_TAG)
|
42
|
+
skip_if requests.blank?, 'No SMART token requests made.'
|
43
|
+
|
44
|
+
jti_list = []
|
45
|
+
token_list = []
|
46
|
+
requests.each_with_index do |token_request, index|
|
47
|
+
request_params = URI.decode_www_form(token_request.request_body).to_h
|
48
|
+
check_request_params(request_params, index + 1)
|
49
|
+
check_client_assertion(request_params['client_assertion'], index + 1, jti_list)
|
50
|
+
token_list << extract_token_from_response(token_request)
|
51
|
+
end
|
52
|
+
|
53
|
+
output smart_tokens: token_list.compact.join("\n")
|
54
|
+
|
55
|
+
assert messages.none? { |msg|
|
56
|
+
msg[:type] == 'error'
|
57
|
+
}, 'Invalid token requests detected. See messages for details.'
|
58
|
+
end
|
59
|
+
|
60
|
+
def check_request_params(params, request_num)
|
61
|
+
if params['grant_type'] != 'client_credentials'
|
62
|
+
add_message('error',
|
63
|
+
"Token request #{request_num} had an incorrect `grant_type`: expected 'client_credentials', " \
|
64
|
+
"but got '#{params['grant_type']}'")
|
65
|
+
end
|
66
|
+
if params['client_assertion_type'] != 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
67
|
+
add_message('error',
|
68
|
+
"Token request #{request_num} had an incorrect `client_assertion_type`: " \
|
69
|
+
"expected 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', " \
|
70
|
+
"but got '#{params['client_assertion_type']}'")
|
71
|
+
end
|
72
|
+
return unless params['scope'].blank?
|
73
|
+
|
74
|
+
add_message('error', "Token request #{request_num} did not include the requested `scope`")
|
75
|
+
end
|
76
|
+
|
77
|
+
def check_client_assertion(assertion, request_num, jti_list)
|
78
|
+
decoded_token =
|
79
|
+
begin
|
80
|
+
JWT::EncodedToken.new(assertion)
|
81
|
+
rescue StandardError => e
|
82
|
+
add_message('error', "Token request #{request_num} contained an invalid client assertion jwt: #{e}")
|
83
|
+
nil
|
84
|
+
end
|
85
|
+
|
86
|
+
return unless decoded_token.present?
|
87
|
+
|
88
|
+
check_jwt_header(decoded_token.header, request_num)
|
89
|
+
check_jwt_payload(decoded_token.payload, request_num, jti_list)
|
90
|
+
check_jwt_signature(decoded_token, request_num)
|
91
|
+
end
|
92
|
+
|
93
|
+
def check_jwt_header(header, request_num)
|
94
|
+
return unless header['typ'] != 'JWT'
|
95
|
+
|
96
|
+
add_message('error', "client assertion jwt on token request #{request_num} has an incorrect `typ` header: " \
|
97
|
+
"expected 'JWT', got '#{header['typ']}'")
|
98
|
+
end
|
99
|
+
|
100
|
+
def check_jwt_payload(claims, request_num, jti_list)
|
101
|
+
if claims['iss'] != client_id
|
102
|
+
add_message('error', "client assertion jwt on token request #{request_num} has an incorrect `iss` claim: " \
|
103
|
+
"expected '#{client_id}', got '#{claims['iss']}'")
|
104
|
+
end
|
105
|
+
|
106
|
+
if claims['sub'] != client_id
|
107
|
+
add_message('error', "client assertion jwt on token request #{request_num} has an incorrect `sub` claim: " \
|
108
|
+
"expected '#{client_id}', got '#{claims['sub']}'")
|
109
|
+
end
|
110
|
+
|
111
|
+
if claims['aud'] != client_token_url
|
112
|
+
add_message('error', "client assertion jwt on token request #{request_num} has an incorrect `aud` claim: " \
|
113
|
+
"expected '#{client_token_url}', got '#{claims['aud']}'")
|
114
|
+
end
|
115
|
+
|
116
|
+
if claims['exp'].blank?
|
117
|
+
add_message('error', "client assertion jwt on token request #{request_num} is missing the `exp` claim.")
|
118
|
+
end
|
119
|
+
|
120
|
+
if claims['jti'].blank?
|
121
|
+
add_message('error', "client assertion jwt on token request #{request_num} is missing the `jti` claim.")
|
122
|
+
elsif jti_list.include?(claims['jti'])
|
123
|
+
add_message('error', "client assertion jwt on token request #{request_num} has a `jti` claim that was " \
|
124
|
+
"previouly used: '#{claims['jti']}'.")
|
125
|
+
else
|
126
|
+
jti_list << claims['jti']
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def check_jwt_signature(encoded_token, request_num)
|
131
|
+
error = MockSMARTServer.smart_assertion_signature_verification(encoded_token, smart_jwk_set)
|
132
|
+
|
133
|
+
return unless error.present?
|
134
|
+
|
135
|
+
add_message('error', "Signature validation failed on token request #{request_num}: #{error}")
|
136
|
+
end
|
137
|
+
|
138
|
+
def extract_token_from_response(request)
|
139
|
+
return unless request.status == 200
|
140
|
+
|
141
|
+
JSON.parse(request.response_body)&.dig('access_token')
|
142
|
+
rescue
|
143
|
+
nil
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require_relative '../tags'
|
2
|
+
require_relative '../endpoints/mock_smart_server'
|
3
|
+
|
4
|
+
module SMARTAppLaunch
|
5
|
+
class SMARTClientTokenUseVerification < Inferno::Test
|
6
|
+
|
7
|
+
id :smart_client_token_use_verification
|
8
|
+
title 'Verify SMART Token Use'
|
9
|
+
description %(
|
10
|
+
Check that a SMART token returned to the client was used for request
|
11
|
+
authentication.
|
12
|
+
)
|
13
|
+
|
14
|
+
input :smart_tokens,
|
15
|
+
optional: true # verified in the test to return a more specific error message
|
16
|
+
input :smart_jwk_set,
|
17
|
+
optional: false,
|
18
|
+
locked: true
|
19
|
+
|
20
|
+
def access_request_tags
|
21
|
+
return config.options[:access_request_tags] if config.options[:access_request_tags].present?
|
22
|
+
|
23
|
+
[ACCESS_TAG]
|
24
|
+
end
|
25
|
+
|
26
|
+
run do
|
27
|
+
omit_if smart_jwk_set.blank?, # for re-use: mark the smart_jwk_set input as optional when importing to enable
|
28
|
+
'SMART Authentication not demonstrated as a part of this test session.'
|
29
|
+
|
30
|
+
access_requests = access_request_tags.map do |access_request_tag|
|
31
|
+
load_tagged_requests(access_request_tag).reject { |access| access.status == 401 }
|
32
|
+
end.flatten
|
33
|
+
obtained_tokens = smart_tokens&.split("\n")
|
34
|
+
|
35
|
+
skip_if obtained_tokens.blank?, 'No token requests made.'
|
36
|
+
skip_if access_requests.blank?, 'No successful access requests made.'
|
37
|
+
|
38
|
+
used_tokens = access_requests.map do |access_request|
|
39
|
+
access_request.request_headers.find do |header|
|
40
|
+
header.name.downcase == 'authorization'
|
41
|
+
end&.value&.delete_prefix('Bearer ')
|
42
|
+
end.compact
|
43
|
+
|
44
|
+
assert (used_tokens & obtained_tokens).present?, 'Returned tokens never used in any requests.'
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
{
|
2
|
+
"info": {
|
3
|
+
"_postman_id": "22f52416-c6ae-4ffc-a388-54616465d149",
|
4
|
+
"name": "FHIR Request",
|
5
|
+
"description": "Make a simple FHIR request with a specific bearer token. Useful for security client tests like SMART and UDAP.\n\n- base_url: 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:4567](http://localhost:4567)",
|
6
|
+
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
7
|
+
"_exporter_id": "32597978"
|
8
|
+
},
|
9
|
+
"item": [
|
10
|
+
{
|
11
|
+
"name": "Patient Read",
|
12
|
+
"request": {
|
13
|
+
"auth": {
|
14
|
+
"type": "bearer",
|
15
|
+
"bearer": [
|
16
|
+
{
|
17
|
+
"key": "token",
|
18
|
+
"value": "{{bearer_token}}",
|
19
|
+
"type": "string"
|
20
|
+
}
|
21
|
+
]
|
22
|
+
},
|
23
|
+
"method": "GET",
|
24
|
+
"header": [],
|
25
|
+
"url": {
|
26
|
+
"raw": "{{base_url}}/custom/{{target_suite}}/fhir/Patient/example",
|
27
|
+
"host": [
|
28
|
+
"{{base_url}}"
|
29
|
+
],
|
30
|
+
"path": [
|
31
|
+
"custom",
|
32
|
+
"{{target_suite}}",
|
33
|
+
"fhir",
|
34
|
+
"Patient",
|
35
|
+
"example"
|
36
|
+
]
|
37
|
+
}
|
38
|
+
},
|
39
|
+
"response": []
|
40
|
+
}
|
41
|
+
],
|
42
|
+
"event": [
|
43
|
+
{
|
44
|
+
"listen": "prerequest",
|
45
|
+
"script": {
|
46
|
+
"type": "text/javascript",
|
47
|
+
"packages": {},
|
48
|
+
"exec": [
|
49
|
+
""
|
50
|
+
]
|
51
|
+
}
|
52
|
+
},
|
53
|
+
{
|
54
|
+
"listen": "test",
|
55
|
+
"script": {
|
56
|
+
"type": "text/javascript",
|
57
|
+
"packages": {},
|
58
|
+
"exec": [
|
59
|
+
""
|
60
|
+
]
|
61
|
+
}
|
62
|
+
}
|
63
|
+
],
|
64
|
+
"variable": [
|
65
|
+
{
|
66
|
+
"key": "base_url",
|
67
|
+
"value": "https://inferno.healthit.gov/suites",
|
68
|
+
"type": "string"
|
69
|
+
},
|
70
|
+
{
|
71
|
+
"key": "target_suite",
|
72
|
+
"value": "smart_client_stu2_2",
|
73
|
+
"type": "string"
|
74
|
+
},
|
75
|
+
{
|
76
|
+
"key": "bearer_token",
|
77
|
+
"value": "",
|
78
|
+
"type": "string"
|
79
|
+
}
|
80
|
+
]
|
81
|
+
}
|
@@ -0,0 +1,121 @@
|
|
1
|
+
## Overview
|
2
|
+
|
3
|
+
The SMART App Launch STU 2.2 Client Test Suite verifies the conformance of
|
4
|
+
client systems to the STU 2.2.0 version of the HL7® FHIR®
|
5
|
+
[SMART App Launch IG](https://hl7.org/fhir/smart-app-launch/STU2.2/).
|
6
|
+
|
7
|
+
## Scope
|
8
|
+
|
9
|
+
The SMART App Launch Client Test Suite verifies that systems correctly implement
|
10
|
+
the [SMART App Launch IG](http://hl7.org/fhir/smart-app-launch/STU2.2/)
|
11
|
+
for authorizating and/or authenticating with a server in order to gain
|
12
|
+
access to HL7® FHIR® APIs. At this time, the suite only contains tests for
|
13
|
+
the [Backend Services](https://hl7.org/fhir/smart-app-launch/STU2.2/backend-services.html)
|
14
|
+
flow.
|
15
|
+
|
16
|
+
These tests are a **DRAFT** intended to allow implementers to perform
|
17
|
+
preliminary checks of their systems against SMART requirements and
|
18
|
+
[provide feedback](https://github.com/inferno-framework/smart-app-launch-test-kit/issues)
|
19
|
+
on the tests. Future versions of these tests may verify other
|
20
|
+
requirements and may change the test verification logic.
|
21
|
+
|
22
|
+
## Test Methodology
|
23
|
+
|
24
|
+
For these tests Inferno simulates a SMART server that supports the backend services
|
25
|
+
flow. Testers will
|
26
|
+
1. Provide registration details as inputs, including a JSON Web Key Set (JWKS)
|
27
|
+
an optionally a client id if a specific one should be used.
|
28
|
+
2. Request an access token using the registered JWKS and client id.
|
29
|
+
3. Use that access token on a FHIR API request.
|
30
|
+
|
31
|
+
The simulated server is relatively permissive in the sense that it will often
|
32
|
+
provide successful responses even when the request is not conformant. When
|
33
|
+
requesting tokens, Inferno will return an access token as long as it can find
|
34
|
+
the client id and the signature is valid. This allows incomplete systems to
|
35
|
+
run the tests. However, these non-conformant requests will be flagged by
|
36
|
+
the tests as failures so that systems will not pass the tests without being
|
37
|
+
fully conformant.
|
38
|
+
|
39
|
+
## Running the Tests
|
40
|
+
|
41
|
+
### Quick Start
|
42
|
+
|
43
|
+
The following inputs must be provided by the tester at a minimum to execute
|
44
|
+
any tests in this suite:
|
45
|
+
1. **SMART JSON Web Key Set (JWKS)**: The SMART client's public JSON Web Key Set including
|
46
|
+
key(s) that Inferno will use to verify the signature on incoming token requests. May
|
47
|
+
be provided as either a publicly accessible url containing the JWKS, or the raw JWKS.
|
48
|
+
|
49
|
+
The *Additional Inputs* section below describes options available to customize
|
50
|
+
the behavior of Inferno's server simulation.
|
51
|
+
|
52
|
+
### Demonstration
|
53
|
+
|
54
|
+
To try out these tests without a SMART client implementation, these tests can be exercised
|
55
|
+
using the SMART App Launch server test suite and a simple HTTP request generator. The following
|
56
|
+
steps use [Postman](https://www.postman.com/) to generate the access request using
|
57
|
+
[this collection](https://github.com/inferno-framework/smart-app-launch-test-kit/blob/main/lib/smart_app_launch/docs/demo/FHIR%20Request.postman_collection.json). Install the app and import the collection before following these
|
58
|
+
steps.
|
59
|
+
|
60
|
+
1. Start an instance of the SMART App Launch STU2.2 Client test suite.
|
61
|
+
2. From the drop down in the upper left, select preset "Demo: Run Against the SMART Server Suite".
|
62
|
+
3. Click the "RUN ALL TESTS" button in the upper right and click "SUBMIT"
|
63
|
+
4. In a new tab, start an instance of the SMART App Launch STU2.2 Test Suite
|
64
|
+
5. From the drop down in the upper left, select preset "Demo: Run Against the SMART Client Suite"
|
65
|
+
6. Select test group **3** Backend Services from the left panel, click the "RUN TESTS" button
|
66
|
+
in the upper right, and click "SUBMIT"
|
67
|
+
7. Find the access token to use for the data access request by opening test **3.2.05** Authorization
|
68
|
+
request succeeds when supplied correct information, click on the "REQUESTS" tab, clicking on the "DETAILS"
|
69
|
+
button, and expanding the "Response Body". Copy the "access_token" value, which will be a ~100 character
|
70
|
+
string of letters and numbers (e.g., eyJjbGllbnRfaWQiOiJzbWFydF9jbGllbnRfdGVzdF9kZW1vIiwiZXhwaXJhdGlvbiI6MTc0MzUxNDk4Mywibm9uY2UiOiJlZDI5MWIwNmZhMTE4OTc4In0)
|
71
|
+
8. Open Postman and open the "FHIR Request" Collection. Click the "Variables" tab and add the copied access token
|
72
|
+
as the current value of the `bearer_token` variable. Also update the
|
73
|
+
`base_url` value for where the test is running (see details on the "Overview" tab).
|
74
|
+
Save the collection.
|
75
|
+
9. Select the "Patient Read" request and click "Send". A FHIR Patient resource should be returned.
|
76
|
+
10. Return to the client tests and click the link to continue and complete the tests.
|
77
|
+
|
78
|
+
The client tests should pass with the exception of test **1.2.02** Verify SMART Token Requests. This is
|
79
|
+
expected as the Server tests make several intentionally invalid token requests. Inferno's simulated SMART
|
80
|
+
server responds successfully to those requests when the client id can be identified, but flags them as
|
81
|
+
not conformant causing these expected failures. Because responding with an access token to non-conformant
|
82
|
+
token requests is itself not conformant there are corresponding failures on the server test in tests **3.2.02**,
|
83
|
+
**3.2.04**, and **3.2.04**. There may be other SMART server test failures due to an assumption that
|
84
|
+
servers support the app launch capabilities in addition to backend services.
|
85
|
+
|
86
|
+
### Additional Inputs
|
87
|
+
|
88
|
+
Two additional inputs are available to support testers
|
89
|
+
- **Client Id**: Testers may specify a client id for Inferno to use for the test session if they
|
90
|
+
have one already configured.
|
91
|
+
- **FHIR Response to Echo**: The focus of this test kit is on the auth protocol, so the
|
92
|
+
simulated FHIR server implemented in this test suite is very simple and will by default
|
93
|
+
return a FHIR OperationOutcome to any request made. Testers may provide a static
|
94
|
+
FHIR JSON body for Inferno to return instead. In this case, the simulation is a simple
|
95
|
+
echo and Inferno does not check that the response if appropriate for the request made.
|
96
|
+
|
97
|
+
## Current Limitations
|
98
|
+
|
99
|
+
This test kit is still in draft form and does not test all of the requirements and features
|
100
|
+
described in the SMART App Launch IG for clients. Notably, only the backend services flow
|
101
|
+
is tested at this time.
|
102
|
+
|
103
|
+
The following sections list other known gaps and limitations.
|
104
|
+
|
105
|
+
### SMART Server Simulation Limitations
|
106
|
+
|
107
|
+
This test suite contains a simulation of a SMART Backend Services server which is not fully
|
108
|
+
general and not all conformant clients may be able to interact with it. However, the intention
|
109
|
+
is not to prevent systems from passing for making conformant choices that Inferno's simulation
|
110
|
+
does not support. One specific example is that the SMART configuration metadata available at
|
111
|
+
`.well-known/smart-configuration` for the simulated server is fixed and cannot be changed by
|
112
|
+
testers at this time. Please report any issues that prevent conformant systems from passing in
|
113
|
+
the [github repository's issues page](https://github.com/inferno-framework/smart-app-launch-test-kit/issues/).
|
114
|
+
|
115
|
+
### FHIR Server Simulation Limitations
|
116
|
+
|
117
|
+
The FHIR server simulation used to support clients in demonstrating their ability to access
|
118
|
+
FHIR APIs using access tokens obtained using the SMART flows is very limited. Testers are currently
|
119
|
+
able to provide a single static response that will be echoed for any FHIR request made. While
|
120
|
+
Inferno will never implement a fully general FHIR server simulation, additional options may be added
|
121
|
+
in the future based on community feedback.
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../urls'
|
4
|
+
require_relative '../tags'
|
5
|
+
require_relative 'mock_smart_server'
|
6
|
+
|
7
|
+
module SMARTAppLaunch
|
8
|
+
class EchoingFHIRResponderEndpoint < Inferno::DSL::SuiteEndpoint
|
9
|
+
def test_run_identifier
|
10
|
+
MockSMARTServer.token_to_client_id(request.headers['authorization']&.delete_prefix('Bearer '))
|
11
|
+
end
|
12
|
+
|
13
|
+
def make_response
|
14
|
+
return if response.status == 401 # set in update_result (expired token handling there)
|
15
|
+
|
16
|
+
response.content_type = 'application/fhir+json'
|
17
|
+
|
18
|
+
# If the tester provided a response, echo it
|
19
|
+
# otherwise, operation outcome
|
20
|
+
echo_response = JSON.parse(result.input_json)
|
21
|
+
.find { |input| input['name'].include?('echoed_fhir_response') }
|
22
|
+
&.dig('value')
|
23
|
+
|
24
|
+
unless echo_response.present?
|
25
|
+
response.status = 400
|
26
|
+
response.body = FHIR::OperationOutcome.new(
|
27
|
+
issue: FHIR::OperationOutcome::Issue.new(
|
28
|
+
severity: 'fatal', code: 'required',
|
29
|
+
details: FHIR::CodeableConcept.new(text: 'No response provided to echo.')
|
30
|
+
)
|
31
|
+
).to_json
|
32
|
+
return
|
33
|
+
end
|
34
|
+
|
35
|
+
response.status = 200
|
36
|
+
response.body = echo_response
|
37
|
+
end
|
38
|
+
|
39
|
+
def update_result
|
40
|
+
if MockSMARTServer.request_has_expired_token?(request)
|
41
|
+
MockSMARTServer.update_response_for_expired_token(response)
|
42
|
+
return
|
43
|
+
end
|
44
|
+
|
45
|
+
nil # never update for now
|
46
|
+
end
|
47
|
+
|
48
|
+
def tags
|
49
|
+
[ACCESS_TAG]
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../../urls'
|
4
|
+
require_relative '../../tags'
|
5
|
+
require_relative '../mock_smart_server'
|
6
|
+
|
7
|
+
module SMARTAppLaunch
|
8
|
+
module MockSMARTServer
|
9
|
+
class TokenEndpoint < Inferno::DSL::SuiteEndpoint
|
10
|
+
def test_run_identifier
|
11
|
+
MockSMARTServer.client_id_from_client_assertion(request.params[:client_assertion])
|
12
|
+
end
|
13
|
+
|
14
|
+
def make_response
|
15
|
+
MockSMARTServer.make_smart_token_response(request, response, result)
|
16
|
+
end
|
17
|
+
|
18
|
+
def update_result
|
19
|
+
nil # never update for now
|
20
|
+
end
|
21
|
+
|
22
|
+
def tags
|
23
|
+
[TOKEN_TAG, SMART_TAG]
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,217 @@
|
|
1
|
+
require 'jwt'
|
2
|
+
require 'faraday'
|
3
|
+
require 'time'
|
4
|
+
require_relative '../urls'
|
5
|
+
require_relative '../tags'
|
6
|
+
|
7
|
+
module SMARTAppLaunch
|
8
|
+
module MockSMARTServer
|
9
|
+
SUPPORTED_SCOPES = ['openid', 'system/*.read', 'user/*.read', 'patient/*.read'].freeze
|
10
|
+
|
11
|
+
module_function
|
12
|
+
|
13
|
+
def smart_server_metadata(suite_id)
|
14
|
+
base_url = "#{Inferno::Application['base_url']}/custom/#{suite_id}"
|
15
|
+
response_body = {
|
16
|
+
token_endpoint_auth_signing_alg_values_supported: ['RS384', 'ES384'],
|
17
|
+
capabilities: ['client-confidential-asymmetric'],
|
18
|
+
code_challenge_methods_supported: ['S256'],
|
19
|
+
token_endpoint_auth_methods_supported: ['private_key_jwt'],
|
20
|
+
issuer: base_url + FHIR_PATH,
|
21
|
+
grant_types_supported: ['client_credentials'],
|
22
|
+
scopes_supported: SUPPORTED_SCOPES,
|
23
|
+
token_endpoint: base_url + TOKEN_PATH
|
24
|
+
}.to_json
|
25
|
+
|
26
|
+
[200, { 'Content-Type' => 'application/json', 'Access-Control-Allow-Origin' => '*' }, [response_body]]
|
27
|
+
end
|
28
|
+
|
29
|
+
def make_smart_token_response(request, response, result)
|
30
|
+
assertion = request.params[:client_assertion]
|
31
|
+
client_id = client_id_from_client_assertion(assertion)
|
32
|
+
|
33
|
+
key_set_input = JSON.parse(result.input_json)&.find do |input|
|
34
|
+
input['name'] == 'smart_jwk_set'
|
35
|
+
end&.dig('value')
|
36
|
+
signature_error = smart_assertion_signature_verification(assertion, key_set_input)
|
37
|
+
|
38
|
+
if signature_error.present?
|
39
|
+
update_response_for_invalid_assertion(response, signature_error)
|
40
|
+
return
|
41
|
+
end
|
42
|
+
|
43
|
+
exp_min = 60
|
44
|
+
response_body = {
|
45
|
+
access_token: client_id_to_token(client_id, exp_min),
|
46
|
+
token_type: 'Bearer',
|
47
|
+
expires_in: 60 * exp_min,
|
48
|
+
scope: request.params[:scope]
|
49
|
+
}
|
50
|
+
|
51
|
+
response.body = response_body.to_json
|
52
|
+
response.headers['Cache-Control'] = 'no-store'
|
53
|
+
response.headers['Pragma'] = 'no-cache'
|
54
|
+
response.headers['Access-Control-Allow-Origin'] = '*'
|
55
|
+
response.content_type = 'application/json'
|
56
|
+
response.status = 200
|
57
|
+
end
|
58
|
+
|
59
|
+
def client_id_from_client_assertion(client_assertion_jwt)
|
60
|
+
return unless client_assertion_jwt.present?
|
61
|
+
|
62
|
+
jwt_claims(client_assertion_jwt)&.dig('iss')
|
63
|
+
end
|
64
|
+
|
65
|
+
def parsed_request_body(request)
|
66
|
+
JSON.parse(request.request_body)
|
67
|
+
rescue JSON::ParserError
|
68
|
+
nil
|
69
|
+
end
|
70
|
+
|
71
|
+
def parsed_io_body(request)
|
72
|
+
parsed_body = begin
|
73
|
+
JSON.parse(request.body.read)
|
74
|
+
rescue JSON::ParserError
|
75
|
+
nil
|
76
|
+
end
|
77
|
+
request.body.rewind
|
78
|
+
|
79
|
+
parsed_body
|
80
|
+
end
|
81
|
+
|
82
|
+
def jwt_claims(encoded_jwt)
|
83
|
+
JWT.decode(encoded_jwt, nil, false)[0]
|
84
|
+
end
|
85
|
+
|
86
|
+
def client_uri_to_client_id(client_uri)
|
87
|
+
Base64.urlsafe_encode64(client_uri, padding: false)
|
88
|
+
end
|
89
|
+
|
90
|
+
def client_id_to_client_uri(client_id)
|
91
|
+
Base64.urlsafe_decode64(client_id)
|
92
|
+
end
|
93
|
+
|
94
|
+
def client_id_to_token(client_id, exp_min)
|
95
|
+
token_structure = {
|
96
|
+
client_id:,
|
97
|
+
expiration: exp_min.minutes.from_now.to_i,
|
98
|
+
nonce: SecureRandom.hex(8)
|
99
|
+
}.to_json
|
100
|
+
|
101
|
+
Base64.urlsafe_encode64(token_structure, padding: false)
|
102
|
+
end
|
103
|
+
|
104
|
+
def decode_token(token)
|
105
|
+
JSON.parse(Base64.urlsafe_decode64(token))
|
106
|
+
rescue JSON::ParserError
|
107
|
+
nil
|
108
|
+
end
|
109
|
+
|
110
|
+
def token_to_client_id(token)
|
111
|
+
decode_token(token)&.dig('client_id')
|
112
|
+
end
|
113
|
+
|
114
|
+
def jwk_set(jku, warning_messages = []) # rubocop:disable Metrics/CyclomaticComplexity
|
115
|
+
jwk_set = JWT::JWK::Set.new
|
116
|
+
|
117
|
+
if jku.blank?
|
118
|
+
warning_messages << 'No key set input.'
|
119
|
+
return jwk_set
|
120
|
+
end
|
121
|
+
|
122
|
+
jwk_body = # try as raw jwk set
|
123
|
+
begin
|
124
|
+
JSON.parse(jku)
|
125
|
+
rescue JSON::ParserError
|
126
|
+
nil
|
127
|
+
end
|
128
|
+
|
129
|
+
if jwk_body.blank?
|
130
|
+
retrieved = Faraday.get(jku) # try as url pointing to a jwk set
|
131
|
+
jwk_body =
|
132
|
+
begin
|
133
|
+
JSON.parse(retrieved.body)
|
134
|
+
rescue JSON::ParserError
|
135
|
+
warning_messages << "Failed to fetch valid json from jwks uri #{jwk_set}."
|
136
|
+
nil
|
137
|
+
end
|
138
|
+
else
|
139
|
+
warning_messages << 'Providing the JWK Set directly is strongly discouraged.'
|
140
|
+
end
|
141
|
+
|
142
|
+
return jwk_set if jwk_body.blank?
|
143
|
+
|
144
|
+
jwk_body['keys']&.each_with_index do |key_hash, index|
|
145
|
+
parsed_key =
|
146
|
+
begin
|
147
|
+
JWT::JWK.new(key_hash)
|
148
|
+
rescue JWT::JWKError => e
|
149
|
+
id = key_hash['kid'] | index
|
150
|
+
warning_messages << "Key #{id} invalid: #{e}"
|
151
|
+
nil
|
152
|
+
end
|
153
|
+
jwk_set << parsed_key unless parsed_key.blank?
|
154
|
+
end
|
155
|
+
|
156
|
+
jwk_set
|
157
|
+
end
|
158
|
+
|
159
|
+
def request_has_expired_token?(request)
|
160
|
+
return false if request.params[:session_path].present?
|
161
|
+
|
162
|
+
token = request.headers['authorization']&.delete_prefix('Bearer ')
|
163
|
+
decoded_token = decode_token(token)
|
164
|
+
return false unless decoded_token&.dig('expiration').present?
|
165
|
+
|
166
|
+
decoded_token['expiration'] < Time.now.to_i
|
167
|
+
end
|
168
|
+
|
169
|
+
def update_response_for_expired_token(response)
|
170
|
+
response.status = 401
|
171
|
+
response.format = :json
|
172
|
+
response.body = FHIR::OperationOutcome.new(
|
173
|
+
issue: FHIR::OperationOutcome::Issue.new(severity: 'fatal', code: 'expired',
|
174
|
+
details: FHIR::CodeableConcept.new(text: 'Bearer token has expired'))
|
175
|
+
).to_json
|
176
|
+
end
|
177
|
+
|
178
|
+
def smart_assertion_signature_verification(token, key_set_input) # rubocop:disable Metrics/CyclomaticComplexity
|
179
|
+
encoded_token = nil
|
180
|
+
if token.is_a?(JWT::EncodedToken)
|
181
|
+
encoded_token = token
|
182
|
+
else
|
183
|
+
begin
|
184
|
+
encoded_token = JWT::EncodedToken.new(token)
|
185
|
+
rescue StandardError => e
|
186
|
+
return "invalid token structure: #{e}"
|
187
|
+
end
|
188
|
+
end
|
189
|
+
return 'invalid token' unless encoded_token.present?
|
190
|
+
return 'missing `alg` header' if encoded_token.header['alg'].blank?
|
191
|
+
return 'missing `kid` header' if encoded_token.header['kid'].blank?
|
192
|
+
|
193
|
+
jwk = identify_smart_signing_key(encoded_token.header['kid'], encoded_token.header['jku'], key_set_input)
|
194
|
+
return "no key found with `kid` '#{encoded_token.header['kid']}'" if jwk.blank?
|
195
|
+
|
196
|
+
begin
|
197
|
+
encoded_token.verify_signature!(algorithm: encoded_token.header['alg'], key: jwk.verify_key)
|
198
|
+
rescue StandardError => e
|
199
|
+
return e
|
200
|
+
end
|
201
|
+
|
202
|
+
nil
|
203
|
+
end
|
204
|
+
|
205
|
+
def identify_smart_signing_key(kid, jku, key_set_input)
|
206
|
+
key_set = jku.present? ? jku : key_set_input
|
207
|
+
parsed_key_set = jwk_set(key_set)
|
208
|
+
parsed_key_set&.find { |key| key.kid == kid }
|
209
|
+
end
|
210
|
+
|
211
|
+
def update_response_for_invalid_assertion(response, error_message)
|
212
|
+
response.status = 401
|
213
|
+
response.format = :json
|
214
|
+
response.body = { error: 'invalid_client', error_description: error_message }.to_json
|
215
|
+
end
|
216
|
+
end
|
217
|
+
end
|
@@ -64,12 +64,12 @@ module SMARTAppLaunch
|
|
64
64
|
section](https://github.com/inferno-framework/smart-app-launch-test-kit/issues)
|
65
65
|
of the repository.
|
66
66
|
DESCRIPTION
|
67
|
-
suite_ids [:smart, :smart_stu2, :smart_stu2_2, :smart_access_brands]
|
67
|
+
suite_ids [:smart, :smart_stu2, :smart_stu2_2, :smart_access_brands, :smart_client_stu2_2]
|
68
68
|
tags ['SMART App Launch', 'Endpoint Publication']
|
69
69
|
last_updated LAST_UPDATED
|
70
70
|
version VERSION
|
71
71
|
maturity 'Medium'
|
72
|
-
authors ['Stephen MacVicar']
|
72
|
+
authors ['Stephen MacVicar', 'Karl Naden']
|
73
73
|
repo 'https://github.com/inferno-framework/smart-app-launch-test-kit'
|
74
74
|
end
|
75
75
|
end
|
@@ -262,7 +262,8 @@ module SMARTAppLaunch
|
|
262
262
|
smart_auth_info: { name: :backend_services_smart_auth_info }
|
263
263
|
},
|
264
264
|
outputs: {
|
265
|
-
smart_auth_info: { name: :backend_services_smart_auth_info }
|
265
|
+
smart_auth_info: { name: :backend_services_smart_auth_info },
|
266
|
+
received_scopes: { name: :backend_services_received_scopes }
|
266
267
|
}
|
267
268
|
}
|
268
269
|
end
|
@@ -260,7 +260,8 @@ module SMARTAppLaunch
|
|
260
260
|
smart_auth_info: { name: :backend_services_smart_auth_info }
|
261
261
|
},
|
262
262
|
outputs: {
|
263
|
-
smart_auth_info: { name: :backend_services_smart_auth_info }
|
263
|
+
smart_auth_info: { name: :backend_services_smart_auth_info },
|
264
|
+
received_scopes: { name: :backend_services_received_scopes }
|
264
265
|
}
|
265
266
|
}
|
266
267
|
end
|
@@ -53,7 +53,7 @@ module SMARTAppLaunch
|
|
53
53
|
introspection response and should match the claim in the ID token
|
54
54
|
)
|
55
55
|
|
56
|
-
input :standalone_smart_auth_info, type: :auth_info, options: { mode: '
|
56
|
+
input :standalone_smart_auth_info, type: :auth_info, options: { mode: 'access' }
|
57
57
|
|
58
58
|
input :standalone_received_scopes,
|
59
59
|
title: 'Expected Introspection Response Value: scope',
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SMARTAppLaunch
|
4
|
+
FHIR_PATH = '/fhir'
|
5
|
+
RESUME_PASS_PATH = '/resume_pass'
|
6
|
+
RESUME_FAIL_PATH = '/resume_fail'
|
7
|
+
AUTH_SERVER_PATH = '/auth'
|
8
|
+
SMART_DISCOVERY_PATH = "#{FHIR_PATH}/.well-known/smart-configuration".freeze
|
9
|
+
TOKEN_PATH = "#{AUTH_SERVER_PATH}/token".freeze
|
10
|
+
|
11
|
+
module URLs
|
12
|
+
def client_base_url
|
13
|
+
@client_base_url ||= "#{Inferno::Application['base_url']}/custom/#{client_suite_id}"
|
14
|
+
end
|
15
|
+
|
16
|
+
def client_fhir_base_url
|
17
|
+
@client_fhir_base_url ||= client_base_url + FHIR_PATH
|
18
|
+
end
|
19
|
+
|
20
|
+
def client_resume_pass_url
|
21
|
+
@client_resume_pass_url ||= client_base_url + RESUME_PASS_PATH
|
22
|
+
end
|
23
|
+
|
24
|
+
def client_resume_fail_url
|
25
|
+
@client_resume_fail_url ||= client_base_url + RESUME_FAIL_PATH
|
26
|
+
end
|
27
|
+
|
28
|
+
def client_smart_discovery_url
|
29
|
+
@client_smart_discovery_url ||= client_base_url + SMART_DISCOVERY_PATH
|
30
|
+
end
|
31
|
+
|
32
|
+
def client_token_url
|
33
|
+
@client_token_url ||= client_base_url + TOKEN_PATH
|
34
|
+
end
|
35
|
+
|
36
|
+
def client_suite_id
|
37
|
+
SMARTAppLaunch::SMARTClientSTU22Suite.id
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -5,3 +5,4 @@ require_relative 'smart_app_launch/smart_stu1_suite'
|
|
5
5
|
require_relative 'smart_app_launch/smart_stu2_suite'
|
6
6
|
require_relative 'smart_app_launch/smart_stu2_2_suite'
|
7
7
|
require_relative 'smart_app_launch/smart_access_brands_suite'
|
8
|
+
require_relative 'smart_app_launch/client_stu2_2_suite'
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: smart_app_launch_test_kit
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.6.
|
4
|
+
version: 0.6.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Stephen MacVicar
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-04-07 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: inferno_core
|
@@ -145,6 +145,8 @@ extensions: []
|
|
145
145
|
extra_rdoc_files: []
|
146
146
|
files:
|
147
147
|
- LICENSE
|
148
|
+
- config/presets/SMART_RunClientAgainstServer.json.erb
|
149
|
+
- config/presets/SMART_RunServerAgainstClient.json.erb
|
148
150
|
- config/presets/inferno_reference_server_preset.json
|
149
151
|
- config/presets/inferno_reference_server_stu2_2_preset.json
|
150
152
|
- config/presets/inferno_reference_server_stu2_preset.json
|
@@ -164,6 +166,13 @@ files:
|
|
164
166
|
- lib/smart_app_launch/backend_services_invalid_grant_type_test.rb
|
165
167
|
- lib/smart_app_launch/backend_services_invalid_jwt_test.rb
|
166
168
|
- lib/smart_app_launch/client_assertion_builder.rb
|
169
|
+
- lib/smart_app_launch/client_stu2_2_suite.rb
|
170
|
+
- lib/smart_app_launch/client_suite/client_access_group.rb
|
171
|
+
- lib/smart_app_launch/client_suite/client_access_interaction_test.rb
|
172
|
+
- lib/smart_app_launch/client_suite/client_registration_group.rb
|
173
|
+
- lib/smart_app_launch/client_suite/client_registration_verification_test.rb
|
174
|
+
- lib/smart_app_launch/client_suite/client_token_request_verification_test.rb
|
175
|
+
- lib/smart_app_launch/client_suite/client_token_use_verification_test.rb
|
167
176
|
- lib/smart_app_launch/code_received_test.rb
|
168
177
|
- lib/smart_app_launch/cors_metadata_request_test.rb
|
169
178
|
- lib/smart_app_launch/cors_openid_fhir_user_claim_test.rb
|
@@ -172,9 +181,14 @@ files:
|
|
172
181
|
- lib/smart_app_launch/discovery_stu1_group.rb
|
173
182
|
- lib/smart_app_launch/discovery_stu2_2_group.rb
|
174
183
|
- lib/smart_app_launch/discovery_stu2_group.rb
|
184
|
+
- lib/smart_app_launch/docs/demo/FHIR Request.postman_collection.json
|
185
|
+
- lib/smart_app_launch/docs/smart_stu2_2_client_suite_description.md
|
175
186
|
- lib/smart_app_launch/ehr_launch_group.rb
|
176
187
|
- lib/smart_app_launch/ehr_launch_group_stu2.rb
|
177
188
|
- lib/smart_app_launch/ehr_launch_group_stu2_2.rb
|
189
|
+
- lib/smart_app_launch/endpoints/echoing_fhir_responder.rb
|
190
|
+
- lib/smart_app_launch/endpoints/mock_smart_server.rb
|
191
|
+
- lib/smart_app_launch/endpoints/mock_smart_server/token.rb
|
178
192
|
- lib/smart_app_launch/jwks.rb
|
179
193
|
- lib/smart_app_launch/launch_received_test.rb
|
180
194
|
- lib/smart_app_launch/metadata.rb
|
@@ -207,6 +221,7 @@ files:
|
|
207
221
|
- lib/smart_app_launch/standalone_launch_group.rb
|
208
222
|
- lib/smart_app_launch/standalone_launch_group_stu2.rb
|
209
223
|
- lib/smart_app_launch/standalone_launch_group_stu2_2.rb
|
224
|
+
- lib/smart_app_launch/tags.rb
|
210
225
|
- lib/smart_app_launch/token_exchange_stu2_2_test.rb
|
211
226
|
- lib/smart_app_launch/token_exchange_stu2_test.rb
|
212
227
|
- lib/smart_app_launch/token_exchange_test.rb
|
@@ -226,6 +241,7 @@ files:
|
|
226
241
|
- lib/smart_app_launch/token_response_body_test_stu2_2.rb
|
227
242
|
- lib/smart_app_launch/token_response_headers_test.rb
|
228
243
|
- lib/smart_app_launch/url_helpers.rb
|
244
|
+
- lib/smart_app_launch/urls.rb
|
229
245
|
- lib/smart_app_launch/version.rb
|
230
246
|
- lib/smart_app_launch/well_known_capabilities_stu1_test.rb
|
231
247
|
- lib/smart_app_launch/well_known_capabilities_stu2_test.rb
|