smart_app_launch_test_kit 0.3.0 → 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: 97f3e4dca10ecb9dfe4aa30802d90f4272dd32ffd10715fa7b3a0855a04b5c8d
4
- data.tar.gz: 016a700551ef524e7bdc7f8871beb03f7297dc3cba39e5b4b53349b22f22ed53
3
+ metadata.gz: 60325a0cc4d2866edb260627b289e76676d0847bd2e28523556f39170d424dac
4
+ data.tar.gz: dd887ba5a645f71f16e9f2269886628ca5911d57c79e3d03699582d6ae810b7f
5
5
  SHA512:
6
- metadata.gz: 519d2bb8c5bde6a7c5c28f04ec17bfdcff37c7270b6216707a05db4b04c94269d244c6a1888dd97a0c25c5a50764d4969a08c2774dcb636db7fd274301184427
7
- data.tar.gz: eda1f5484f72de3a82c34eb1d0424a2b10a327baeb93d6e6df8f94be7a7e3ef378e5e6510378f62e7d9387e56424b42b52f4524fc557302d3a5ddab0654c782e
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
 
@@ -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: {
@@ -6,6 +6,7 @@ require_relative 'discovery_stu2_group'
6
6
  require_relative 'standalone_launch_group_stu2'
7
7
  require_relative 'ehr_launch_group_stu2'
8
8
  require_relative 'openid_connect_group'
9
+ require_relative 'token_introspection_group'
9
10
  require_relative 'token_refresh_group'
10
11
 
11
12
  module SMARTAppLaunch
@@ -55,6 +56,20 @@ module SMARTAppLaunch
55
56
  * `#{Inferno::Application[:base_url]}/custom/smart_stu2/.well-known/jwks.json`
56
57
  DESCRIPTION
57
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
+
58
73
  group do
59
74
  title 'Standalone Launch'
60
75
  id :smart_full_standalone_launch
@@ -204,5 +219,8 @@ module SMARTAppLaunch
204
219
  }
205
220
  }
206
221
  end
222
+
223
+ group from: :smart_token_introspection
224
+
207
225
  end
208
226
  end
@@ -33,6 +33,7 @@ module SMARTAppLaunch
33
33
  input :client_auth_type,
34
34
  title: 'Client Authentication Method',
35
35
  type: 'radio',
36
+ default: 'public',
36
37
  options: {
37
38
  list_options: [
38
39
  {
@@ -71,6 +71,8 @@ module SMARTAppLaunch
71
71
  output token_retrieval_time: Time.now.iso8601
72
72
 
73
73
  token_response_body = JSON.parse(request.response_body)
74
+
75
+
74
76
  output smart_credentials: {
75
77
  refresh_token: token_response_body['refresh_token'],
76
78
  access_token: token_response_body['access_token'],
@@ -80,6 +82,7 @@ module SMARTAppLaunch
80
82
  token_retrieval_time: token_retrieval_time,
81
83
  token_url: smart_token_url
82
84
  }.to_json
85
+
83
86
  end
84
87
  end
85
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.3.0'.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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stephen MacVicar
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-08-08 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
@@ -158,6 +158,10 @@ files:
158
158
  - lib/smart_app_launch/standalone_launch_group_stu2.rb
159
159
  - lib/smart_app_launch/token_exchange_stu2_test.rb
160
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
161
165
  - lib/smart_app_launch/token_payload_validation.rb
162
166
  - lib/smart_app_launch/token_refresh_body_test.rb
163
167
  - lib/smart_app_launch/token_refresh_group.rb