smart_app_launch_test_kit 0.2.2 → 0.4.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 99071ac48c50bc9184c429d8ac6d6a82c257a62b543b0300ffce76bb5428cd3e
4
- data.tar.gz: 96ab8b622c5bcdbb803f38bb3fac3b954e09ed509ca50eb498fcb33a6f8a50a5
3
+ metadata.gz: 60325a0cc4d2866edb260627b289e76676d0847bd2e28523556f39170d424dac
4
+ data.tar.gz: dd887ba5a645f71f16e9f2269886628ca5911d57c79e3d03699582d6ae810b7f
5
5
  SHA512:
6
- metadata.gz: 5ecbf51ed63307f358311b3e14b63827a1653a0697f79b3d85333577b72109eda60721cdf1a26acda174a0099df2ac3f710bb4371429789dab51744a23375915
7
- data.tar.gz: d79ef7288453f232d1b4365dc271cabcc84436e94c8c5c0a02b7554521b16f05c0291eda15093a118fc8aa909577043ce5bb0ef0919c97d6abde81dcb6195bd5
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 paramters on it
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)
@@ -16,7 +16,7 @@ module SMARTAppLaunch
16
16
  )
17
17
 
18
18
  input :authorization_method,
19
- title: 'Authorization Method',
19
+ title: 'Authorization Request Method',
20
20
  type: 'radio',
21
21
  default: 'get',
22
22
  options: {
@@ -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
- if client_secret.present?
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&param2=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
@@ -1,3 +1,3 @@
1
1
  module SMARTAppLaunch
2
- VERSION = '0.2.2'.freeze
2
+ VERSION = '0.4.0'.freeze
3
3
  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.2.2
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-02-03 00:00:00.000000000 Z
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.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.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