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