smart_app_launch_test_kit 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|