smart_app_launch_test_kit 0.0.1
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 +7 -0
- data/LICENSE +201 -0
- data/lib/smart_app_launch/app_launch_test.rb +25 -0
- data/lib/smart_app_launch/app_redirect_test.rb +61 -0
- data/lib/smart_app_launch/code_received_test.rb +31 -0
- data/lib/smart_app_launch/discovery_group.rb +229 -0
- data/lib/smart_app_launch/ehr_launch_group.rb +117 -0
- data/lib/smart_app_launch/launch_received_test.rb +19 -0
- data/lib/smart_app_launch/openid_connect_group.rb +83 -0
- data/lib/smart_app_launch/openid_decode_id_token_test.rb +30 -0
- data/lib/smart_app_launch/openid_fhir_user_claim_test.rb +30 -0
- data/lib/smart_app_launch/openid_required_configuration_fields_test.rb +50 -0
- data/lib/smart_app_launch/openid_retrieve_configuration_test.rb +31 -0
- data/lib/smart_app_launch/openid_retrieve_jwks_test.rb +43 -0
- data/lib/smart_app_launch/openid_token_header_test.rb +39 -0
- data/lib/smart_app_launch/openid_token_payload_test.rb +64 -0
- data/lib/smart_app_launch/standalone_launch_group.rb +104 -0
- data/lib/smart_app_launch/token_exchange_test.rb +47 -0
- data/lib/smart_app_launch/token_payload_validation.rb +47 -0
- data/lib/smart_app_launch/token_refresh_body_test.rb +52 -0
- data/lib/smart_app_launch/token_refresh_group.rb +45 -0
- data/lib/smart_app_launch/token_refresh_test.rb +52 -0
- data/lib/smart_app_launch/token_response_body_test.rb +53 -0
- data/lib/smart_app_launch/token_response_headers_test.rb +27 -0
- data/lib/smart_app_launch_test_kit.rb +141 -0
- metadata +168 -0
@@ -0,0 +1,19 @@
|
|
1
|
+
module SMARTAppLaunch
|
2
|
+
class LaunchReceivedTest < Inferno::Test
|
3
|
+
title 'EHR server sends launch parameter'
|
4
|
+
description %(
|
5
|
+
Code is a required querystring parameter on the redirect.
|
6
|
+
)
|
7
|
+
id :smart_launch_received
|
8
|
+
|
9
|
+
output :launch
|
10
|
+
uses_request :launch
|
11
|
+
|
12
|
+
run do
|
13
|
+
launch = request.query_parameters['launch']
|
14
|
+
output launch: launch
|
15
|
+
|
16
|
+
assert launch.present?, 'No `launch` paramater received'
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
require 'jwt'
|
2
|
+
require_relative 'openid_decode_id_token_test'
|
3
|
+
require_relative 'openid_retrieve_configuration_test'
|
4
|
+
require_relative 'openid_required_configuration_fields_test'
|
5
|
+
require_relative 'openid_retrieve_jwks_test'
|
6
|
+
require_relative 'openid_token_header_test'
|
7
|
+
require_relative 'openid_token_payload_test'
|
8
|
+
require_relative 'openid_fhir_user_claim_test'
|
9
|
+
|
10
|
+
module SMARTAppLaunch
|
11
|
+
class OpenIDConnectGroup < Inferno::TestGroup
|
12
|
+
id :smart_openid_connect
|
13
|
+
title 'OpenID Connect'
|
14
|
+
|
15
|
+
description %(
|
16
|
+
# Background
|
17
|
+
|
18
|
+
OpenID Connect (OIDC) provides the ability to verify the identity of the
|
19
|
+
authorizing user. Within the [SMART App Launch
|
20
|
+
Framework](http://hl7.org/fhir/smart-app-launch/), Applications can
|
21
|
+
request an `id_token` be provided with by including the `openid fhirUser`
|
22
|
+
scopes when requesting authorization.
|
23
|
+
|
24
|
+
# Test Methodology
|
25
|
+
|
26
|
+
This sequence validates the id token returned as part of the OAuth 2.0
|
27
|
+
token response. Once the token is decoded, the server's OIDC configuration
|
28
|
+
is retrieved from its well-known configuration endpoint. This
|
29
|
+
configuration is checked to ensure that all required fields are present.
|
30
|
+
Next the keys used to cryptographically sign the id token are retrieved
|
31
|
+
from the url contained in the OIDC configuration. Then the header,
|
32
|
+
payload, and signature of the id token are validated. Finally, the FHIR
|
33
|
+
resource from the `fhirUser` claim in the id token is fetched from the
|
34
|
+
FHIR server.
|
35
|
+
|
36
|
+
For more information see:
|
37
|
+
|
38
|
+
* [SMART App Launch Framework](http://hl7.org/fhir/smart-app-launch/)
|
39
|
+
* [Scopes for requesting identity data](http://hl7.org/fhir/smart-app-launch/scopes-and-launch-context/index.html#scopes-for-requesting-identity-data)
|
40
|
+
* [Apps Requesting Authorization](http://hl7.org/fhir/smart-app-launch/#step-1-app-asks-for-authorization)
|
41
|
+
* [OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html)
|
42
|
+
)
|
43
|
+
|
44
|
+
test from: :smart_openid_decode_id_token
|
45
|
+
|
46
|
+
test from: :smart_openid_retrieve_configuration
|
47
|
+
|
48
|
+
test from: :smart_openid_required_configuration_fields
|
49
|
+
|
50
|
+
test from: :smart_openid_retrieve_jwks
|
51
|
+
|
52
|
+
test from: :smart_openid_token_header
|
53
|
+
|
54
|
+
test from: :smart_openid_token_payload
|
55
|
+
|
56
|
+
test from: :smart_openid_fhir_user_claim
|
57
|
+
|
58
|
+
# test do
|
59
|
+
# id :smart_openid_fhir_user_retrieval
|
60
|
+
# title 'fhirUser can be retrieved'
|
61
|
+
# description %(
|
62
|
+
# Verify that the FHIR resource referred to in the `fhirUser` claim can be
|
63
|
+
# retrieved.
|
64
|
+
# )
|
65
|
+
|
66
|
+
# input :id_token_fhir_user, :openid_issuer, :standalone_access_token
|
67
|
+
# makes_request :id_token_fhir_user
|
68
|
+
|
69
|
+
# run do
|
70
|
+
# skip_if id_token_fhir_user.blank?
|
71
|
+
|
72
|
+
# split_fhir_user = id_token_fhir_user.split('/')
|
73
|
+
# resource_type = split_fhir_user[-2]
|
74
|
+
# resource_id = split_fhir_user[-1]
|
75
|
+
# fhir_read(resource_type, resource_id)
|
76
|
+
|
77
|
+
# assert_response_status(200)
|
78
|
+
# assert_valid_json(response[:body])
|
79
|
+
# assert_resource_type(resource_type)
|
80
|
+
# end
|
81
|
+
# end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module SMARTAppLaunch
|
2
|
+
class OpenIDDecodeIDTokenTest < Inferno::Test
|
3
|
+
id :smart_openid_decode_id_token
|
4
|
+
title 'ID token can be decoded'
|
5
|
+
description %(
|
6
|
+
Verify that the ID token is a properly constructed JWT.
|
7
|
+
)
|
8
|
+
|
9
|
+
input :id_token
|
10
|
+
output :id_token_payload_json, :id_token_header_json
|
11
|
+
|
12
|
+
run do
|
13
|
+
skip_if id_token.blank?
|
14
|
+
|
15
|
+
begin
|
16
|
+
payload, header =
|
17
|
+
JWT.decode(
|
18
|
+
id_token,
|
19
|
+
nil,
|
20
|
+
false
|
21
|
+
)
|
22
|
+
|
23
|
+
output id_token_payload_json: payload.to_json,
|
24
|
+
id_token_header_json: header.to_json
|
25
|
+
rescue StandardError => e
|
26
|
+
assert false, "ID token is not a properly constructed JWT: #{e.message}"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module SMARTAppLaunch
|
2
|
+
class OpenIDFHIRUserClaimTest < Inferno::Test
|
3
|
+
id :smart_openid_fhir_user_claim
|
4
|
+
title 'ID token contains a valid fhirUser claim'
|
5
|
+
description %(
|
6
|
+
Verify that the `fhirUser` claim is present in the ID token. The
|
7
|
+
`fhirUser` claim must be the url for a Patient, Practitioner,
|
8
|
+
RelatedPerson, or Person resource.
|
9
|
+
)
|
10
|
+
|
11
|
+
input :id_token_payload_json, :requested_scopes
|
12
|
+
output :id_token_fhir_user
|
13
|
+
|
14
|
+
run do
|
15
|
+
skip_if id_token_payload_json.blank?
|
16
|
+
skip_if !requested_scopes&.include?('fhirUser'), '`fhirUser` scope not requested'
|
17
|
+
|
18
|
+
payload = JSON.parse(id_token_payload_json)
|
19
|
+
fhir_user = payload['fhirUser']
|
20
|
+
|
21
|
+
valid_fhir_user_resource_types = ['Patient', 'Practitioner', 'RelatedPerson', 'Person']
|
22
|
+
|
23
|
+
assert fhir_user.present?, 'ID token does not contain `fhirUser` claim'
|
24
|
+
assert valid_fhir_user_resource_types.any? { |type| fhir_user.include? type },
|
25
|
+
"ID token `fhirUser` claim does not refer to a valid resource type: #{fhir_user}"
|
26
|
+
|
27
|
+
output id_token_fhir_user: fhir_user
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module SMARTAppLaunch
|
2
|
+
class OpenIDRequiredConfigurationFieldsTest < Inferno::Test
|
3
|
+
id :smart_openid_required_configuration_fields
|
4
|
+
title 'OpenID Connect well-known configuration contains all required fields'
|
5
|
+
description %(
|
6
|
+
Verify that the OpenId Connect configuration contains the following
|
7
|
+
required fields: `issuer`, `authorization_endpoint`, `token_endpoint`,
|
8
|
+
`jwks_uri`, `response_types_supported`, `subject_types_supported`, and
|
9
|
+
`id_token_signing_alg_values_supported`.
|
10
|
+
|
11
|
+
Additionally, the [SMART App Launch
|
12
|
+
Framework](http://www.hl7.org/fhir/smart-app-launch/scopes-and-launch-context/index.html#scopes-for-requesting-identity-data)
|
13
|
+
requires that the RSA SHA-256 signing algorithm be supported.
|
14
|
+
)
|
15
|
+
|
16
|
+
input :openid_configuration_json
|
17
|
+
output :openid_jwks_uri
|
18
|
+
|
19
|
+
REQUIRED_FIELDS =
|
20
|
+
[
|
21
|
+
'issuer',
|
22
|
+
'authorization_endpoint',
|
23
|
+
'token_endpoint',
|
24
|
+
'jwks_uri',
|
25
|
+
'response_types_supported',
|
26
|
+
'subject_types_supported',
|
27
|
+
'id_token_signing_alg_values_supported'
|
28
|
+
].freeze
|
29
|
+
|
30
|
+
def required_fields
|
31
|
+
REQUIRED_FIELDS.dup
|
32
|
+
end
|
33
|
+
|
34
|
+
run do
|
35
|
+
skip_if openid_configuration_json.blank?
|
36
|
+
|
37
|
+
configuration = JSON.parse(openid_configuration_json)
|
38
|
+
output openid_jwks_uri: configuration['jwks_uri']
|
39
|
+
|
40
|
+
missing_fields = required_fields - configuration.keys
|
41
|
+
missing_fields_string = missing_fields.map { |field| "`#{field}`" }.join(', ')
|
42
|
+
|
43
|
+
assert missing_fields.empty?,
|
44
|
+
"OpenID Connect well-known configuration missing required fields: #{missing_fields_string}"
|
45
|
+
|
46
|
+
assert configuration['id_token_signing_alg_values_supported'].include?('RS256'),
|
47
|
+
'Signing tokens with RSA SHA-256 not supported'
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module SMARTAppLaunch
|
2
|
+
class OpenIDRetrieveConfigurationTest < Inferno::Test
|
3
|
+
id :smart_openid_retrieve_configuration
|
4
|
+
title 'OpenID Connect well-known configuration can be retrieved'
|
5
|
+
description %(
|
6
|
+
Verify that the OpenId Connect configuration can be retrieved as
|
7
|
+
described in the OpenID Connect Discovery 1.0 documentation.
|
8
|
+
)
|
9
|
+
|
10
|
+
input :id_token_payload_json
|
11
|
+
output :openid_configuration_json, :openid_issuer
|
12
|
+
makes_request :openid_configuration
|
13
|
+
|
14
|
+
run do
|
15
|
+
skip_if id_token_payload_json.blank?
|
16
|
+
|
17
|
+
payload = JSON.parse(id_token_payload_json)
|
18
|
+
issuer = payload['iss']
|
19
|
+
|
20
|
+
configuration_url = "#{issuer.chomp('/')}/.well-known/openid-configuration"
|
21
|
+
get(configuration_url, name: :openid_configuration)
|
22
|
+
|
23
|
+
assert_response_status(200)
|
24
|
+
assert_response_content_type('application/json')
|
25
|
+
assert_valid_json(response[:body])
|
26
|
+
|
27
|
+
output openid_configuration_json: response[:body],
|
28
|
+
openid_issuer: issuer
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module SMARTAppLaunch
|
2
|
+
class OpenIDRetrieveJWKSTest < Inferno::Test
|
3
|
+
id :smart_openid_retrieve_jwks
|
4
|
+
title 'JWKS can be retrieved'
|
5
|
+
description %(
|
6
|
+
Verify that the JWKS can be retrieved from the `jwks_uri` from the
|
7
|
+
OpenID Connect well-known configuration.
|
8
|
+
)
|
9
|
+
|
10
|
+
input :openid_jwks_uri
|
11
|
+
output :openid_jwks_json, :openid_rsa_keys_json
|
12
|
+
makes_request :openid_jwks
|
13
|
+
|
14
|
+
run do
|
15
|
+
skip_if openid_jwks_uri.blank?
|
16
|
+
|
17
|
+
get(openid_jwks_uri, name: :openid_jwks)
|
18
|
+
|
19
|
+
assert_response_status(200)
|
20
|
+
assert_valid_json(response[:body])
|
21
|
+
output openid_jwks_json: response[:body]
|
22
|
+
|
23
|
+
raw_jwks = JSON.parse(response[:body])
|
24
|
+
assert raw_jwks['keys'].is_a?(Array), 'JWKS `keys` field must be an array'
|
25
|
+
|
26
|
+
# https://tools.ietf.org/html/rfc7517#section-5
|
27
|
+
# Implementations SHOULD ignore JWKs within a JWK Set that use "kty"
|
28
|
+
# (key type) values that are not understood by them.
|
29
|
+
# SMART only requires support of RSA SHA-256 keys
|
30
|
+
rsa_keys = raw_jwks['keys'].select { |jwk| jwk['kty'] == 'RSA' }
|
31
|
+
|
32
|
+
assert rsa_keys.present?, 'JWKS contains no RSA keys'
|
33
|
+
|
34
|
+
rsa_keys.each do |jwk|
|
35
|
+
JWT::JWK.import(jwk.deep_symbolize_keys)
|
36
|
+
rescue StandardError
|
37
|
+
assert false, "Invalid JWK: #{jwk.to_json}"
|
38
|
+
end
|
39
|
+
|
40
|
+
output openid_rsa_keys_json: rsa_keys.to_json
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module SMARTAppLaunch
|
2
|
+
class OpenIDTokenHeaderTest < Inferno::Test
|
3
|
+
id :smart_openid_token_header
|
4
|
+
title 'ID token header contains required information'
|
5
|
+
description %(
|
6
|
+
Verify that the id token header indicates that the tokenis signed using
|
7
|
+
RSA SHA-256 [as required by the SMART app launch
|
8
|
+
framework](http://www.hl7.org/fhir/smart-app-launch/scopes-and-launch-context/index.html#scopes-for-requesting-identity-data)
|
9
|
+
and that the key used to sign the token can be identified in the JWKS.
|
10
|
+
)
|
11
|
+
|
12
|
+
input :id_token_header_json, :openid_rsa_keys_json
|
13
|
+
output :id_token_jwk_json
|
14
|
+
|
15
|
+
run do
|
16
|
+
skip_if id_token_header_json.blank?
|
17
|
+
skip_if openid_rsa_keys_json.blank?
|
18
|
+
|
19
|
+
header = JSON.parse(id_token_header_json)
|
20
|
+
algorithm = header['alg']
|
21
|
+
|
22
|
+
assert algorithm == 'RS256', "ID Token signed with `#{algorithm}` rather than RS256"
|
23
|
+
|
24
|
+
kid = header['kid']
|
25
|
+
rsa_keys = JSON.parse(openid_rsa_keys_json)
|
26
|
+
|
27
|
+
if rsa_keys.length > 1
|
28
|
+
assert kid.present?, '`kid` field must be present if JWKS contains multiple keys'
|
29
|
+
jwk = rsa_keys.find { |key| key['kid'] == kid }
|
30
|
+
assert jwk.present?, "JWKS did not contain an RS256 key with an id of `#{kid}`"
|
31
|
+
else
|
32
|
+
jwk = rsa_keys.first
|
33
|
+
assert kid.blank? || jwk['kid'] == kid, "JWKS did not contain an RS256 key with an id of `#{kid}`"
|
34
|
+
end
|
35
|
+
|
36
|
+
output id_token_jwk_json: jwk.to_json
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require_relative 'token_payload_validation'
|
2
|
+
|
3
|
+
module SMARTAppLaunch
|
4
|
+
class OpenIDTokenPayloadTest < Inferno::Test
|
5
|
+
include TokenPayloadValidation
|
6
|
+
id :smart_openid_token_payload
|
7
|
+
title 'ID token payload has required claims and a valid signature'
|
8
|
+
description %(
|
9
|
+
The `iss`, `sub`, `aud`, `exp`, and `iat` claims are required.
|
10
|
+
Additionally:
|
11
|
+
|
12
|
+
- `iss` must match the `issuer` from the OpenID Connect well-known
|
13
|
+
configuration
|
14
|
+
- `aud` must match the client ID
|
15
|
+
- `exp` must represent a time in the future
|
16
|
+
)
|
17
|
+
|
18
|
+
REQUIRED_CLAIMS = ['iss', 'sub', 'aud', 'exp', 'iat'].freeze
|
19
|
+
|
20
|
+
def required_claims
|
21
|
+
REQUIRED_CLAIMS.dup
|
22
|
+
end
|
23
|
+
|
24
|
+
input :id_token,
|
25
|
+
:openid_configuration_json,
|
26
|
+
:id_token_jwk_json,
|
27
|
+
:client_id
|
28
|
+
|
29
|
+
run do
|
30
|
+
skip_if id_token.blank?, 'No ID Token'
|
31
|
+
skip_if openid_configuration_json.blank?, 'No OpenID Configuration found'
|
32
|
+
skip_if id_token_jwk_json.blank?, 'No ID Token jwk found'
|
33
|
+
skip_if client_id.blank?, 'No Client ID'
|
34
|
+
|
35
|
+
begin
|
36
|
+
configuration = JSON.parse(openid_configuration_json)
|
37
|
+
jwk = JSON.parse(id_token_jwk_json).deep_symbolize_keys
|
38
|
+
payload, =
|
39
|
+
JWT.decode(
|
40
|
+
id_token,
|
41
|
+
JWT::JWK.import(jwk).public_key,
|
42
|
+
true,
|
43
|
+
algorithms: ['RS256'],
|
44
|
+
exp_leeway: 60,
|
45
|
+
iss: configuration['issuer'],
|
46
|
+
aud: client_id,
|
47
|
+
verify_not_before: false,
|
48
|
+
verify_iat: false,
|
49
|
+
verify_jti: false,
|
50
|
+
verify_sub: false,
|
51
|
+
verify_iss: true,
|
52
|
+
verify_aud: true
|
53
|
+
)
|
54
|
+
rescue StandardError => e
|
55
|
+
assert false, "Token validation error: #{e.message}"
|
56
|
+
end
|
57
|
+
|
58
|
+
missing_claims = required_claims - payload.keys
|
59
|
+
missing_claims_string = missing_claims.map { |claim| "`#{claim}`" }.join(', ')
|
60
|
+
|
61
|
+
assert missing_claims.empty?, "ID token missing required claims: #{missing_claims_string}"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
require_relative 'app_redirect_test'
|
2
|
+
require_relative 'code_received_test'
|
3
|
+
require_relative 'token_exchange_test'
|
4
|
+
require_relative 'token_response_body_test'
|
5
|
+
require_relative 'token_response_headers_test'
|
6
|
+
|
7
|
+
module SMARTAppLaunch
|
8
|
+
class StandaloneLaunchGroup < Inferno::TestGroup
|
9
|
+
id :smart_standalone_launch
|
10
|
+
title 'SMART Standalone Launch'
|
11
|
+
|
12
|
+
description %(
|
13
|
+
# Background
|
14
|
+
|
15
|
+
The [Standalone
|
16
|
+
Launch](http://hl7.org/fhir/smart-app-launch/#standalone-launch-sequence)
|
17
|
+
Sequence allows an app, like Inferno, to be launched independent of an
|
18
|
+
existing EHR session. It is one of the two launch methods described in
|
19
|
+
the SMART App Launch Framework alongside EHR Launch. The app will
|
20
|
+
request authorization for the provided scope from the authorization
|
21
|
+
endpoint, ultimately receiving an authorization token which can be used
|
22
|
+
to gain access to resources on the FHIR server.
|
23
|
+
|
24
|
+
# Test Methodology
|
25
|
+
|
26
|
+
Inferno will redirect the user to the the authorization endpoint so that
|
27
|
+
they may provide any required credentials and authorize the application.
|
28
|
+
Upon successful authorization, Inferno will exchange the authorization
|
29
|
+
code provided for an access token.
|
30
|
+
|
31
|
+
For more information on the #{title}:
|
32
|
+
|
33
|
+
* [Standalone Launch Sequence](http://hl7.org/fhir/smart-app-launch/#standalone-launch-sequence)
|
34
|
+
)
|
35
|
+
|
36
|
+
config(
|
37
|
+
inputs: {
|
38
|
+
client_id: {
|
39
|
+
name: :standalone_client_id,
|
40
|
+
title: 'Standalone Client ID',
|
41
|
+
description: 'Client ID provided during registration of Inferno as a standalone application',
|
42
|
+
default: 'SAMPLE_PUBLIC_CLIENT_ID'
|
43
|
+
},
|
44
|
+
client_secret: {
|
45
|
+
name: :standalone_client_secret,
|
46
|
+
title: 'Standalone Client Secret',
|
47
|
+
description: 'Client Secret provided during registration of Inferno as a standalone application'
|
48
|
+
},
|
49
|
+
requested_scopes: {
|
50
|
+
name: :standalone_requested_scopes,
|
51
|
+
title: 'Standalone Scope',
|
52
|
+
description: 'OAuth 2.0 scope provided by system to enable all required functionality',
|
53
|
+
type: 'textarea',
|
54
|
+
default: %(
|
55
|
+
launch/patient openid fhirUser offline_access
|
56
|
+
patient/Medication.read patient/AllergyIntolerance.read
|
57
|
+
patient/CarePlan.read patient/CareTeam.read patient/Condition.read
|
58
|
+
patient/Device.read patient/DiagnosticReport.read
|
59
|
+
patient/DocumentReference.read patient/Encounter.read
|
60
|
+
patient/Goal.read patient/Immunization.read patient/Location.read
|
61
|
+
patient/MedicationRequest.read patient/Observation.read
|
62
|
+
patient/Organization.read patient/Patient.read
|
63
|
+
patient/Practitioner.read patient/Procedure.read
|
64
|
+
patient/Provenance.read patient/PractitionerRole.read
|
65
|
+
).gsub(/\s{2,}/, ' ').strip
|
66
|
+
},
|
67
|
+
url: {
|
68
|
+
title: 'Standalone FHIR Endpoint',
|
69
|
+
description: 'URL of the FHIR endpoint used by standalone applications',
|
70
|
+
default: 'https://inferno.healthit.gov/reference-server/r4'
|
71
|
+
},
|
72
|
+
code: {
|
73
|
+
name: :standalone_code
|
74
|
+
},
|
75
|
+
state: {
|
76
|
+
name: :standalone_state
|
77
|
+
}
|
78
|
+
},
|
79
|
+
outputs: {
|
80
|
+
code: { name: :standalone_code },
|
81
|
+
token_retrieval_time: { name: :standalone_token_retrieval_time },
|
82
|
+
state: { name: :standalone_state },
|
83
|
+
id_token: { name: :standalone_id_token },
|
84
|
+
refresh_token: { name: :standalone_refresh_token },
|
85
|
+
access_token: { name: :standalone_access_token },
|
86
|
+
expires_in: { name: :standalone_expires_in },
|
87
|
+
patient_id: { name: :standalone_patient_id },
|
88
|
+
encounter_id: { name: :standalone_encounter_id },
|
89
|
+
received_scopes: { name: :standalone_received_scopes },
|
90
|
+
intent: { name: :standalone_intent }
|
91
|
+
},
|
92
|
+
requests: {
|
93
|
+
redirect: { name: :standalone_redirect },
|
94
|
+
token: { name: :standalone_token }
|
95
|
+
}
|
96
|
+
)
|
97
|
+
|
98
|
+
test from: :smart_app_redirect
|
99
|
+
test from: :smart_code_received
|
100
|
+
test from: :smart_token_exchange
|
101
|
+
test from: :smart_token_response_body
|
102
|
+
test from: :smart_token_response_headers
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module SMARTAppLaunch
|
2
|
+
class TokenExchangeTest < Inferno::Test
|
3
|
+
title 'OAuth token exchange request succeeds when supplied correct information'
|
4
|
+
description %(
|
5
|
+
After obtaining an authorization code, the app trades the code for an
|
6
|
+
access token via HTTP POST to the EHR authorization server's token
|
7
|
+
endpoint URL, using content-type application/x-www-form-urlencoded, as
|
8
|
+
described in section [4.1.3 of
|
9
|
+
RFC6749](https://tools.ietf.org/html/rfc6749#section-4.1.3).
|
10
|
+
)
|
11
|
+
id :smart_token_exchange
|
12
|
+
|
13
|
+
input :code,
|
14
|
+
:smart_token_url,
|
15
|
+
:client_id
|
16
|
+
input :client_secret, optional: true
|
17
|
+
output :token_retrieval_time
|
18
|
+
uses_request :redirect
|
19
|
+
makes_request :token
|
20
|
+
|
21
|
+
config options: { redirect_uri: "#{Inferno::Application['inferno_host']}/custom/smart/redirect" }
|
22
|
+
|
23
|
+
run do
|
24
|
+
skip_if request.query_parameters['error'].present?, 'Error during authorization request'
|
25
|
+
|
26
|
+
oauth2_params = {
|
27
|
+
grant_type: 'authorization_code',
|
28
|
+
code: code,
|
29
|
+
redirect_uri: config.options[:redirect_uri]
|
30
|
+
}
|
31
|
+
oauth2_headers = { 'Content-Type' => 'application/x-www-form-urlencoded' }
|
32
|
+
|
33
|
+
if client_secret.present?
|
34
|
+
client_credentials = "#{client_id}:#{client_secret}"
|
35
|
+
oauth2_headers['Authorization'] = "Basic #{Base64.strict_encode64(client_credentials)}"
|
36
|
+
else
|
37
|
+
oauth2_params[:client_id] = client_id
|
38
|
+
end
|
39
|
+
|
40
|
+
post(smart_token_url, body: oauth2_params, name: :token, headers: oauth2_headers)
|
41
|
+
|
42
|
+
output token_retrieval_time: Time.now.iso8601
|
43
|
+
|
44
|
+
assert_response_status(200)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module SMARTAppLaunch
|
2
|
+
module TokenPayloadValidation
|
3
|
+
STRING_FIELDS = ['access_token', 'token_type', 'scope', 'refresh_token'].freeze
|
4
|
+
NUMERIC_FIELDS = ['expires_in'].freeze
|
5
|
+
|
6
|
+
def validate_required_fields_present(body, required_fields)
|
7
|
+
missing_fields = required_fields.select { |field| body[field].blank? }
|
8
|
+
missing_fields_string = missing_fields.map { |field| "`#{field}`" }.join(', ')
|
9
|
+
assert missing_fields.empty?,
|
10
|
+
"Token exchange response did not include all required fields: #{missing_fields_string}."
|
11
|
+
end
|
12
|
+
|
13
|
+
def validate_token_type(body)
|
14
|
+
assert body['token_type'].casecmp('bearer').zero?, '`token_type` must be `bearer`'
|
15
|
+
end
|
16
|
+
|
17
|
+
def check_for_missing_scopes(requested_scopes, body)
|
18
|
+
expected_scopes = requested_scopes.split
|
19
|
+
new_scopes = body['scope'].split
|
20
|
+
missing_scopes = expected_scopes - new_scopes
|
21
|
+
|
22
|
+
warning do
|
23
|
+
missing_scopes_string = missing_scopes.map { |scope| "`#{scope}`" }.join(', ')
|
24
|
+
assert missing_scopes.empty?, %(
|
25
|
+
Token exchange response did not include all requested scopes.
|
26
|
+
These may have been denied by user: #{missing_scopes_string}.
|
27
|
+
)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def validate_token_field_types(body)
|
32
|
+
STRING_FIELDS
|
33
|
+
.select { |field| body[field].present? }
|
34
|
+
.each do |field|
|
35
|
+
assert body[field].is_a?(String),
|
36
|
+
"Expected `#{field}` to be a String, but found #{body[field].class.name}"
|
37
|
+
end
|
38
|
+
|
39
|
+
NUMERIC_FIELDS
|
40
|
+
.select { |field| body[field].present? }
|
41
|
+
.each do |field|
|
42
|
+
assert body[field].is_a?(Numeric),
|
43
|
+
"Expected `#{field}` to be a Numeric, but found #{body[field].class.name}"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require_relative 'token_payload_validation'
|
2
|
+
|
3
|
+
module SMARTAppLaunch
|
4
|
+
class TokenRefreshBodyTest < Inferno::Test
|
5
|
+
include TokenPayloadValidation
|
6
|
+
|
7
|
+
id :smart_token_refresh_body
|
8
|
+
title 'Server successfully refreshes the access token when optional scope parameter omitted'
|
9
|
+
description %(
|
10
|
+
Server successfully exchanges refresh token at OAuth token endpoint
|
11
|
+
without providing scope in the body of the request.
|
12
|
+
|
13
|
+
The EHR authorization server SHALL return a JSON structure that includes
|
14
|
+
an access token or a message indicating that the authorization request
|
15
|
+
has been denied. `access_token`, `expires_in`, `token_type`, and `scope` are
|
16
|
+
required. `access_token` must be `Bearer`.
|
17
|
+
|
18
|
+
Although not required in the token refresh portion of the SMART App
|
19
|
+
Launch Guide, the token refresh response should include the HTTP
|
20
|
+
Cache-Control response header field with a value of no-store, as well as
|
21
|
+
the Pragma response header field with a value of no-cache to be
|
22
|
+
consistent with the requirements of the inital access token exchange.
|
23
|
+
)
|
24
|
+
input :received_scopes
|
25
|
+
output :refresh_token, :access_token, :token_retrieval_time, :expires_in, :received_scopes
|
26
|
+
uses_request :token_refresh
|
27
|
+
|
28
|
+
run do
|
29
|
+
skip_if request.status != 200, 'Token exchange was unsuccessful'
|
30
|
+
|
31
|
+
assert_valid_json(response[:body])
|
32
|
+
|
33
|
+
body = JSON.parse(response[:body])
|
34
|
+
output refresh_token: body['refresh_token'] if body.key? 'refresh_token'
|
35
|
+
|
36
|
+
required_fields = ['access_token', 'token_type', 'expires_in', 'scope']
|
37
|
+
validate_required_fields_present(body, required_fields)
|
38
|
+
|
39
|
+
old_received_scopes = received_scopes
|
40
|
+
output access_token: body['access_token'],
|
41
|
+
token_retrieval_time: Time.now.iso8601,
|
42
|
+
expires_in: body['expires_in'],
|
43
|
+
received_scopes: body['scope']
|
44
|
+
|
45
|
+
validate_token_field_types(body)
|
46
|
+
validate_token_type(body)
|
47
|
+
|
48
|
+
assert received_scopes.split.sort == old_received_scopes.split.sort,
|
49
|
+
'Received scopes not equal to originally granted scopes'
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|