smart_app_launch_test_kit 0.2.2 → 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/smart_app_launch/app_redirect_test.rb +3 -1
- data/lib/smart_app_launch/app_redirect_test_stu2.rb +1 -1
- data/lib/smart_app_launch/client_assertion_builder.rb +63 -0
- data/lib/smart_app_launch/ehr_launch_group.rb +2 -1
- data/lib/smart_app_launch/ehr_launch_group_stu2.rb +8 -0
- data/lib/smart_app_launch/jwks.rb +27 -0
- data/lib/smart_app_launch/smart_jwks.json +58 -0
- data/lib/smart_app_launch/smart_stu1_suite.rb +33 -0
- data/lib/smart_app_launch/smart_stu2_suite.rb +66 -0
- data/lib/smart_app_launch/standalone_launch_group.rb +2 -1
- data/lib/smart_app_launch/standalone_launch_group_stu2.rb +8 -0
- data/lib/smart_app_launch/token_exchange_stu2_test.rb +92 -0
- data/lib/smart_app_launch/token_exchange_test.rb +15 -8
- data/lib/smart_app_launch/token_introspection_access_token_group.rb +26 -0
- data/lib/smart_app_launch/token_introspection_group.rb +69 -0
- data/lib/smart_app_launch/token_introspection_request_group.rb +122 -0
- data/lib/smart_app_launch/token_introspection_response_group.rb +191 -0
- data/lib/smart_app_launch/token_payload_validation.rb +83 -0
- data/lib/smart_app_launch/token_response_body_test.rb +4 -0
- data/lib/smart_app_launch/version.rb +1 -1
- metadata +12 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 60325a0cc4d2866edb260627b289e76676d0847bd2e28523556f39170d424dac
|
4
|
+
data.tar.gz: dd887ba5a645f71f16e9f2269886628ca5911d57c79e3d03699582d6ae810b7f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 16ee6a9963940d5ea882e894b5e1db23646c86a51e1f6dbe58868878c047708a581c0f353ba24d264a4c49cdc8f549f522afc40ee7bbd5127d89b0b9ac7f2e36
|
7
|
+
data.tar.gz: 022f7b43c590f419638e23614a84dc0c5d106b87bbef38dd6d4b2e71650b58bf86db59bd6bb231c04280a0274e105b6486601fb41fa3749485ddfd534a15beb4
|
@@ -75,7 +75,7 @@ module SMARTAppLaunch
|
|
75
75
|
def authorization_url_builder(url, params)
|
76
76
|
uri = URI(url)
|
77
77
|
|
78
|
-
# because the URL might have
|
78
|
+
# because the URL might have parameters on it
|
79
79
|
original_parameters = Hash[URI.decode_www_form(uri.query || '')]
|
80
80
|
new_params = original_parameters.merge(params)
|
81
81
|
|
@@ -126,6 +126,8 @@ module SMARTAppLaunch
|
|
126
126
|
oauth2_params
|
127
127
|
)
|
128
128
|
|
129
|
+
info("Inferno redirecting browser to #{authorization_url}.")
|
130
|
+
|
129
131
|
wait(
|
130
132
|
identifier: state,
|
131
133
|
message: wait_message(authorization_url)
|
@@ -0,0 +1,63 @@
|
|
1
|
+
require 'jwt'
|
2
|
+
|
3
|
+
require_relative 'jwks'
|
4
|
+
|
5
|
+
module SMARTAppLaunch
|
6
|
+
class ClientAssertionBuilder
|
7
|
+
def self.build(...)
|
8
|
+
new(...).client_assertion
|
9
|
+
end
|
10
|
+
|
11
|
+
attr_reader :aud,
|
12
|
+
:client_assertion_type,
|
13
|
+
:content_type,
|
14
|
+
:client_auth_encryption_method,
|
15
|
+
:exp,
|
16
|
+
:grant_type,
|
17
|
+
:iss,
|
18
|
+
:jti,
|
19
|
+
:sub
|
20
|
+
|
21
|
+
def initialize(
|
22
|
+
client_auth_encryption_method:,
|
23
|
+
iss:,
|
24
|
+
sub:,
|
25
|
+
aud:,
|
26
|
+
exp: 5.minutes.from_now.to_i,
|
27
|
+
jti: SecureRandom.hex(32)
|
28
|
+
)
|
29
|
+
@client_auth_encryption_method = client_auth_encryption_method
|
30
|
+
@iss = iss
|
31
|
+
@sub = sub
|
32
|
+
@aud = aud
|
33
|
+
@content_type = content_type
|
34
|
+
@grant_type = grant_type
|
35
|
+
@client_assertion_type = client_assertion_type
|
36
|
+
@exp = exp
|
37
|
+
@jti = jti
|
38
|
+
end
|
39
|
+
|
40
|
+
def private_key
|
41
|
+
@private_key ||=
|
42
|
+
JWKS.jwks
|
43
|
+
.find { |key| key[:key_ops]&.include?('sign') && key[:alg] == client_auth_encryption_method }
|
44
|
+
end
|
45
|
+
|
46
|
+
def jwt_payload
|
47
|
+
{ iss:, sub:, aud:, exp:, jti: }.compact
|
48
|
+
end
|
49
|
+
|
50
|
+
def kid
|
51
|
+
private_key.kid
|
52
|
+
end
|
53
|
+
|
54
|
+
def signing_key
|
55
|
+
private_key.signing_key
|
56
|
+
end
|
57
|
+
|
58
|
+
def client_assertion
|
59
|
+
@client_assertion ||=
|
60
|
+
JWT.encode jwt_payload, signing_key, client_auth_encryption_method, { alg: client_auth_encryption_method, kid:, typ: 'JWT' }
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -48,7 +48,8 @@ module SMARTAppLaunch
|
|
48
48
|
client_secret: {
|
49
49
|
name: :ehr_client_secret,
|
50
50
|
title: 'EHR Launch Client Secret',
|
51
|
-
description: 'Client Secret provided during registration of Inferno as an EHR launch application'
|
51
|
+
description: 'Client Secret provided during registration of Inferno as an EHR launch application. ' \
|
52
|
+
'Only for clients using confidential symmetric authentication.'
|
52
53
|
},
|
53
54
|
requested_scopes: {
|
54
55
|
name: :ehr_requested_scopes,
|
@@ -1,5 +1,6 @@
|
|
1
1
|
require_relative 'app_redirect_test_stu2'
|
2
2
|
require_relative 'ehr_launch_group'
|
3
|
+
require_relative 'token_exchange_stu2_test'
|
3
4
|
|
4
5
|
module SMARTAppLaunch
|
5
6
|
class EHRLaunchGroupSTU2 < EHRLaunchGroup
|
@@ -52,5 +53,12 @@ module SMARTAppLaunch
|
|
52
53
|
|
53
54
|
redirect_index = children.find_index { |child| child.id.to_s.end_with? 'app_redirect' }
|
54
55
|
children[redirect_index] = children.pop
|
56
|
+
|
57
|
+
test from: :smart_token_exchange_stu2
|
58
|
+
|
59
|
+
token_exchange_index = children.find_index { |child| child.id.to_s.end_with? 'token_exchange' }
|
60
|
+
children[token_exchange_index] = children.pop
|
61
|
+
|
62
|
+
children[token_exchange_index].id(:smart_token_exchange)
|
55
63
|
end
|
56
64
|
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'jwt'
|
2
|
+
|
3
|
+
module SMARTAppLaunch
|
4
|
+
class JWKS
|
5
|
+
class << self
|
6
|
+
def jwks_json
|
7
|
+
@jwks_json ||=
|
8
|
+
JSON.pretty_generate(
|
9
|
+
{ keys: jwks.export[:keys].select { |key| key[:key_ops]&.include?('verify') } }
|
10
|
+
)
|
11
|
+
end
|
12
|
+
|
13
|
+
def default_jwks_path
|
14
|
+
@default_jwks_path ||= File.join(__dir__, 'smart_jwks.json')
|
15
|
+
end
|
16
|
+
|
17
|
+
def jwks_path
|
18
|
+
@jwks_path ||=
|
19
|
+
ENV.fetch('SMART_JWKS_PATH', default_jwks_path)
|
20
|
+
end
|
21
|
+
|
22
|
+
def jwks
|
23
|
+
@jwks ||= JWT::JWK::Set.new(JSON.parse(File.read(jwks_path)))
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
{
|
2
|
+
"keys":
|
3
|
+
[{
|
4
|
+
"kty": "EC",
|
5
|
+
"crv": "P-384",
|
6
|
+
"x": "JQKTsV6PT5Szf4QtDA1qrs0EJ1pbimQmM2SKvzOlIAqlph3h1OHmZ2i7MXahIF2C",
|
7
|
+
"y": "bRWWQRJBgDa6CTgwofYrHjVGcO-A7WNEnu4oJA5OUJPPPpczgx1g2NsfinK-D2Rw",
|
8
|
+
"use": "sig",
|
9
|
+
"key_ops": [
|
10
|
+
"verify"
|
11
|
+
],
|
12
|
+
"ext": true,
|
13
|
+
"kid": "4b49a739d1eb115b3225f4cf9beb6d1b",
|
14
|
+
"alg": "ES384"
|
15
|
+
},
|
16
|
+
{
|
17
|
+
"kty": "EC",
|
18
|
+
"crv": "P-384",
|
19
|
+
"d": "kDkn55p7gryKk2tj6z2ij7ExUnhi0ngxXosvqa73y7epwgthFqaJwApmiXXU2yhK",
|
20
|
+
"x": "JQKTsV6PT5Szf4QtDA1qrs0EJ1pbimQmM2SKvzOlIAqlph3h1OHmZ2i7MXahIF2C",
|
21
|
+
"y": "bRWWQRJBgDa6CTgwofYrHjVGcO-A7WNEnu4oJA5OUJPPPpczgx1g2NsfinK-D2Rw",
|
22
|
+
"key_ops": [
|
23
|
+
"sign"
|
24
|
+
],
|
25
|
+
"ext": true,
|
26
|
+
"kid": "4b49a739d1eb115b3225f4cf9beb6d1b",
|
27
|
+
"alg": "ES384"
|
28
|
+
},
|
29
|
+
{
|
30
|
+
"kty": "RSA",
|
31
|
+
"alg": "RS384",
|
32
|
+
"n": "vjbIzTqiY8K8zApeNng5ekNNIxJfXAue9BjoMrZ9Qy9m7yIA-tf6muEupEXWhq70tC7vIGLqJJ4O8m7yiH8H2qklX2mCAMg3xG3nbykY2X7JXtW9P8VIdG0sAMt5aZQnUGCgSS3n0qaooGn2LUlTGIR88Qi-4Nrao9_3Ki3UCiICeCiAE224jGCg0OlQU6qj2gEB3o-DWJFlG_dz1y-Mxo5ivaeM0vWuodjDrp-aiabJcSF_dx26sdC9dZdBKXFDq0t19I9S9AyGpGDJwzGRtWHY6LsskNHLvo8Zb5AsJ9eRZKpnh30SYBZI9WHtzU85M9WQqdScR69Vyp-6Uhfbvw",
|
33
|
+
"e": "AQAB",
|
34
|
+
"use": "sig",
|
35
|
+
"key_ops": [
|
36
|
+
"verify"
|
37
|
+
],
|
38
|
+
"ext": true,
|
39
|
+
"kid": "b41528b6f37a9500edb8a905a595bdd7"
|
40
|
+
},
|
41
|
+
{
|
42
|
+
"kty": "RSA",
|
43
|
+
"alg": "RS384",
|
44
|
+
"n": "vjbIzTqiY8K8zApeNng5ekNNIxJfXAue9BjoMrZ9Qy9m7yIA-tf6muEupEXWhq70tC7vIGLqJJ4O8m7yiH8H2qklX2mCAMg3xG3nbykY2X7JXtW9P8VIdG0sAMt5aZQnUGCgSS3n0qaooGn2LUlTGIR88Qi-4Nrao9_3Ki3UCiICeCiAE224jGCg0OlQU6qj2gEB3o-DWJFlG_dz1y-Mxo5ivaeM0vWuodjDrp-aiabJcSF_dx26sdC9dZdBKXFDq0t19I9S9AyGpGDJwzGRtWHY6LsskNHLvo8Zb5AsJ9eRZKpnh30SYBZI9WHtzU85M9WQqdScR69Vyp-6Uhfbvw",
|
45
|
+
"e": "AQAB",
|
46
|
+
"d": "rriV9GYimi5by7TOW4xNh6_gYBHVRDBsft2OFF8qapdVHt2GNuRDDxc_B6ga6TY2Enh2MLKLTr1dD3W4FIdTCJiMerrorp07FJS7nJEMgWQDxrfgkX4_EqrhW42L5d4vypYnRXEEW6u4gzkx5uFOkdvJBIK7CsIdSaBFYhochnynNgvbKWasi4rl2hayEH8tdf3B7Z6OIH9alspBTaq3j_zJt_KkrpYEzIUb4UgALB5NTWn5YKr0Avk_asOg8YfjViQwO9ASGaWjQeJ2Rx8OEQwBMQHSDMCSWNiWmYOu9PcwSZFc1vLxqzyIM8QrQSJHCCMo_wGYgke_r0CLeONHEQ",
|
47
|
+
"p": "5hH_QApWGeobRi1n7XbMfJYohB8K3JDPa0MspfplHpJ-17JiGG2sNoBdBcpaPRf9OX48P8VqO0qrSSRAk-I-uO6OO9BHbIukXJILqnY2JmurYzbcYbt5FVbknlHRJojkF6-7sFBazpueUlOnXCw7X7Z_SkfNE4QX5Ejm2Zm5mek",
|
48
|
+
"q": "06bZz7c7K9s1-aEZsxYnLJ9eTpKlt1tIBDA_LwIh5W3w259pes2kUtimbnkyOf-V2ZIERsFCh5s-S9IOEMvAIa6M5j9GW1ILNT7AcHIUfcyFcH-FF8BU_KJdRP5PXnIXFdYcylvsdoIdchy1AaUIzyiKRCU3HBYI75hez0l_F2c",
|
49
|
+
"dp": "h_sVIXW6hCCRND48EedIX06k7conMkxIu_39ErDXOWWeoMAnKIcR5TijQnviL__QxD1vQMXezuKIMHfDz2RGbClbWdD1lhtG7wvG515tDPJQXxia0wzqOQmdoFF9S8hXAAT26vPjaAAkaEZXQaxG_4Au5elgNWu6b0wDXZN1Vpk",
|
50
|
+
"dq": "GqS0YpuUTU8JGmWXUJ4HTGy7eHSpe8134V8ZdRd1oOYYHe2RX64nc25mdR24nuh3uq3Q7_9AGsYGL5E_yAl-JD9O6WUpvDE1y_wcSYty3Os0GRdUb8r8Z9kgmKDS6Pa_xTXw5eBwgfKbNlQ6zPwzgbB-x1lP-K8lbNPni3ybDR0",
|
51
|
+
"qi": "cqQfoi0sM5Su8ZOhznmdWrDIQB28H6fBKiabgaIKkbWZV4e0nwFvLquHjPOvv4Ao8iEGU5dyhvg0n5BKYPi-4mp6M6OA1sy0NrTr7EsKSYGyu2pBq9rw4oAYTM2LXKg6K-awgUUlkc451IwxHBAe15PWCBM3kvLQeijNid0Vz5I",
|
52
|
+
"key_ops": [
|
53
|
+
"sign"
|
54
|
+
],
|
55
|
+
"ext": true,
|
56
|
+
"kid": "b41528b6f37a9500edb8a905a595bdd7"
|
57
|
+
}]
|
58
|
+
}
|
@@ -1,5 +1,6 @@
|
|
1
1
|
require 'tls_test_kit'
|
2
2
|
|
3
|
+
require_relative 'jwks'
|
3
4
|
require_relative 'version'
|
4
5
|
require_relative 'discovery_stu1_group'
|
5
6
|
require_relative 'standalone_launch_group'
|
@@ -21,15 +22,39 @@ module SMARTAppLaunch
|
|
21
22
|
request.query_parameters['state']
|
22
23
|
end
|
23
24
|
|
25
|
+
route(
|
26
|
+
:get,
|
27
|
+
'/.well-known/jwks.json',
|
28
|
+
->(_env) { [200, { 'Content-Type' => 'application/json' }, [JWKS.jwks_json]] }
|
29
|
+
)
|
30
|
+
|
24
31
|
config options: {
|
25
32
|
redirect_uri: "#{Inferno::Application['base_url']}/custom/smart/redirect",
|
26
33
|
launch_uri: "#{Inferno::Application['base_url']}/custom/smart/launch"
|
27
34
|
}
|
28
35
|
|
36
|
+
description <<~DESCRIPTION
|
37
|
+
The SMART App Launch Test Suite verifies that systems correctly implement
|
38
|
+
the [SMART App Launch IG](http://hl7.org/fhir/smart-app-launch/1.0.0/)
|
39
|
+
for providing authorization and/or authentication services to client
|
40
|
+
applications accessing HL7® FHIR® APIs. To get started, please first register
|
41
|
+
the Inferno client as a SMART App with the following information:
|
42
|
+
|
43
|
+
* SMART Launch URI: `#{config.options[:launch_uri]}`
|
44
|
+
* OAuth Redirect URI: `#{config.options[:redirect_uri]}`
|
45
|
+
DESCRIPTION
|
46
|
+
|
29
47
|
group do
|
30
48
|
title 'Standalone Launch'
|
31
49
|
id :smart_full_standalone_launch
|
32
50
|
|
51
|
+
input_instructions <<~INSTRUCTIONS
|
52
|
+
Please register the Inferno client as a SMART App with the following
|
53
|
+
information:
|
54
|
+
|
55
|
+
* OAuth Redirect URI: `#{config.options[:redirect_uri]}`
|
56
|
+
INSTRUCTIONS
|
57
|
+
|
33
58
|
run_as_group
|
34
59
|
|
35
60
|
group from: :smart_discovery
|
@@ -92,6 +117,14 @@ module SMARTAppLaunch
|
|
92
117
|
title 'EHR Launch'
|
93
118
|
id :smart_full_ehr_launch
|
94
119
|
|
120
|
+
input_instructions <<~INSTRUCTIONS
|
121
|
+
Please register the Inferno client as a SMART App with the following
|
122
|
+
information:
|
123
|
+
|
124
|
+
* SMART Launch URI: `#{config.options[:launch_uri]}`
|
125
|
+
* OAuth Redirect URI: `#{config.options[:redirect_uri]}`
|
126
|
+
INSTRUCTIONS
|
127
|
+
|
95
128
|
run_as_group
|
96
129
|
|
97
130
|
group from: :smart_discovery
|
@@ -1,10 +1,12 @@
|
|
1
1
|
require 'tls_test_kit'
|
2
2
|
|
3
|
+
require_relative 'jwks'
|
3
4
|
require_relative 'version'
|
4
5
|
require_relative 'discovery_stu2_group'
|
5
6
|
require_relative 'standalone_launch_group_stu2'
|
6
7
|
require_relative 'ehr_launch_group_stu2'
|
7
8
|
require_relative 'openid_connect_group'
|
9
|
+
require_relative 'token_introspection_group'
|
8
10
|
require_relative 'token_refresh_group'
|
9
11
|
|
10
12
|
module SMARTAppLaunch
|
@@ -21,6 +23,12 @@ module SMARTAppLaunch
|
|
21
23
|
request.query_parameters['state']
|
22
24
|
end
|
23
25
|
|
26
|
+
route(
|
27
|
+
:get,
|
28
|
+
'/.well-known/jwks.json',
|
29
|
+
->(_env) { [200, { 'Content-Type' => 'application/json' }, [JWKS.jwks_json]] }
|
30
|
+
)
|
31
|
+
|
24
32
|
@post_auth_page = File.read(File.join(__dir__, 'post_auth.html'))
|
25
33
|
post_auth_handler = proc { [200, {}, [@post_auth_page]] }
|
26
34
|
|
@@ -32,10 +40,52 @@ module SMARTAppLaunch
|
|
32
40
|
post_authorization_uri: "#{Inferno::Application['base_url']}/custom/smart_stu2/post_auth"
|
33
41
|
}
|
34
42
|
|
43
|
+
description <<~DESCRIPTION
|
44
|
+
The SMART App Launch Test Suite verifies that systems correctly implement
|
45
|
+
the [SMART App Launch IG](http://hl7.org/fhir/smart-app-launch/STU2/)
|
46
|
+
for providing authorization and/or authentication services to client
|
47
|
+
applications accessing HL7® FHIR® APIs. To get started, please first register
|
48
|
+
the Inferno client as a SMART App with the following information:
|
49
|
+
|
50
|
+
* SMART Launch URI: `#{config.options[:launch_uri]}`
|
51
|
+
* OAuth Redirect URI: `#{config.options[:redirect_uri]}`
|
52
|
+
|
53
|
+
If using asymmetric client authentication, register Inferno with the
|
54
|
+
following JWK Set URL:
|
55
|
+
|
56
|
+
* `#{Inferno::Application[:base_url]}/custom/smart_stu2/.well-known/jwks.json`
|
57
|
+
DESCRIPTION
|
58
|
+
|
59
|
+
input_instructions %(
|
60
|
+
When running tests at this level, the token introspection endpoint is not available as a manual input.
|
61
|
+
Instead, group 3 Token Introspection will assume the token introspection endpoint
|
62
|
+
will be output from group 1 Standalone Launch tests, specifically the SMART On FHIR Discovery tests that query
|
63
|
+
the .well-known/smart-configuration endpoint. However, including the token introspection
|
64
|
+
endpoint as part of the well-known ouput is NOT required and is not formally checked in the SMART On FHIR Discovery
|
65
|
+
tests. RFC-7662 on Token Introspection says that "The means by which the protected resource discovers the location of the introspection
|
66
|
+
endpoint are outside the scope of this specification" and the Token Introspection IG does not add any further
|
67
|
+
requirements to this.
|
68
|
+
|
69
|
+
If the token introspection endpoint of the system under test is NOT available at .well-known/smart-configuration,
|
70
|
+
please run the test groups individually and group 3 Token Introspection will include the introspection endpoint as a manual input.
|
71
|
+
)
|
72
|
+
|
35
73
|
group do
|
36
74
|
title 'Standalone Launch'
|
37
75
|
id :smart_full_standalone_launch
|
38
76
|
|
77
|
+
input_instructions <<~INSTRUCTIONS
|
78
|
+
Please register the Inferno client as a SMART App with the following
|
79
|
+
information:
|
80
|
+
|
81
|
+
* OAuth Redirect URI: `#{config.options[:redirect_uri]}`
|
82
|
+
|
83
|
+
If using asymmetric client authentication, register Inferno with the
|
84
|
+
following JWK Set URL:
|
85
|
+
|
86
|
+
* `#{Inferno::Application[:base_url]}/custom/smart_stu2/.well-known/jwks.json`
|
87
|
+
INSTRUCTIONS
|
88
|
+
|
39
89
|
run_as_group
|
40
90
|
|
41
91
|
group from: :smart_discovery_stu2
|
@@ -98,6 +148,19 @@ module SMARTAppLaunch
|
|
98
148
|
title 'EHR Launch'
|
99
149
|
id :smart_full_ehr_launch
|
100
150
|
|
151
|
+
input_instructions <<~INSTRUCTIONS
|
152
|
+
Please register the Inferno client as a SMART App with the following
|
153
|
+
information:
|
154
|
+
|
155
|
+
* SMART Launch URI: `#{config.options[:launch_uri]}`
|
156
|
+
* OAuth Redirect URI: `#{config.options[:redirect_uri]}`
|
157
|
+
|
158
|
+
If using asymmetric client authentication, register Inferno with the
|
159
|
+
following JWK Set URL:
|
160
|
+
|
161
|
+
* `#{Inferno::Application[:base_url]}/custom/smart_stu2/.well-known/jwks.json`
|
162
|
+
INSTRUCTIONS
|
163
|
+
|
101
164
|
run_as_group
|
102
165
|
|
103
166
|
group from: :smart_discovery_stu2
|
@@ -156,5 +219,8 @@ module SMARTAppLaunch
|
|
156
219
|
}
|
157
220
|
}
|
158
221
|
end
|
222
|
+
|
223
|
+
group from: :smart_token_introspection
|
224
|
+
|
159
225
|
end
|
160
226
|
end
|
@@ -44,7 +44,8 @@ module SMARTAppLaunch
|
|
44
44
|
client_secret: {
|
45
45
|
name: :standalone_client_secret,
|
46
46
|
title: 'Standalone Client Secret',
|
47
|
-
description: 'Client Secret provided during registration of Inferno as a standalone application'
|
47
|
+
description: 'Client Secret provided during registration of Inferno as a standalone application. ' \
|
48
|
+
'Only for clients using confidential symmetric authentication.'
|
48
49
|
},
|
49
50
|
requested_scopes: {
|
50
51
|
name: :standalone_requested_scopes,
|
@@ -1,4 +1,5 @@
|
|
1
1
|
require_relative 'app_redirect_test_stu2'
|
2
|
+
require_relative 'token_exchange_stu2_test'
|
2
3
|
require_relative 'standalone_launch_group'
|
3
4
|
|
4
5
|
module SMARTAppLaunch
|
@@ -48,5 +49,12 @@ module SMARTAppLaunch
|
|
48
49
|
|
49
50
|
redirect_index = children.find_index { |child| child.id.to_s.end_with? 'app_redirect' }
|
50
51
|
children[redirect_index] = children.pop
|
52
|
+
|
53
|
+
test from: :smart_token_exchange_stu2
|
54
|
+
|
55
|
+
token_exchange_index = children.find_index { |child| child.id.to_s.end_with? 'token_exchange' }
|
56
|
+
children[token_exchange_index] = children.pop
|
57
|
+
|
58
|
+
children[token_exchange_index].id('smart_token_exchange')
|
51
59
|
end
|
52
60
|
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
require_relative 'client_assertion_builder'
|
2
|
+
require_relative 'token_exchange_test'
|
3
|
+
|
4
|
+
module SMARTAppLaunch
|
5
|
+
class TokenExchangeSTU2Test < TokenExchangeTest
|
6
|
+
title 'OAuth token exchange request succeeds when supplied correct information'
|
7
|
+
description %(
|
8
|
+
After obtaining an authorization code, the app trades the code for an
|
9
|
+
access token via HTTP POST to the EHR authorization server's token
|
10
|
+
endpoint URL, using content-type application/x-www-form-urlencoded, as
|
11
|
+
described in section [4.1.3 of
|
12
|
+
RFC6749](https://tools.ietf.org/html/rfc6749#section-4.1.3).
|
13
|
+
)
|
14
|
+
id :smart_token_exchange_stu2
|
15
|
+
|
16
|
+
input :client_auth_encryption_method,
|
17
|
+
title: 'Encryption Method (Confidential Asymmetric Client Auth Only)',
|
18
|
+
type: 'radio',
|
19
|
+
default: 'ES384',
|
20
|
+
options: {
|
21
|
+
list_options: [
|
22
|
+
{
|
23
|
+
label: 'ES384',
|
24
|
+
value: 'ES384'
|
25
|
+
},
|
26
|
+
{
|
27
|
+
label: 'RS384',
|
28
|
+
value: 'RS384'
|
29
|
+
}
|
30
|
+
]
|
31
|
+
}
|
32
|
+
|
33
|
+
input :client_auth_type,
|
34
|
+
title: 'Client Authentication Method',
|
35
|
+
type: 'radio',
|
36
|
+
default: 'public',
|
37
|
+
options: {
|
38
|
+
list_options: [
|
39
|
+
{
|
40
|
+
label: 'Public',
|
41
|
+
value: 'public'
|
42
|
+
},
|
43
|
+
{
|
44
|
+
label: 'Confidential Symmetric',
|
45
|
+
value: 'confidential_symmetric'
|
46
|
+
},
|
47
|
+
{
|
48
|
+
label: 'Confidential Asymmetric',
|
49
|
+
value: 'confidential_asymmetric'
|
50
|
+
}
|
51
|
+
]
|
52
|
+
}
|
53
|
+
|
54
|
+
config(
|
55
|
+
inputs: {
|
56
|
+
use_pkce: {
|
57
|
+
default: 'true',
|
58
|
+
options: {
|
59
|
+
list_options: [
|
60
|
+
{
|
61
|
+
label: 'Enabled',
|
62
|
+
value: 'true'
|
63
|
+
}
|
64
|
+
]
|
65
|
+
}
|
66
|
+
}
|
67
|
+
}
|
68
|
+
)
|
69
|
+
|
70
|
+
def add_credentials_to_request(oauth2_params, oauth2_headers)
|
71
|
+
if client_auth_type == 'confidential_symmetric'
|
72
|
+
assert client_secret.present?,
|
73
|
+
"A client secret must be provided when using confidential symmetric client authentication."
|
74
|
+
|
75
|
+
client_credentials = "#{client_id}:#{client_secret}"
|
76
|
+
oauth2_headers['Authorization'] = "Basic #{Base64.strict_encode64(client_credentials)}"
|
77
|
+
elsif client_auth_type == 'public'
|
78
|
+
oauth2_params[:client_id] = client_id
|
79
|
+
else
|
80
|
+
oauth2_params.merge!(
|
81
|
+
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
82
|
+
client_assertion: ClientAssertionBuilder.build(
|
83
|
+
iss: client_id,
|
84
|
+
sub: client_id,
|
85
|
+
aud: smart_token_url,
|
86
|
+
client_auth_encryption_method: client_auth_encryption_method
|
87
|
+
)
|
88
|
+
)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -38,22 +38,26 @@ module SMARTAppLaunch
|
|
38
38
|
|
39
39
|
config options: { redirect_uri: "#{Inferno::Application['base_url']}/custom/smart/redirect" }
|
40
40
|
|
41
|
+
def add_credentials_to_request(oauth2_params, oauth2_headers)
|
42
|
+
if client_secret.present?
|
43
|
+
client_credentials = "#{client_id}:#{client_secret}"
|
44
|
+
oauth2_headers['Authorization'] = "Basic #{Base64.strict_encode64(client_credentials)}"
|
45
|
+
else
|
46
|
+
oauth2_params[:client_id] = client_id
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
41
50
|
run do
|
42
51
|
skip_if request.query_parameters['error'].present?, 'Error during authorization request'
|
43
52
|
|
44
53
|
oauth2_params = {
|
45
|
-
grant_type: 'authorization_code',
|
46
54
|
code: code,
|
47
|
-
redirect_uri: config.options[:redirect_uri]
|
55
|
+
redirect_uri: config.options[:redirect_uri],
|
56
|
+
grant_type: 'authorization_code'
|
48
57
|
}
|
49
58
|
oauth2_headers = { 'Content-Type' => 'application/x-www-form-urlencoded' }
|
50
59
|
|
51
|
-
|
52
|
-
client_credentials = "#{client_id}:#{client_secret}"
|
53
|
-
oauth2_headers['Authorization'] = "Basic #{Base64.strict_encode64(client_credentials)}"
|
54
|
-
else
|
55
|
-
oauth2_params[:client_id] = client_id
|
56
|
-
end
|
60
|
+
add_credentials_to_request(oauth2_params, oauth2_headers)
|
57
61
|
|
58
62
|
if use_pkce == 'true'
|
59
63
|
oauth2_params[:code_verifier] = pkce_code_verifier
|
@@ -67,6 +71,8 @@ module SMARTAppLaunch
|
|
67
71
|
output token_retrieval_time: Time.now.iso8601
|
68
72
|
|
69
73
|
token_response_body = JSON.parse(request.response_body)
|
74
|
+
|
75
|
+
|
70
76
|
output smart_credentials: {
|
71
77
|
refresh_token: token_response_body['refresh_token'],
|
72
78
|
access_token: token_response_body['access_token'],
|
@@ -76,6 +82,7 @@ module SMARTAppLaunch
|
|
76
82
|
token_retrieval_time: token_retrieval_time,
|
77
83
|
token_url: smart_token_url
|
78
84
|
}.to_json
|
85
|
+
|
79
86
|
end
|
80
87
|
end
|
81
88
|
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require_relative 'standalone_launch_group_stu2'
|
2
|
+
|
3
|
+
module SMARTAppLaunch
|
4
|
+
class SMARTTokenIntrospectionAccessTokenGroup < Inferno::TestGroup
|
5
|
+
title 'Request New Access Token to Introspect'
|
6
|
+
run_as_group
|
7
|
+
|
8
|
+
id :smart_token_introspection_access_token_group
|
9
|
+
|
10
|
+
description %(
|
11
|
+
These tests are repeated from the Standalone Launch tests in order to receive a new, active access token that
|
12
|
+
will be provided for token introspection. This test group may be skipped if the tester can obtain an access token
|
13
|
+
__and__ the contents of the access token response body by some other means.
|
14
|
+
|
15
|
+
These tests are currently designed such that the token introspection URL must be present in the SMART well-known endpoint.
|
16
|
+
|
17
|
+
)
|
18
|
+
|
19
|
+
input_instructions %(
|
20
|
+
Register Inferno as a Standalone SMART App and provide the registration details below.
|
21
|
+
)
|
22
|
+
|
23
|
+
group from: :smart_discovery_stu2
|
24
|
+
group from: :smart_standalone_launch_stu2
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
require_relative 'token_introspection_access_token_group'
|
2
|
+
require_relative 'token_introspection_response_group'
|
3
|
+
require_relative 'token_introspection_request_group'
|
4
|
+
|
5
|
+
module SMARTAppLaunch
|
6
|
+
class SMARTTokenIntrospectionGroup < Inferno::TestGroup
|
7
|
+
title 'Token Introspection'
|
8
|
+
id :smart_token_introspection
|
9
|
+
description %(
|
10
|
+
# Background
|
11
|
+
|
12
|
+
OAuth 2.0 Token introspection, as described in [RFC-7662](https://datatracker.ietf.org/doc/html/rfc7662), allows
|
13
|
+
an authorized resource server to query an OAuth 2.0 authorization server for metadata on a token. The
|
14
|
+
[SMART App Launch STU2 Implementation Guide Section on Token Introspection](https://hl7.org/fhir/smart-app-launch/STU2/token-introspection.html)
|
15
|
+
states that "SMART on FHIR EHRs SHOULD support token introspection, which allows a broader ecosystem of resource servers
|
16
|
+
to leverage authorization decisions managed by a single authorization server."
|
17
|
+
|
18
|
+
# Test Methodology
|
19
|
+
|
20
|
+
In these tests, Inferno acts as an authorized resource server that queries the authorization server about an access
|
21
|
+
token, rather than a client to a FHIR resource server as in the previous SMART App Launch tests.
|
22
|
+
Ideally, Inferno should be registered with the authorization server as an authorized resource server
|
23
|
+
capable of accessing the token introspection endpoint through client credentials, per the SMART IG recommendations.
|
24
|
+
However, the SMART IG only formally REQUIRES "some form of authorization" to access
|
25
|
+
the token introspection endpoint and does not specifiy any one specific approach. As such, the token introspection tests are
|
26
|
+
broken up into three groups that each complete a discrete step in the token introspection process:
|
27
|
+
|
28
|
+
1. **Request Access Token Group** - optional but recommended, repeats a subset of Standalone Launch tests
|
29
|
+
in order to receive a new access token with an authorization code grant. If skipped, testers will need to
|
30
|
+
obtain an access token out-of-band and manually provide values from the access token response as inputs to
|
31
|
+
the Validate Token Response group.
|
32
|
+
2. **Issue Token Introspection Request Group** - optional but recommended, completes the introspection requests.
|
33
|
+
If skipped, testers will need to complete an introspection request out-of-band and manually provide the introspection
|
34
|
+
responses as inputs to the Validate Token Response group.
|
35
|
+
3. **Validate Token Introspection Response Group** - required, validates the contents of the introspection responses.
|
36
|
+
|
37
|
+
Running all three test groups in order is the simplest and is highly recommended if the environment under test
|
38
|
+
can support it, as outputs from one group will feed the inputs of the next group. However, test groups can be run
|
39
|
+
independently if needed.
|
40
|
+
|
41
|
+
See the individual test groups for more details and guidance.
|
42
|
+
)
|
43
|
+
group from: :smart_token_introspection_access_token_group
|
44
|
+
group from: :smart_token_introspection_request_group
|
45
|
+
group from: :smart_token_introspection_response_group
|
46
|
+
|
47
|
+
input_order :url, :standalone_client_id, :standalone_client_secret,
|
48
|
+
:authorization_method, :use_pkce, :pkce_code_challenge_method,
|
49
|
+
:standalone_requested_scopes, :client_auth_encryption_method,
|
50
|
+
:client_auth_type, :custom_authorization_header,
|
51
|
+
:optional_introspection_request_params
|
52
|
+
input_instructions %(
|
53
|
+
Executing tests at this level will run all three Token Introspection groups back-to-back. If test groups need
|
54
|
+
to be run independently, exit this window and select a specific test group instead.
|
55
|
+
|
56
|
+
These tests are currently designed such that the token introspection URL must be present in the SMART well-known endpoint.
|
57
|
+
|
58
|
+
If the introspection endpoint is protected, testers must enter their own HTTP Authorization header for the introspection request. See
|
59
|
+
[RFC 7616 The 'Basic' HTTP Authentication Scheme](https://datatracker.ietf.org/doc/html/rfc7617) for the most common
|
60
|
+
approach that uses client credentials. Testers may also provide any additional parameters needed for their authorization
|
61
|
+
server to complete the introspection request.
|
62
|
+
|
63
|
+
**Note:** For both the Authorization header and request parameters, user-input
|
64
|
+
values will be sent exactly as entered and therefore the tester must
|
65
|
+
URI-encode any appropriate values.
|
66
|
+
)
|
67
|
+
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
require_relative 'token_exchange_test'
|
2
|
+
require_relative 'token_refresh_body_test'
|
3
|
+
require_relative 'well_known_endpoint_test'
|
4
|
+
require_relative 'standalone_launch_group'
|
5
|
+
|
6
|
+
module SMARTAppLaunch
|
7
|
+
class SMARTTokenIntrospectionRequestGroup < Inferno::TestGroup
|
8
|
+
title 'Issue Token Introspection Request'
|
9
|
+
run_as_group
|
10
|
+
|
11
|
+
id :smart_token_introspection_request_group
|
12
|
+
description %(
|
13
|
+
This group of tests executes the token introspection requests and ensures the correct HTTP response is returned
|
14
|
+
but does not validate the contents of the token introspection response.
|
15
|
+
|
16
|
+
If Inferno cannot reasonably be configured to be authorized to access the token introspection endpoint, these tests
|
17
|
+
can be skipped. Instead, an out-of-band token introspection request must be completed and the response body
|
18
|
+
manually provided as input for the Validate Introspection Response test group.
|
19
|
+
)
|
20
|
+
|
21
|
+
input_instructions %(
|
22
|
+
If the Request New Access Token group was executed, the access token input will auto-populate with that token.
|
23
|
+
Otherwise an active access token needs to be obtained out-of-band and input.
|
24
|
+
|
25
|
+
Per [RFC-7662](https://datatracker.ietf.org/doc/html/rfc7662#section-2), "the definition of an active token is
|
26
|
+
currently dependent upon the authorization server, but this is commonly a token that has been issued by this
|
27
|
+
authorization server, is not expired, has not been revoked, and is valid for use at the protected resource making
|
28
|
+
the introspection call."
|
29
|
+
|
30
|
+
If the introspection endpoint is protected, testers must enter their own HTTP Authorization header for the introspection request. See
|
31
|
+
[RFC 7616 The 'Basic' HTTP Authentication Scheme](https://datatracker.ietf.org/doc/html/rfc7617) for the most common
|
32
|
+
approach that uses client credentials. Testers may also provide any additional parameters needed for their authorization
|
33
|
+
server to complete the introspection request.
|
34
|
+
|
35
|
+
**Note:** For both the Authorization header and request parameters, user-input
|
36
|
+
values will be sent exactly as entered and therefore the tester must URI-encode any appropriate values.
|
37
|
+
)
|
38
|
+
|
39
|
+
input :well_known_introspection_url,
|
40
|
+
title: 'Token Introspection Endpoint URL',
|
41
|
+
description: 'The complete URL of the token introspection endpoint.'
|
42
|
+
|
43
|
+
input :custom_authorization_header,
|
44
|
+
title: 'HTTP Authorization Header for Introspection Request',
|
45
|
+
type: 'textarea',
|
46
|
+
optional: true,
|
47
|
+
description: %(
|
48
|
+
Include header name, auth scheme, and auth parameters.
|
49
|
+
Ex: 'Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW'
|
50
|
+
)
|
51
|
+
|
52
|
+
input :optional_introspection_request_params,
|
53
|
+
title: 'Additional Introspection Request Parameters',
|
54
|
+
type: 'textarea',
|
55
|
+
optional: true,
|
56
|
+
description: %(
|
57
|
+
Any additional parameters to append to the request body, separated by &. Example: 'param1=abc¶m2=def'
|
58
|
+
)
|
59
|
+
|
60
|
+
test do
|
61
|
+
title 'Token introspection endpoint returns a response when provided an active token'
|
62
|
+
description %(
|
63
|
+
This test will execute a token introspection request for an active token and ensure a 200 status and valid JSON
|
64
|
+
body are returned in the response.
|
65
|
+
)
|
66
|
+
|
67
|
+
input :standalone_access_token,
|
68
|
+
title: 'Access Token',
|
69
|
+
description: 'The access token to be introspected. MUST be active.'
|
70
|
+
|
71
|
+
|
72
|
+
output :active_token_introspection_response_body
|
73
|
+
|
74
|
+
run do
|
75
|
+
|
76
|
+
# If this is being chained from an earlier test, it might be blank if not present in the well-known endpoint
|
77
|
+
skip_if well_known_introspection_url.nil?, 'No introspection URL present in SMART well-known endpoint.'
|
78
|
+
|
79
|
+
headers = {'Accept' => 'application/json', 'Content-Type' => 'application/x-www-form-urlencoded'}
|
80
|
+
body = "token=#{standalone_access_token}"
|
81
|
+
|
82
|
+
if custom_authorization_header.present?
|
83
|
+
parsed_header = custom_authorization_header.split(':', 2)
|
84
|
+
assert parsed_header.length == 2, "Incorrect custom HTTP header format input, expected: '<header name>: <header value>'"
|
85
|
+
headers[parsed_header[0]] = parsed_header[1].strip
|
86
|
+
end
|
87
|
+
|
88
|
+
if optional_introspection_request_params.present?
|
89
|
+
body += "&#{optional_introspection_request_params}"
|
90
|
+
end
|
91
|
+
|
92
|
+
post(well_known_introspection_url, body: body, headers: headers)
|
93
|
+
|
94
|
+
assert_response_status(200)
|
95
|
+
output active_token_introspection_response_body: request.response_body
|
96
|
+
end
|
97
|
+
|
98
|
+
end
|
99
|
+
|
100
|
+
test do
|
101
|
+
title 'Token introspection endpoint returns a response when provided an invalid token'
|
102
|
+
description %(
|
103
|
+
This test will execute a token introspection request for an invalid token and ensure a 200 status and valid JSON
|
104
|
+
body are returned in response.
|
105
|
+
)
|
106
|
+
|
107
|
+
output :invalid_token_introspection_response_body
|
108
|
+
run do
|
109
|
+
|
110
|
+
# If this is being chained from an earlier test, it might be blank if not present in the well-known endpoint
|
111
|
+
skip_if well_known_introspection_url.nil?, 'No introspection URL present in SMART well-known endpoint.'
|
112
|
+
|
113
|
+
headers = {'Accept' => 'application/json', 'Content-Type' => 'application/x-www-form-urlencoded'}
|
114
|
+
body = "token=invalid_token_value"
|
115
|
+
post(well_known_introspection_url, body: body, headers: headers)
|
116
|
+
|
117
|
+
assert_response_status(200)
|
118
|
+
output invalid_token_introspection_response_body: request.response_body
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,191 @@
|
|
1
|
+
require_relative 'token_introspection_request_group'
|
2
|
+
require_relative 'token_exchange_test'
|
3
|
+
|
4
|
+
module SMARTAppLaunch
|
5
|
+
class SMARTTokenIntrospectionResponseGroup < Inferno::TestGroup
|
6
|
+
title 'Validate Token Introspection Response'
|
7
|
+
run_as_group
|
8
|
+
|
9
|
+
id :smart_token_introspection_response_group
|
10
|
+
description %(
|
11
|
+
This group of tests validates the contents of the token introspection response by comparing the fields and/or
|
12
|
+
values in the token introspection response to the fields and/or values of the original access token response
|
13
|
+
in which the access token was given to the client.
|
14
|
+
)
|
15
|
+
|
16
|
+
input_instructions %(
|
17
|
+
There are two categories of input for this test group:
|
18
|
+
|
19
|
+
1. The access token response values, which will dictate what the tests will expect to find in the token
|
20
|
+
introspection response. If the Request New Access Token group was run, these inputs will auto-populate.
|
21
|
+
|
22
|
+
2. The token introspection response bodies. If the Issue Introspection Request test group was run, these will
|
23
|
+
auto-populate; otherwise, the tester will need to an run out-of-band INTROSPECTION requests for a. An ACTIVE
|
24
|
+
access token, AND b. An INACTIVE OR INVALID token
|
25
|
+
|
26
|
+
See [RFC-7662](https://datatracker.ietf.org/doc/html/rfc7662#section-2) for details on active vs inactive tokens.
|
27
|
+
)
|
28
|
+
|
29
|
+
test do
|
30
|
+
title 'Token introspection response for an active token contains required fields'
|
31
|
+
|
32
|
+
description %(
|
33
|
+
This test will check whether the metadata in the token introspection response is correct for an active token and
|
34
|
+
that the response data matches the data in the original access token and/or access token response from the
|
35
|
+
authorization server, including the following:
|
36
|
+
|
37
|
+
Required:
|
38
|
+
* `active` claim is set to true
|
39
|
+
* `scope`, `client_id`, and `exp` claim(s) match between introspection response and access token
|
40
|
+
|
41
|
+
It is not possible to know what the expected value for `exp` is in advance, so Inferno tests that the claim is
|
42
|
+
present and represents a time greater than or equal to 10 minutes in the past.
|
43
|
+
|
44
|
+
Conditionally Required:
|
45
|
+
* IF launch context parameter(s) included in access token, introspection response includes claim(s) for
|
46
|
+
launch context parameter(s)
|
47
|
+
* Parameters checked for are `patient` and `encounter`
|
48
|
+
* IF identity token was included as part of access token response, `iss` and `sub` claims are present in the
|
49
|
+
introspection response and match those of the orignal ID token
|
50
|
+
|
51
|
+
Optional but Recommended:
|
52
|
+
* IF identity token was included as part of access token response, `fhirUser` claim SHOULD be present in
|
53
|
+
introspection response and should match the claim in the ID token
|
54
|
+
)
|
55
|
+
|
56
|
+
input :standalone_client_id,
|
57
|
+
title: 'Access Token client_id',
|
58
|
+
description: 'ID of the client that requested the access token being introspected'
|
59
|
+
|
60
|
+
|
61
|
+
input :standalone_received_scopes,
|
62
|
+
title: 'Expected Introspection Response Value: scope',
|
63
|
+
description: 'A space-separated list of scopes from the original access token response body'
|
64
|
+
|
65
|
+
input :standalone_id_token,
|
66
|
+
title: 'Access Token Response: id_token',
|
67
|
+
type: 'textarea',
|
68
|
+
optional: true,
|
69
|
+
description: 'The ID token from the original access token response body, IF it was present'
|
70
|
+
|
71
|
+
input :standalone_patient_id,
|
72
|
+
title: 'Expected Introspection Response for Patient Launch Context Parameter',
|
73
|
+
optional: true,
|
74
|
+
description: 'The value for patient launch context from the original access token response body, IF it was present'
|
75
|
+
|
76
|
+
input :standalone_encounter_id,
|
77
|
+
title: 'Expected Introspection Response for Encounter Launch Context Parameter',
|
78
|
+
optional: true,
|
79
|
+
description: 'The value for encounter launch context from the original access token response body, IF it was present'
|
80
|
+
|
81
|
+
input :active_token_introspection_response_body,
|
82
|
+
title: 'Active Token Introspection Response Body',
|
83
|
+
type: 'textarea',
|
84
|
+
description: 'The JSON body of the token introspection response when provided an ACTIVE token'
|
85
|
+
|
86
|
+
def get_json_claim_value(json_response, claim_key)
|
87
|
+
claim_value = json_response[claim_key]
|
88
|
+
assert claim_value != nil, "Failure: introspection response has no claim for '#{claim_key}'"
|
89
|
+
return claim_value
|
90
|
+
end
|
91
|
+
|
92
|
+
def assert_introspection_response_match(json_response, claim_key, expected_value)
|
93
|
+
expected_value = expected_value.strip
|
94
|
+
claim_value = get_json_claim_value(json_response, claim_key)
|
95
|
+
claim_value = claim_value.strip
|
96
|
+
assert claim_value.eql?(expected_value),
|
97
|
+
"Failure: expected introspection response value for '#{claim_key}' to match expected value '#{expected_value}'"
|
98
|
+
end
|
99
|
+
|
100
|
+
run do
|
101
|
+
skip_if active_token_introspection_response_body.nil?, 'No introspection response available to validate.'
|
102
|
+
assert_valid_json(active_token_introspection_response_body)
|
103
|
+
active_introspection_response_body_parsed = JSON.parse(active_token_introspection_response_body)
|
104
|
+
|
105
|
+
# Required Fields
|
106
|
+
assert active_introspection_response_body_parsed['active'] == true, "Failure: expected introspection response for 'active' to be Boolean value true for valid token"
|
107
|
+
assert_introspection_response_match(active_introspection_response_body_parsed, 'client_id', standalone_client_id)
|
108
|
+
|
109
|
+
response_scope_value = get_json_claim_value(active_introspection_response_body_parsed, 'scope')
|
110
|
+
|
111
|
+
# splitting contents and comparing values allows a scope lists with the same contents but different orders to still pass
|
112
|
+
response_scopes_split = response_scope_value.split()
|
113
|
+
expected_scopes_split = standalone_received_scopes.split()
|
114
|
+
|
115
|
+
assert response_scopes_split.length() == expected_scopes_split.length(),
|
116
|
+
"Failure: number of scopes in introspection response, #{response_scopes_split.length()}, does not match number of scopes in access token response, #{expected_scopes_split.length()}"
|
117
|
+
|
118
|
+
expected_scopes_split.each do |scope|
|
119
|
+
assert response_scopes_split.include?(scope), "Failure: expected scope '#{scope}' not present in introspection response scopes"
|
120
|
+
end
|
121
|
+
|
122
|
+
# Cannot verify exact value for exp, so instead ensure its value represents a time >= 10 minutes in the past
|
123
|
+
exp = active_introspection_response_body_parsed['exp']
|
124
|
+
assert exp != nil, "Failure: introspection response has no claim for 'exp'"
|
125
|
+
current_time = Time.now.to_i
|
126
|
+
assert exp.to_i >= current_time - 600, "Failure: expired token, exp claim of #{exp} for active token is more than 10 minutes in the past"
|
127
|
+
|
128
|
+
# Conditional fields
|
129
|
+
assert_introspection_response_match(active_introspection_response_body_parsed, 'patient', standalone_patient_id) if standalone_patient_id.present?
|
130
|
+
assert_introspection_response_match(active_introspection_response_body_parsed, 'encounter', standalone_encounter_id) if standalone_encounter_id.present?
|
131
|
+
|
132
|
+
# ID Token Fields
|
133
|
+
if standalone_id_token.present?
|
134
|
+
id_payload, id_header =
|
135
|
+
JWT.decode(
|
136
|
+
standalone_id_token,
|
137
|
+
nil,
|
138
|
+
false
|
139
|
+
)
|
140
|
+
|
141
|
+
# Required fields if ID token present
|
142
|
+
id_token_iss = id_payload['iss']
|
143
|
+
id_token_sub = id_payload['sub']
|
144
|
+
|
145
|
+
assert id_token_iss != nil, "Failure: ID token from access token response does not have 'iss' claim"
|
146
|
+
assert id_token_sub != nil, "Failure: ID token from access token response does not have 'sub' claim"
|
147
|
+
assert_introspection_response_match(active_introspection_response_body_parsed, 'iss', id_token_iss)
|
148
|
+
assert_introspection_response_match(active_introspection_response_body_parsed, 'sub', id_token_sub)
|
149
|
+
|
150
|
+
# fhirUser not required but recommended
|
151
|
+
fhirUser_id_claim = id_payload['fhirUser']
|
152
|
+
fhirUser_intr_claim = active_introspection_response_body_parsed['fhirUser']
|
153
|
+
|
154
|
+
info do
|
155
|
+
assert fhirUser_intr_claim != nil, "Introspection response SHOULD include claim for fhirUser because ID token present in access token response" if fhirUser_id_claim != nil
|
156
|
+
assert fhirUser_intr_claim.eql?(fhirUser_id_claim), "Introspection response claim for fhirUser SHOULD match value in ID token" if fhirUser_id_claim != nil
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
test do
|
163
|
+
title 'Token introspection response for an invalid token contains required fields'
|
164
|
+
|
165
|
+
description %(
|
166
|
+
From [RFC7662 OAuth2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2):
|
167
|
+
"If the introspection call is properly authorized but the token is not
|
168
|
+
active, does not exist on this server, or the protected resource is
|
169
|
+
not allowed to introspect this particular token, then the
|
170
|
+
authorization server MUST return an introspection response with the
|
171
|
+
"active" field set to "false". Note that to avoid disclosing too
|
172
|
+
much of the authorization server's state to a third party, the
|
173
|
+
authorization server SHOULD NOT include any additional information
|
174
|
+
about an inactive token, including why the token is inactive."
|
175
|
+
)
|
176
|
+
|
177
|
+
input :invalid_token_introspection_response_body,
|
178
|
+
title: 'Invalid Token Introspection Response Body',
|
179
|
+
type: 'textarea',
|
180
|
+
description: 'The JSON body of the token introspection response when provided an INVALID token'
|
181
|
+
|
182
|
+
run do
|
183
|
+
skip_if invalid_token_introspection_response_body.nil?, 'No invalid introspection response available to validate.'
|
184
|
+
assert_valid_json(invalid_token_introspection_response_body)
|
185
|
+
invalid_token_introspection_response_body_parsed = JSON.parse(invalid_token_introspection_response_body)
|
186
|
+
assert invalid_token_introspection_response_body_parsed['active'] == false, "Failure: expected introspection response for 'active' to be Boolean value false for invalid token"
|
187
|
+
assert invalid_token_introspection_response_body_parsed.size == 1, "Failure: expected only 'active' field to be present in introspection response for invalid token"
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
@@ -3,6 +3,69 @@ module SMARTAppLaunch
|
|
3
3
|
STRING_FIELDS = ['access_token', 'token_type', 'scope', 'refresh_token'].freeze
|
4
4
|
NUMERIC_FIELDS = ['expires_in'].freeze
|
5
5
|
|
6
|
+
# All resource types from DSTU3, STU3, R4, R4B, and R5
|
7
|
+
FHIR_RESOURCE_TYPES = [
|
8
|
+
"Account", "ActivityDefinition", "ActorDefinition",
|
9
|
+
"AdministrableProductDefinition", "AdverseEvent", "AllergyIntolerance",
|
10
|
+
"Appointment", "AppointmentResponse", "ArtifactAssessment", "AuditEvent",
|
11
|
+
"Basic", "Binary", "BiologicallyDerivedProduct",
|
12
|
+
"BiologicallyDerivedProductDispense", "BodySite", "BodyStructure",
|
13
|
+
"Bundle", "CapabilityStatement", "CarePlan", "CareTeam", "CatalogEntry",
|
14
|
+
"ChargeItem", "ChargeItemDefinition", "Citation", "Claim",
|
15
|
+
"ClaimResponse", "ClinicalImpression", "ClinicalUseDefinition",
|
16
|
+
"CodeSystem", "Communication", "CommunicationRequest",
|
17
|
+
"CompartmentDefinition", "Composition", "ConceptMap", "Condition",
|
18
|
+
"ConditionDefinition", "Conformance", "Consent", "Contract", "Coverage",
|
19
|
+
"CoverageEligibilityRequest", "CoverageEligibilityResponse",
|
20
|
+
"DataElement", "DetectedIssue", "Device", "DeviceAssociation",
|
21
|
+
"DeviceComponent", "DeviceDefinition", "DeviceDispense", "DeviceMetric",
|
22
|
+
"DeviceRequest", "DeviceUsage", "DeviceUseRequest", "DeviceUseStatement",
|
23
|
+
"DiagnosticOrder", "DiagnosticReport", "DocumentManifest",
|
24
|
+
"DocumentReference", "EffectEvidenceSynthesis", "EligibilityRequest",
|
25
|
+
"EligibilityResponse", "Encounter", "EncounterHistory", "Endpoint",
|
26
|
+
"EnrollmentRequest", "EnrollmentResponse", "EpisodeOfCare",
|
27
|
+
"EventDefinition", "Evidence", "EvidenceReport", "EvidenceVariable",
|
28
|
+
"ExampleScenario", "ExpansionProfile", "ExplanationOfBenefit",
|
29
|
+
"FamilyMemberHistory", "Flag", "FormularyItem", "GenomicStudy", "Goal",
|
30
|
+
"GraphDefinition", "Group", "GuidanceResponse", "HealthcareService",
|
31
|
+
"ImagingManifest", "ImagingObjectSelection", "ImagingSelection",
|
32
|
+
"ImagingStudy", "Immunization", "ImmunizationEvaluation",
|
33
|
+
"ImmunizationRecommendation", "ImplementationGuide", "Ingredient",
|
34
|
+
"InsurancePlan", "InventoryItem", "InventoryReport", "Invoice", "Library",
|
35
|
+
"Linkage", "List", "Location", "ManufacturedItemDefinition", "Measure",
|
36
|
+
"MeasureReport", "Media", "Medication", "MedicationAdministration",
|
37
|
+
"MedicationDispense", "MedicationKnowledge", "MedicationOrder",
|
38
|
+
"MedicationRequest", "MedicationStatement", "MedicinalProduct",
|
39
|
+
"MedicinalProductAuthorization", "MedicinalProductContraindication",
|
40
|
+
"MedicinalProductDefinition", "MedicinalProductIndication",
|
41
|
+
"MedicinalProductIngredient", "MedicinalProductInteraction",
|
42
|
+
"MedicinalProductManufactured", "MedicinalProductPackaged",
|
43
|
+
"MedicinalProductPharmaceutical", "MedicinalProductUndesirableEffect",
|
44
|
+
"MessageDefinition", "MessageHeader", "MolecularSequence", "NamingSystem",
|
45
|
+
"NutritionIntake", "NutritionOrder", "NutritionProduct", "Observation",
|
46
|
+
"ObservationDefinition", "OperationDefinition", "OperationOutcome",
|
47
|
+
"Order", "OrderResponse", "Organization", "OrganizationAffiliation",
|
48
|
+
"PackagedProductDefinition", "Patient", "PaymentNotice",
|
49
|
+
"PaymentReconciliation", "Permission", "Person", "PlanDefinition",
|
50
|
+
"Practitioner", "PractitionerRole", "Procedure", "ProcedureRequest",
|
51
|
+
"ProcessRequest", "ProcessResponse", "Provenance", "Questionnaire",
|
52
|
+
"QuestionnaireResponse", "ReferralRequest", "RegulatedAuthorization",
|
53
|
+
"RelatedPerson", "RequestGroup", "RequestOrchestration", "Requirements",
|
54
|
+
"ResearchDefinition", "ResearchElementDefinition", "ResearchStudy",
|
55
|
+
"ResearchSubject", "RiskAssessment", "RiskEvidenceSynthesis", "Schedule",
|
56
|
+
"SearchParameter", "Sequence", "ServiceDefinition", "ServiceRequest",
|
57
|
+
"Slot", "Specimen", "SpecimenDefinition", "StructureDefinition",
|
58
|
+
"StructureMap", "Subscription", "SubscriptionStatus", "SubscriptionTopic",
|
59
|
+
"Substance", "SubstanceDefinition", "SubstanceNucleicAcid",
|
60
|
+
"SubstancePolymer", "SubstanceProtein", "SubstanceReferenceInformation",
|
61
|
+
"SubstanceSourceMaterial", "SubstanceSpecification", "SupplyDelivery",
|
62
|
+
"SupplyRequest", "Task", "TerminologyCapabilities", "TestPlan",
|
63
|
+
"TestReport", "TestScript", "Transport", "ValueSet", "VerificationResult",
|
64
|
+
"VisionPrescription"
|
65
|
+
].to_set.freeze
|
66
|
+
|
67
|
+
FHIR_ID_REGEX = /[A-Za-z0-9\-\.]{1,64}(\/_history\/[A-Za-z0-9\-\.]{1,64})?(#[A-Za-z0-9\-\.]{1,64})?/.freeze
|
68
|
+
|
6
69
|
def validate_required_fields_present(body, required_fields)
|
7
70
|
missing_fields = required_fields.select { |field| body[field].blank? }
|
8
71
|
missing_fields_string = missing_fields.map { |field| "`#{field}`" }.join(', ')
|
@@ -49,5 +112,25 @@ module SMARTAppLaunch
|
|
49
112
|
"Expected `#{field}` to be a Numeric, but found #{body[field].class.name}"
|
50
113
|
end
|
51
114
|
end
|
115
|
+
|
116
|
+
def validate_fhir_context(fhir_context)
|
117
|
+
return if fhir_context.nil?
|
118
|
+
|
119
|
+
assert fhir_context.is_a?(Array), "`fhirContext` field is a #{fhir_context.class.name}, but should be an Array"
|
120
|
+
|
121
|
+
fhir_context.each do |reference|
|
122
|
+
assert reference.is_a?(String), "`#{reference.inspect}` is not a string"
|
123
|
+
end
|
124
|
+
|
125
|
+
fhir_context.each do |reference|
|
126
|
+
assert !reference.start_with?('http'), "`#{reference}` is not a relative reference"
|
127
|
+
|
128
|
+
resource_type, id = reference.split('/')
|
129
|
+
assert FHIR_RESOURCE_TYPES.include?(resource_type),
|
130
|
+
"`#{resource_type}` is not a valid FHIR resource type"
|
131
|
+
|
132
|
+
assert id.match?(FHIR_ID_REGEX), "`#{id}` is not a valid FHIR id"
|
133
|
+
end
|
134
|
+
end
|
52
135
|
end
|
53
136
|
end
|
@@ -11,6 +11,8 @@ module SMARTAppLaunch
|
|
11
11
|
has been denied. `access_token`, `token_type`, and `scope` are required.
|
12
12
|
`token_type` must be Bearer. `expires_in` is required for token
|
13
13
|
refreshes.
|
14
|
+
|
15
|
+
The format of the optional `fhirContext` field is validated if present.
|
14
16
|
)
|
15
17
|
id :smart_token_response_body
|
16
18
|
|
@@ -48,6 +50,8 @@ module SMARTAppLaunch
|
|
48
50
|
assert access_token.present?, 'Token response did not contain an access token'
|
49
51
|
assert token_response_body['token_type']&.casecmp('Bearer')&.zero?,
|
50
52
|
'`token_type` field must have a value of `Bearer`'
|
53
|
+
|
54
|
+
validate_fhir_context(token_response_body['fhirContext'])
|
51
55
|
end
|
52
56
|
end
|
53
57
|
end
|
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.
|
4
|
+
version: 0.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Stephen MacVicar
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-12-08 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: inferno_core
|
@@ -30,14 +30,14 @@ dependencies:
|
|
30
30
|
requirements:
|
31
31
|
- - "~>"
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version: '2.
|
33
|
+
version: '2.6'
|
34
34
|
type: :runtime
|
35
35
|
prerelease: false
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
37
37
|
requirements:
|
38
38
|
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
|
-
version: '2.
|
40
|
+
version: '2.6'
|
41
41
|
- !ruby/object:Gem::Dependency
|
42
42
|
name: tls_test_kit
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
@@ -134,11 +134,13 @@ files:
|
|
134
134
|
- lib/smart_app_launch/app_launch_test.rb
|
135
135
|
- lib/smart_app_launch/app_redirect_test.rb
|
136
136
|
- lib/smart_app_launch/app_redirect_test_stu2.rb
|
137
|
+
- lib/smart_app_launch/client_assertion_builder.rb
|
137
138
|
- lib/smart_app_launch/code_received_test.rb
|
138
139
|
- lib/smart_app_launch/discovery_stu1_group.rb
|
139
140
|
- lib/smart_app_launch/discovery_stu2_group.rb
|
140
141
|
- lib/smart_app_launch/ehr_launch_group.rb
|
141
142
|
- lib/smart_app_launch/ehr_launch_group_stu2.rb
|
143
|
+
- lib/smart_app_launch/jwks.rb
|
142
144
|
- lib/smart_app_launch/launch_received_test.rb
|
143
145
|
- lib/smart_app_launch/openid_connect_group.rb
|
144
146
|
- lib/smart_app_launch/openid_decode_id_token_test.rb
|
@@ -149,11 +151,17 @@ files:
|
|
149
151
|
- lib/smart_app_launch/openid_token_header_test.rb
|
150
152
|
- lib/smart_app_launch/openid_token_payload_test.rb
|
151
153
|
- lib/smart_app_launch/post_auth.html
|
154
|
+
- lib/smart_app_launch/smart_jwks.json
|
152
155
|
- lib/smart_app_launch/smart_stu1_suite.rb
|
153
156
|
- lib/smart_app_launch/smart_stu2_suite.rb
|
154
157
|
- lib/smart_app_launch/standalone_launch_group.rb
|
155
158
|
- lib/smart_app_launch/standalone_launch_group_stu2.rb
|
159
|
+
- lib/smart_app_launch/token_exchange_stu2_test.rb
|
156
160
|
- lib/smart_app_launch/token_exchange_test.rb
|
161
|
+
- lib/smart_app_launch/token_introspection_access_token_group.rb
|
162
|
+
- lib/smart_app_launch/token_introspection_group.rb
|
163
|
+
- lib/smart_app_launch/token_introspection_request_group.rb
|
164
|
+
- lib/smart_app_launch/token_introspection_response_group.rb
|
157
165
|
- lib/smart_app_launch/token_payload_validation.rb
|
158
166
|
- lib/smart_app_launch/token_refresh_body_test.rb
|
159
167
|
- lib/smart_app_launch/token_refresh_group.rb
|