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 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