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 +4 -4
- data/lib/smart_app_launch/app_redirect_test.rb +1 -1
- data/lib/smart_app_launch/app_redirect_test_stu2.rb +1 -1
- data/lib/smart_app_launch/smart_stu2_suite.rb +18 -0
- data/lib/smart_app_launch/token_exchange_stu2_test.rb +1 -0
- data/lib/smart_app_launch/token_exchange_test.rb +3 -0
- data/lib/smart_app_launch/token_introspection_access_token_group.rb +26 -0
- data/lib/smart_app_launch/token_introspection_group.rb +69 -0
- data/lib/smart_app_launch/token_introspection_request_group.rb +122 -0
- data/lib/smart_app_launch/token_introspection_response_group.rb +191 -0
- data/lib/smart_app_launch/token_payload_validation.rb +83 -0
- data/lib/smart_app_launch/token_response_body_test.rb +4 -0
- data/lib/smart_app_launch/version.rb +1 -1
- metadata +8 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 60325a0cc4d2866edb260627b289e76676d0847bd2e28523556f39170d424dac
|
4
|
+
data.tar.gz: dd887ba5a645f71f16e9f2269886628ca5911d57c79e3d03699582d6ae810b7f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 16ee6a9963940d5ea882e894b5e1db23646c86a51e1f6dbe58868878c047708a581c0f353ba24d264a4c49cdc8f549f522afc40ee7bbd5127d89b0b9ac7f2e36
|
7
|
+
data.tar.gz: 022f7b43c590f419638e23614a84dc0c5d106b87bbef38dd6d4b2e71650b58bf86db59bd6bb231c04280a0274e105b6486601fb41fa3749485ddfd534a15beb4
|
@@ -75,7 +75,7 @@ module SMARTAppLaunch
|
|
75
75
|
def authorization_url_builder(url, params)
|
76
76
|
uri = URI(url)
|
77
77
|
|
78
|
-
# because the URL might have
|
78
|
+
# because the URL might have parameters on it
|
79
79
|
original_parameters = Hash[URI.decode_www_form(uri.query || '')]
|
80
80
|
new_params = original_parameters.merge(params)
|
81
81
|
|
@@ -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
|
@@ -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¶m2=def'
|
58
|
+
)
|
59
|
+
|
60
|
+
test do
|
61
|
+
title 'Token introspection endpoint returns a response when provided an active token'
|
62
|
+
description %(
|
63
|
+
This test will execute a token introspection request for an active token and ensure a 200 status and valid JSON
|
64
|
+
body are returned in the response.
|
65
|
+
)
|
66
|
+
|
67
|
+
input :standalone_access_token,
|
68
|
+
title: 'Access Token',
|
69
|
+
description: 'The access token to be introspected. MUST be active.'
|
70
|
+
|
71
|
+
|
72
|
+
output :active_token_introspection_response_body
|
73
|
+
|
74
|
+
run do
|
75
|
+
|
76
|
+
# If this is being chained from an earlier test, it might be blank if not present in the well-known endpoint
|
77
|
+
skip_if well_known_introspection_url.nil?, 'No introspection URL present in SMART well-known endpoint.'
|
78
|
+
|
79
|
+
headers = {'Accept' => 'application/json', 'Content-Type' => 'application/x-www-form-urlencoded'}
|
80
|
+
body = "token=#{standalone_access_token}"
|
81
|
+
|
82
|
+
if custom_authorization_header.present?
|
83
|
+
parsed_header = custom_authorization_header.split(':', 2)
|
84
|
+
assert parsed_header.length == 2, "Incorrect custom HTTP header format input, expected: '<header name>: <header value>'"
|
85
|
+
headers[parsed_header[0]] = parsed_header[1].strip
|
86
|
+
end
|
87
|
+
|
88
|
+
if optional_introspection_request_params.present?
|
89
|
+
body += "&#{optional_introspection_request_params}"
|
90
|
+
end
|
91
|
+
|
92
|
+
post(well_known_introspection_url, body: body, headers: headers)
|
93
|
+
|
94
|
+
assert_response_status(200)
|
95
|
+
output active_token_introspection_response_body: request.response_body
|
96
|
+
end
|
97
|
+
|
98
|
+
end
|
99
|
+
|
100
|
+
test do
|
101
|
+
title 'Token introspection endpoint returns a response when provided an invalid token'
|
102
|
+
description %(
|
103
|
+
This test will execute a token introspection request for an invalid token and ensure a 200 status and valid JSON
|
104
|
+
body are returned in response.
|
105
|
+
)
|
106
|
+
|
107
|
+
output :invalid_token_introspection_response_body
|
108
|
+
run do
|
109
|
+
|
110
|
+
# If this is being chained from an earlier test, it might be blank if not present in the well-known endpoint
|
111
|
+
skip_if well_known_introspection_url.nil?, 'No introspection URL present in SMART well-known endpoint.'
|
112
|
+
|
113
|
+
headers = {'Accept' => 'application/json', 'Content-Type' => 'application/x-www-form-urlencoded'}
|
114
|
+
body = "token=invalid_token_value"
|
115
|
+
post(well_known_introspection_url, body: body, headers: headers)
|
116
|
+
|
117
|
+
assert_response_status(200)
|
118
|
+
output invalid_token_introspection_response_body: request.response_body
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,191 @@
|
|
1
|
+
require_relative 'token_introspection_request_group'
|
2
|
+
require_relative 'token_exchange_test'
|
3
|
+
|
4
|
+
module SMARTAppLaunch
|
5
|
+
class SMARTTokenIntrospectionResponseGroup < Inferno::TestGroup
|
6
|
+
title 'Validate Token Introspection Response'
|
7
|
+
run_as_group
|
8
|
+
|
9
|
+
id :smart_token_introspection_response_group
|
10
|
+
description %(
|
11
|
+
This group of tests validates the contents of the token introspection response by comparing the fields and/or
|
12
|
+
values in the token introspection response to the fields and/or values of the original access token response
|
13
|
+
in which the access token was given to the client.
|
14
|
+
)
|
15
|
+
|
16
|
+
input_instructions %(
|
17
|
+
There are two categories of input for this test group:
|
18
|
+
|
19
|
+
1. The access token response values, which will dictate what the tests will expect to find in the token
|
20
|
+
introspection response. If the Request New Access Token group was run, these inputs will auto-populate.
|
21
|
+
|
22
|
+
2. The token introspection response bodies. If the Issue Introspection Request test group was run, these will
|
23
|
+
auto-populate; otherwise, the tester will need to an run out-of-band INTROSPECTION requests for a. An ACTIVE
|
24
|
+
access token, AND b. An INACTIVE OR INVALID token
|
25
|
+
|
26
|
+
See [RFC-7662](https://datatracker.ietf.org/doc/html/rfc7662#section-2) for details on active vs inactive tokens.
|
27
|
+
)
|
28
|
+
|
29
|
+
test do
|
30
|
+
title 'Token introspection response for an active token contains required fields'
|
31
|
+
|
32
|
+
description %(
|
33
|
+
This test will check whether the metadata in the token introspection response is correct for an active token and
|
34
|
+
that the response data matches the data in the original access token and/or access token response from the
|
35
|
+
authorization server, including the following:
|
36
|
+
|
37
|
+
Required:
|
38
|
+
* `active` claim is set to true
|
39
|
+
* `scope`, `client_id`, and `exp` claim(s) match between introspection response and access token
|
40
|
+
|
41
|
+
It is not possible to know what the expected value for `exp` is in advance, so Inferno tests that the claim is
|
42
|
+
present and represents a time greater than or equal to 10 minutes in the past.
|
43
|
+
|
44
|
+
Conditionally Required:
|
45
|
+
* IF launch context parameter(s) included in access token, introspection response includes claim(s) for
|
46
|
+
launch context parameter(s)
|
47
|
+
* Parameters checked for are `patient` and `encounter`
|
48
|
+
* IF identity token was included as part of access token response, `iss` and `sub` claims are present in the
|
49
|
+
introspection response and match those of the orignal ID token
|
50
|
+
|
51
|
+
Optional but Recommended:
|
52
|
+
* IF identity token was included as part of access token response, `fhirUser` claim SHOULD be present in
|
53
|
+
introspection response and should match the claim in the ID token
|
54
|
+
)
|
55
|
+
|
56
|
+
input :standalone_client_id,
|
57
|
+
title: 'Access Token client_id',
|
58
|
+
description: 'ID of the client that requested the access token being introspected'
|
59
|
+
|
60
|
+
|
61
|
+
input :standalone_received_scopes,
|
62
|
+
title: 'Expected Introspection Response Value: scope',
|
63
|
+
description: 'A space-separated list of scopes from the original access token response body'
|
64
|
+
|
65
|
+
input :standalone_id_token,
|
66
|
+
title: 'Access Token Response: id_token',
|
67
|
+
type: 'textarea',
|
68
|
+
optional: true,
|
69
|
+
description: 'The ID token from the original access token response body, IF it was present'
|
70
|
+
|
71
|
+
input :standalone_patient_id,
|
72
|
+
title: 'Expected Introspection Response for Patient Launch Context Parameter',
|
73
|
+
optional: true,
|
74
|
+
description: 'The value for patient launch context from the original access token response body, IF it was present'
|
75
|
+
|
76
|
+
input :standalone_encounter_id,
|
77
|
+
title: 'Expected Introspection Response for Encounter Launch Context Parameter',
|
78
|
+
optional: true,
|
79
|
+
description: 'The value for encounter launch context from the original access token response body, IF it was present'
|
80
|
+
|
81
|
+
input :active_token_introspection_response_body,
|
82
|
+
title: 'Active Token Introspection Response Body',
|
83
|
+
type: 'textarea',
|
84
|
+
description: 'The JSON body of the token introspection response when provided an ACTIVE token'
|
85
|
+
|
86
|
+
def get_json_claim_value(json_response, claim_key)
|
87
|
+
claim_value = json_response[claim_key]
|
88
|
+
assert claim_value != nil, "Failure: introspection response has no claim for '#{claim_key}'"
|
89
|
+
return claim_value
|
90
|
+
end
|
91
|
+
|
92
|
+
def assert_introspection_response_match(json_response, claim_key, expected_value)
|
93
|
+
expected_value = expected_value.strip
|
94
|
+
claim_value = get_json_claim_value(json_response, claim_key)
|
95
|
+
claim_value = claim_value.strip
|
96
|
+
assert claim_value.eql?(expected_value),
|
97
|
+
"Failure: expected introspection response value for '#{claim_key}' to match expected value '#{expected_value}'"
|
98
|
+
end
|
99
|
+
|
100
|
+
run do
|
101
|
+
skip_if active_token_introspection_response_body.nil?, 'No introspection response available to validate.'
|
102
|
+
assert_valid_json(active_token_introspection_response_body)
|
103
|
+
active_introspection_response_body_parsed = JSON.parse(active_token_introspection_response_body)
|
104
|
+
|
105
|
+
# Required Fields
|
106
|
+
assert active_introspection_response_body_parsed['active'] == true, "Failure: expected introspection response for 'active' to be Boolean value true for valid token"
|
107
|
+
assert_introspection_response_match(active_introspection_response_body_parsed, 'client_id', standalone_client_id)
|
108
|
+
|
109
|
+
response_scope_value = get_json_claim_value(active_introspection_response_body_parsed, 'scope')
|
110
|
+
|
111
|
+
# splitting contents and comparing values allows a scope lists with the same contents but different orders to still pass
|
112
|
+
response_scopes_split = response_scope_value.split()
|
113
|
+
expected_scopes_split = standalone_received_scopes.split()
|
114
|
+
|
115
|
+
assert response_scopes_split.length() == expected_scopes_split.length(),
|
116
|
+
"Failure: number of scopes in introspection response, #{response_scopes_split.length()}, does not match number of scopes in access token response, #{expected_scopes_split.length()}"
|
117
|
+
|
118
|
+
expected_scopes_split.each do |scope|
|
119
|
+
assert response_scopes_split.include?(scope), "Failure: expected scope '#{scope}' not present in introspection response scopes"
|
120
|
+
end
|
121
|
+
|
122
|
+
# Cannot verify exact value for exp, so instead ensure its value represents a time >= 10 minutes in the past
|
123
|
+
exp = active_introspection_response_body_parsed['exp']
|
124
|
+
assert exp != nil, "Failure: introspection response has no claim for 'exp'"
|
125
|
+
current_time = Time.now.to_i
|
126
|
+
assert exp.to_i >= current_time - 600, "Failure: expired token, exp claim of #{exp} for active token is more than 10 minutes in the past"
|
127
|
+
|
128
|
+
# Conditional fields
|
129
|
+
assert_introspection_response_match(active_introspection_response_body_parsed, 'patient', standalone_patient_id) if standalone_patient_id.present?
|
130
|
+
assert_introspection_response_match(active_introspection_response_body_parsed, 'encounter', standalone_encounter_id) if standalone_encounter_id.present?
|
131
|
+
|
132
|
+
# ID Token Fields
|
133
|
+
if standalone_id_token.present?
|
134
|
+
id_payload, id_header =
|
135
|
+
JWT.decode(
|
136
|
+
standalone_id_token,
|
137
|
+
nil,
|
138
|
+
false
|
139
|
+
)
|
140
|
+
|
141
|
+
# Required fields if ID token present
|
142
|
+
id_token_iss = id_payload['iss']
|
143
|
+
id_token_sub = id_payload['sub']
|
144
|
+
|
145
|
+
assert id_token_iss != nil, "Failure: ID token from access token response does not have 'iss' claim"
|
146
|
+
assert id_token_sub != nil, "Failure: ID token from access token response does not have 'sub' claim"
|
147
|
+
assert_introspection_response_match(active_introspection_response_body_parsed, 'iss', id_token_iss)
|
148
|
+
assert_introspection_response_match(active_introspection_response_body_parsed, 'sub', id_token_sub)
|
149
|
+
|
150
|
+
# fhirUser not required but recommended
|
151
|
+
fhirUser_id_claim = id_payload['fhirUser']
|
152
|
+
fhirUser_intr_claim = active_introspection_response_body_parsed['fhirUser']
|
153
|
+
|
154
|
+
info do
|
155
|
+
assert fhirUser_intr_claim != nil, "Introspection response SHOULD include claim for fhirUser because ID token present in access token response" if fhirUser_id_claim != nil
|
156
|
+
assert fhirUser_intr_claim.eql?(fhirUser_id_claim), "Introspection response claim for fhirUser SHOULD match value in ID token" if fhirUser_id_claim != nil
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
test do
|
163
|
+
title 'Token introspection response for an invalid token contains required fields'
|
164
|
+
|
165
|
+
description %(
|
166
|
+
From [RFC7662 OAuth2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2):
|
167
|
+
"If the introspection call is properly authorized but the token is not
|
168
|
+
active, does not exist on this server, or the protected resource is
|
169
|
+
not allowed to introspect this particular token, then the
|
170
|
+
authorization server MUST return an introspection response with the
|
171
|
+
"active" field set to "false". Note that to avoid disclosing too
|
172
|
+
much of the authorization server's state to a third party, the
|
173
|
+
authorization server SHOULD NOT include any additional information
|
174
|
+
about an inactive token, including why the token is inactive."
|
175
|
+
)
|
176
|
+
|
177
|
+
input :invalid_token_introspection_response_body,
|
178
|
+
title: 'Invalid Token Introspection Response Body',
|
179
|
+
type: 'textarea',
|
180
|
+
description: 'The JSON body of the token introspection response when provided an INVALID token'
|
181
|
+
|
182
|
+
run do
|
183
|
+
skip_if invalid_token_introspection_response_body.nil?, 'No invalid introspection response available to validate.'
|
184
|
+
assert_valid_json(invalid_token_introspection_response_body)
|
185
|
+
invalid_token_introspection_response_body_parsed = JSON.parse(invalid_token_introspection_response_body)
|
186
|
+
assert invalid_token_introspection_response_body_parsed['active'] == false, "Failure: expected introspection response for 'active' to be Boolean value false for invalid token"
|
187
|
+
assert invalid_token_introspection_response_body_parsed.size == 1, "Failure: expected only 'active' field to be present in introspection response for invalid token"
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
@@ -3,6 +3,69 @@ module SMARTAppLaunch
|
|
3
3
|
STRING_FIELDS = ['access_token', 'token_type', 'scope', 'refresh_token'].freeze
|
4
4
|
NUMERIC_FIELDS = ['expires_in'].freeze
|
5
5
|
|
6
|
+
# All resource types from DSTU3, STU3, R4, R4B, and R5
|
7
|
+
FHIR_RESOURCE_TYPES = [
|
8
|
+
"Account", "ActivityDefinition", "ActorDefinition",
|
9
|
+
"AdministrableProductDefinition", "AdverseEvent", "AllergyIntolerance",
|
10
|
+
"Appointment", "AppointmentResponse", "ArtifactAssessment", "AuditEvent",
|
11
|
+
"Basic", "Binary", "BiologicallyDerivedProduct",
|
12
|
+
"BiologicallyDerivedProductDispense", "BodySite", "BodyStructure",
|
13
|
+
"Bundle", "CapabilityStatement", "CarePlan", "CareTeam", "CatalogEntry",
|
14
|
+
"ChargeItem", "ChargeItemDefinition", "Citation", "Claim",
|
15
|
+
"ClaimResponse", "ClinicalImpression", "ClinicalUseDefinition",
|
16
|
+
"CodeSystem", "Communication", "CommunicationRequest",
|
17
|
+
"CompartmentDefinition", "Composition", "ConceptMap", "Condition",
|
18
|
+
"ConditionDefinition", "Conformance", "Consent", "Contract", "Coverage",
|
19
|
+
"CoverageEligibilityRequest", "CoverageEligibilityResponse",
|
20
|
+
"DataElement", "DetectedIssue", "Device", "DeviceAssociation",
|
21
|
+
"DeviceComponent", "DeviceDefinition", "DeviceDispense", "DeviceMetric",
|
22
|
+
"DeviceRequest", "DeviceUsage", "DeviceUseRequest", "DeviceUseStatement",
|
23
|
+
"DiagnosticOrder", "DiagnosticReport", "DocumentManifest",
|
24
|
+
"DocumentReference", "EffectEvidenceSynthesis", "EligibilityRequest",
|
25
|
+
"EligibilityResponse", "Encounter", "EncounterHistory", "Endpoint",
|
26
|
+
"EnrollmentRequest", "EnrollmentResponse", "EpisodeOfCare",
|
27
|
+
"EventDefinition", "Evidence", "EvidenceReport", "EvidenceVariable",
|
28
|
+
"ExampleScenario", "ExpansionProfile", "ExplanationOfBenefit",
|
29
|
+
"FamilyMemberHistory", "Flag", "FormularyItem", "GenomicStudy", "Goal",
|
30
|
+
"GraphDefinition", "Group", "GuidanceResponse", "HealthcareService",
|
31
|
+
"ImagingManifest", "ImagingObjectSelection", "ImagingSelection",
|
32
|
+
"ImagingStudy", "Immunization", "ImmunizationEvaluation",
|
33
|
+
"ImmunizationRecommendation", "ImplementationGuide", "Ingredient",
|
34
|
+
"InsurancePlan", "InventoryItem", "InventoryReport", "Invoice", "Library",
|
35
|
+
"Linkage", "List", "Location", "ManufacturedItemDefinition", "Measure",
|
36
|
+
"MeasureReport", "Media", "Medication", "MedicationAdministration",
|
37
|
+
"MedicationDispense", "MedicationKnowledge", "MedicationOrder",
|
38
|
+
"MedicationRequest", "MedicationStatement", "MedicinalProduct",
|
39
|
+
"MedicinalProductAuthorization", "MedicinalProductContraindication",
|
40
|
+
"MedicinalProductDefinition", "MedicinalProductIndication",
|
41
|
+
"MedicinalProductIngredient", "MedicinalProductInteraction",
|
42
|
+
"MedicinalProductManufactured", "MedicinalProductPackaged",
|
43
|
+
"MedicinalProductPharmaceutical", "MedicinalProductUndesirableEffect",
|
44
|
+
"MessageDefinition", "MessageHeader", "MolecularSequence", "NamingSystem",
|
45
|
+
"NutritionIntake", "NutritionOrder", "NutritionProduct", "Observation",
|
46
|
+
"ObservationDefinition", "OperationDefinition", "OperationOutcome",
|
47
|
+
"Order", "OrderResponse", "Organization", "OrganizationAffiliation",
|
48
|
+
"PackagedProductDefinition", "Patient", "PaymentNotice",
|
49
|
+
"PaymentReconciliation", "Permission", "Person", "PlanDefinition",
|
50
|
+
"Practitioner", "PractitionerRole", "Procedure", "ProcedureRequest",
|
51
|
+
"ProcessRequest", "ProcessResponse", "Provenance", "Questionnaire",
|
52
|
+
"QuestionnaireResponse", "ReferralRequest", "RegulatedAuthorization",
|
53
|
+
"RelatedPerson", "RequestGroup", "RequestOrchestration", "Requirements",
|
54
|
+
"ResearchDefinition", "ResearchElementDefinition", "ResearchStudy",
|
55
|
+
"ResearchSubject", "RiskAssessment", "RiskEvidenceSynthesis", "Schedule",
|
56
|
+
"SearchParameter", "Sequence", "ServiceDefinition", "ServiceRequest",
|
57
|
+
"Slot", "Specimen", "SpecimenDefinition", "StructureDefinition",
|
58
|
+
"StructureMap", "Subscription", "SubscriptionStatus", "SubscriptionTopic",
|
59
|
+
"Substance", "SubstanceDefinition", "SubstanceNucleicAcid",
|
60
|
+
"SubstancePolymer", "SubstanceProtein", "SubstanceReferenceInformation",
|
61
|
+
"SubstanceSourceMaterial", "SubstanceSpecification", "SupplyDelivery",
|
62
|
+
"SupplyRequest", "Task", "TerminologyCapabilities", "TestPlan",
|
63
|
+
"TestReport", "TestScript", "Transport", "ValueSet", "VerificationResult",
|
64
|
+
"VisionPrescription"
|
65
|
+
].to_set.freeze
|
66
|
+
|
67
|
+
FHIR_ID_REGEX = /[A-Za-z0-9\-\.]{1,64}(\/_history\/[A-Za-z0-9\-\.]{1,64})?(#[A-Za-z0-9\-\.]{1,64})?/.freeze
|
68
|
+
|
6
69
|
def validate_required_fields_present(body, required_fields)
|
7
70
|
missing_fields = required_fields.select { |field| body[field].blank? }
|
8
71
|
missing_fields_string = missing_fields.map { |field| "`#{field}`" }.join(', ')
|
@@ -49,5 +112,25 @@ module SMARTAppLaunch
|
|
49
112
|
"Expected `#{field}` to be a Numeric, but found #{body[field].class.name}"
|
50
113
|
end
|
51
114
|
end
|
115
|
+
|
116
|
+
def validate_fhir_context(fhir_context)
|
117
|
+
return if fhir_context.nil?
|
118
|
+
|
119
|
+
assert fhir_context.is_a?(Array), "`fhirContext` field is a #{fhir_context.class.name}, but should be an Array"
|
120
|
+
|
121
|
+
fhir_context.each do |reference|
|
122
|
+
assert reference.is_a?(String), "`#{reference.inspect}` is not a string"
|
123
|
+
end
|
124
|
+
|
125
|
+
fhir_context.each do |reference|
|
126
|
+
assert !reference.start_with?('http'), "`#{reference}` is not a relative reference"
|
127
|
+
|
128
|
+
resource_type, id = reference.split('/')
|
129
|
+
assert FHIR_RESOURCE_TYPES.include?(resource_type),
|
130
|
+
"`#{resource_type}` is not a valid FHIR resource type"
|
131
|
+
|
132
|
+
assert id.match?(FHIR_ID_REGEX), "`#{id}` is not a valid FHIR id"
|
133
|
+
end
|
134
|
+
end
|
52
135
|
end
|
53
136
|
end
|
@@ -11,6 +11,8 @@ module SMARTAppLaunch
|
|
11
11
|
has been denied. `access_token`, `token_type`, and `scope` are required.
|
12
12
|
`token_type` must be Bearer. `expires_in` is required for token
|
13
13
|
refreshes.
|
14
|
+
|
15
|
+
The format of the optional `fhirContext` field is validated if present.
|
14
16
|
)
|
15
17
|
id :smart_token_response_body
|
16
18
|
|
@@ -48,6 +50,8 @@ module SMARTAppLaunch
|
|
48
50
|
assert access_token.present?, 'Token response did not contain an access token'
|
49
51
|
assert token_response_body['token_type']&.casecmp('Bearer')&.zero?,
|
50
52
|
'`token_type` field must have a value of `Bearer`'
|
53
|
+
|
54
|
+
validate_fhir_context(token_response_body['fhirContext'])
|
51
55
|
end
|
52
56
|
end
|
53
57
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: smart_app_launch_test_kit
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Stephen MacVicar
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-12-08 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: inferno_core
|
@@ -30,14 +30,14 @@ dependencies:
|
|
30
30
|
requirements:
|
31
31
|
- - "~>"
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version: '2.
|
33
|
+
version: '2.6'
|
34
34
|
type: :runtime
|
35
35
|
prerelease: false
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
37
37
|
requirements:
|
38
38
|
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
|
-
version: '2.
|
40
|
+
version: '2.6'
|
41
41
|
- !ruby/object:Gem::Dependency
|
42
42
|
name: tls_test_kit
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
@@ -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
|