smart_app_launch_test_kit 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97f3e4dca10ecb9dfe4aa30802d90f4272dd32ffd10715fa7b3a0855a04b5c8d
4
- data.tar.gz: 016a700551ef524e7bdc7f8871beb03f7297dc3cba39e5b4b53349b22f22ed53
3
+ metadata.gz: f66c9b0314200d03d78422dc6412fe30cd0a091cd34d2a4701b9daa9a85d45f6
4
+ data.tar.gz: 7a34428293bfe281ebeebf83e8ee37597ebdfef2bf158cb8a3893598eca09876
5
5
  SHA512:
6
- metadata.gz: 519d2bb8c5bde6a7c5c28f04ec17bfdcff37c7270b6216707a05db4b04c94269d244c6a1888dd97a0c25c5a50764d4969a08c2774dcb636db7fd274301184427
7
- data.tar.gz: eda1f5484f72de3a82c34eb1d0424a2b10a327baeb93d6e6df8f94be7a7e3ef378e5e6510378f62e7d9387e56424b42b52f4524fc557302d3a5ddab0654c782e
6
+ metadata.gz: d5f432c1b4b43a2c84be8613acb0b31dec557b80b1b76996773c76486db5197265a3dc1f0b9a9015eaa35c17c65d5b6b7eb506d940676e0cdae3b798b1cf8799
7
+ data.tar.gz: b63b86f90dbfc0ea88b7cc65e2075333b07a2cee7d42f88f21d209f4a51274545532706ed0278efad3cd4a0316be4ae32e948632f4ba66247d5451c68eff9559
@@ -75,7 +75,7 @@ module SMARTAppLaunch
75
75
  def authorization_url_builder(url, params)
76
76
  uri = URI(url)
77
77
 
78
- # because the URL might have paramters on it
78
+ # because the URL might have parameters on it
79
79
  original_parameters = Hash[URI.decode_www_form(uri.query || '')]
80
80
  new_params = original_parameters.merge(params)
81
81
 
@@ -16,7 +16,7 @@ module SMARTAppLaunch
16
16
  )
17
17
 
18
18
  input :authorization_method,
19
- title: 'Authorization Method',
19
+ title: 'Authorization Request Method',
20
20
  type: 'radio',
21
21
  default: 'get',
22
22
  options: {
@@ -0,0 +1,88 @@
1
+ require_relative 'backend_services_authorization_request_builder'
2
+ require_relative 'backend_services_invalid_grant_type_test'
3
+ require_relative 'backend_services_invalid_client_assertion_test'
4
+ require_relative 'backend_services_invalid_jwt_test'
5
+ require_relative 'backend_services_authorization_request_success_test'
6
+ require_relative 'backend_services_authorization_response_body_test'
7
+ require_relative 'token_exchange_stu2_test'
8
+
9
+ module SMARTAppLaunch
10
+ class BackendServicesAuthorizationGroup < Inferno::TestGroup
11
+ title 'SMART Backend Services Authorization'
12
+ short_description 'Demonstrate SMART Backend Services Authorization'
13
+
14
+ id :backend_services_authorization
15
+
16
+ input :smart_token_url,
17
+ title: 'Backend Services Token Endpoint',
18
+ description: <<~DESCRIPTION
19
+ The OAuth 2.0 Token Endpoint used by the Backend Services specification to provide bearer tokens.
20
+ DESCRIPTION
21
+
22
+ input :backend_services_client_id,
23
+ title: 'Backend Services Client ID',
24
+ description: 'Client ID provided at registration to the Inferno application.'
25
+ input :backend_services_requested_scope,
26
+ title: 'Backend Services Requested Scopes',
27
+ description: 'Backend Services Scopes provided at registration to the Inferno application; will be `system/` scopes',
28
+ default: 'system/*.read'
29
+
30
+ input :client_auth_encryption_method,
31
+ title: 'Encryption Method for Asymmetric Confidential Client Authorization',
32
+ description: <<~DESCRIPTION,
33
+ The server is required to suport either ES384 or RS384 encryption methods for JWT signature verification.
34
+ Select which method to use.
35
+ DESCRIPTION
36
+ type: 'radio',
37
+ default: 'ES384',
38
+ options: {
39
+ list_options: [
40
+ {
41
+ label: 'ES384',
42
+ value: 'ES384'
43
+ },
44
+ {
45
+ label: 'RS384',
46
+ value: 'RS384'
47
+ }
48
+ ]
49
+ }
50
+ input :backend_services_jwks_kid,
51
+ title: 'Backend Services JWKS kid',
52
+ description: <<~DESCRIPTION,
53
+ The key ID of the JWKS private key to use for signing the client assertion when fetching an auth token.
54
+ Defaults to the first JWK in the list if no kid is supplied.
55
+ DESCRIPTION
56
+ optional: true
57
+
58
+ output :bearer_token
59
+
60
+ test from: :tls_version_test do
61
+ title 'Authorization service token endpoint secured by transport layer security'
62
+ description <<~DESCRIPTION
63
+ The [SMART App Launch 2.0.0 IG specification for Backend Services](https://hl7.org/fhir/smart-app-launch/STU2/backend-services.html#request-1)
64
+ states "the client SHALL use the Transport Layer Security (TLS) Protocol Version 1.2 (RFC5246)
65
+ or a more recent version of TLS to authenticate the identity of the FHIR authorization server and to
66
+ establish an encrypted, integrity-protected link for securing all exchanges between the client and the
67
+ FHIR authorization server’s token endpoint. All exchanges described herein between the client and the
68
+ FHIR server SHALL be secured using TLS V1.2 or a more recent version of TLS."
69
+ DESCRIPTION
70
+ id :smart_backend_services_token_tls_version
71
+
72
+ config(
73
+ inputs: { url: { name: :smart_token_url } },
74
+ options: { minimum_allowed_version: OpenSSL::SSL::TLS1_2_VERSION }
75
+ )
76
+ end
77
+
78
+ test from: :smart_backend_services_invalid_grant_type
79
+
80
+ test from: :smart_backend_services_invalid_client_assertion
81
+
82
+ test from: :smart_backend_services_invalid_jwt
83
+
84
+ test from: :smart_backend_services_auth_request_success
85
+
86
+ test from: :smart_backend_services_auth_response_body
87
+ end
88
+ end
@@ -0,0 +1,74 @@
1
+ require 'json/jwt'
2
+ require_relative 'client_assertion_builder'
3
+
4
+ module SMARTAppLaunch
5
+ class BackendServicesAuthorizationRequestBuilder
6
+ def self.build(...)
7
+ new(...).authorization_request
8
+ end
9
+
10
+ attr_reader :encryption_method, :scope, :iss, :sub, :aud, :content_type, :grant_type, :client_assertion_type, :exp,
11
+ :jti, :kid
12
+
13
+ def initialize(
14
+ encryption_method:,
15
+ scope:,
16
+ iss:,
17
+ sub:,
18
+ aud:,
19
+ content_type: 'application/x-www-form-urlencoded',
20
+ grant_type: 'client_credentials',
21
+ client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
22
+ exp: 5.minutes.from_now,
23
+ jti: SecureRandom.hex(32),
24
+ kid: nil
25
+ )
26
+ @encryption_method = encryption_method
27
+ @scope = scope
28
+ @iss = iss
29
+ @sub = sub
30
+ @aud = aud
31
+ @content_type = content_type
32
+ @grant_type = grant_type
33
+ @client_assertion_type = client_assertion_type
34
+ @exp = exp
35
+ @jti = jti
36
+ @kid = kid
37
+ end
38
+
39
+ def authorization_request_headers
40
+ {
41
+ content_type:,
42
+ accept: 'application/json'
43
+ }.compact
44
+ end
45
+
46
+ def authorization_request_query_values
47
+ {
48
+ 'scope' => scope,
49
+ 'grant_type' => grant_type,
50
+ 'client_assertion_type' => client_assertion_type,
51
+ 'client_assertion' => client_assertion.to_s
52
+ }.compact
53
+ end
54
+
55
+ def client_assertion
56
+ @client_assertion ||= ClientAssertionBuilder.build(
57
+ client_auth_encryption_method: encryption_method,
58
+ iss: iss,
59
+ sub: sub,
60
+ aud: aud,
61
+ exp: exp.to_i,
62
+ jti: jti,
63
+ kid: kid
64
+ )
65
+ end
66
+
67
+ def authorization_request
68
+ uri = Addressable::URI.new
69
+ uri.query_values = authorization_request_query_values
70
+
71
+ { body: uri.query, headers: authorization_request_headers }
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,40 @@
1
+ require_relative 'backend_services_authorization_request_builder'
2
+ require_relative 'backend_services_authorization_group'
3
+
4
+ module SMARTAppLaunch
5
+ class BackendServicesAuthorizationRequestSuccessTest < Inferno::Test
6
+ id :smart_backend_services_auth_request_success
7
+ title 'Authorization request succeeds when supplied correct information'
8
+ description <<~DESCRIPTION
9
+ The [SMART App Launch 2.0.0 IG specification for Backend Services](https://hl7.org/fhir/smart-app-launch/STU2/backend-services.html#issue-access-token)
10
+ states "If the access token request is valid and authorized, the authorization server SHALL issue an access token in response."
11
+ DESCRIPTION
12
+
13
+ input :client_auth_encryption_method,
14
+ :backend_services_requested_scope,
15
+ :backend_services_client_id,
16
+ :smart_token_url,
17
+ :backend_services_jwks_kid
18
+
19
+ output :authentication_response
20
+
21
+ http_client :token_endpoint do
22
+ url :smart_token_url
23
+ end
24
+
25
+ run do
26
+ post_request_content = BackendServicesAuthorizationRequestBuilder.build(encryption_method: client_auth_encryption_method,
27
+ scope: backend_services_requested_scope,
28
+ iss: backend_services_client_id,
29
+ sub: backend_services_client_id,
30
+ aud: smart_token_url,
31
+ kid: backend_services_jwks_kid)
32
+
33
+ authentication_response = post(**{ client: :token_endpoint }.merge(post_request_content))
34
+
35
+ assert_response_status([200, 201])
36
+
37
+ output authentication_response: authentication_response.response_body
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,40 @@
1
+ require_relative 'backend_services_authorization_request_builder'
2
+
3
+ module SMARTAppLaunch
4
+ class BackendServicesAuthorizationResponseBodyTest < Inferno::Test
5
+ id :smart_backend_services_auth_response_body
6
+ title 'Authorization request response body contains required information encoded in JSON'
7
+ description <<~DESCRIPTION
8
+ The [SMART App Launch 2.0.0 IG specification for Backend Services](https://hl7.org/fhir/smart-app-launch/STU2/backend-services.html#issue-access-token)
9
+ states The access token response SHALL be a JSON object with the following properties:
10
+
11
+ | Token Property | Required? | Description |
12
+ | --- | --- | --- |
13
+ | `access_token` | required | The access token issued by the authorization server. |
14
+ | `token_type` | required | Fixed value: `bearer`. |
15
+ | `expires_in` | required | The lifetime in seconds of the access token. The recommended value is `300`, for a five-minute token lifetime. |
16
+ | `scope` | required | Scope of access authorized. Note that this can be different from the scopes requested by the app. |
17
+ DESCRIPTION
18
+
19
+ input :authentication_response
20
+ output :bearer_token
21
+
22
+ run do
23
+ skip_if authentication_response.blank?, 'No authentication response received.'
24
+
25
+ assert_valid_json(authentication_response)
26
+ response_body = JSON.parse(authentication_response)
27
+
28
+ access_token = response_body['access_token']
29
+ assert access_token.present?, 'Token response did not contain access_token as required'
30
+
31
+ output bearer_token: access_token
32
+
33
+ required_keys = ['token_type', 'expires_in', 'scope']
34
+
35
+ required_keys.each do |key|
36
+ assert response_body[key].present?, "Token response did not contain #{key} as required"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,44 @@
1
+ require_relative 'backend_services_authorization_request_builder'
2
+
3
+ module SMARTAppLaunch
4
+ class BackendServicesInvalidClientAssertionTest < Inferno::Test
5
+ id :smart_backend_services_invalid_client_assertion
6
+ title 'Authorization request fails when supplied invalid client_assertion_type'
7
+ description <<~DESCRIPTION
8
+ The [SMART App Launch 2.0.0 IG specification for Backend Services](https://hl7.org/fhir/smart-app-launch/STU2/backend-services.html#request-1)
9
+ defines the required fields for the authorization request, made via HTTP POST to authorization
10
+ token endpoint.
11
+ This includes the `client_assertion_type` parameter, where the value must be `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`.
12
+
13
+ The [OAuth 2.0 Authorization Framework Section 4.3.3](https://datatracker.ietf.org/doc/html/rfc6749#section-4.3.3)
14
+ describes the proper response for an invalid request in the client credentials grant flow:
15
+
16
+ "If the request failed client authentication or is invalid, the authorization server returns an
17
+ error response as described in [Section 5.2](https://tools.ietf.org/html/rfc6749#section-5.2)."
18
+ DESCRIPTION
19
+
20
+ input :client_auth_encryption_method,
21
+ :backend_services_requested_scope,
22
+ :backend_services_client_id,
23
+ :smart_token_url,
24
+ :backend_services_jwks_kid
25
+
26
+ http_client :token_endpoint do
27
+ url :smart_token_url
28
+ end
29
+
30
+ run do
31
+ post_request_content = BackendServicesAuthorizationRequestBuilder.build(encryption_method: client_auth_encryption_method,
32
+ scope: backend_services_requested_scope,
33
+ iss: backend_services_client_id,
34
+ sub: backend_services_client_id,
35
+ aud: smart_token_url,
36
+ client_assertion_type: 'not_an_assertion_type',
37
+ kid: backend_services_jwks_kid)
38
+
39
+ post(**{ client: :token_endpoint }.merge(post_request_content))
40
+
41
+ assert_response_status(400)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,44 @@
1
+ require_relative 'backend_services_authorization_request_builder'
2
+
3
+ module SMARTAppLaunch
4
+ class BackendServicesInvalidGrantTypeTest < Inferno::Test
5
+ id :smart_backend_services_invalid_grant_type
6
+ title 'Authorization request fails when client supplies invalid grant_type'
7
+ description <<~DESCRIPTION
8
+ The [SMART App Launch 2.0.0 IG section on Backend Services](https://hl7.org/fhir/smart-app-launch/STU2/backend-services.html#request-1)
9
+ defines the required fields for the authorization request, made via HTTP POST to authorization
10
+ token endpoint.
11
+ This includes the `grant_type` parameter, where the value must be `client_credentials`.
12
+
13
+ The [OAuth 2.0 Authorization Framework Section 4.3.3](https://datatracker.ietf.org/doc/html/rfc6749#section-4.3.3)
14
+ describes the proper response for an invalid request in the client credentials grant flow:
15
+
16
+ "If the request failed client authentication or is invalid, the authorization server returns an
17
+ error response as described in [Section 5.2](https://tools.ietf.org/html/rfc6749#section-5.2)."
18
+ DESCRIPTION
19
+
20
+ input :client_auth_encryption_method,
21
+ :backend_services_requested_scope,
22
+ :backend_services_client_id,
23
+ :smart_token_url,
24
+ :backend_services_jwks_kid
25
+
26
+ http_client :token_endpoint do
27
+ url :smart_token_url
28
+ end
29
+
30
+ run do
31
+ post_request_content = BackendServicesAuthorizationRequestBuilder.build(encryption_method: client_auth_encryption_method,
32
+ scope: backend_services_requested_scope,
33
+ iss: backend_services_client_id,
34
+ sub: backend_services_client_id,
35
+ aud: smart_token_url,
36
+ grant_type: 'not_a_grant_type',
37
+ kid: backend_services_jwks_kid)
38
+
39
+ post(**{ client: :token_endpoint }.merge(post_request_content))
40
+
41
+ assert_response_status(400)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,55 @@
1
+ require_relative 'backend_services_authorization_request_builder'
2
+
3
+ module SMARTAppLaunch
4
+ class BackendServicesInvalidJWTTest < Inferno::Test
5
+ id :smart_backend_services_invalid_jwt
6
+ title 'Authorization request fails when client supplies invalid JWT token'
7
+ description <<~DESCRIPTION
8
+ The [SMART App Launch 2.0.0 IG section on Backend Services](https://hl7.org/fhir/smart-app-launch/STU2/backend-services.html#request-1)
9
+ defines the required fields for the authorization request, made via HTTP POST to authorization
10
+ token endpoint.
11
+ This includes the `client_assertion` parameter, where the value must be
12
+ a valid JWT as specified in
13
+ [Asymmetric (public key) Client Authentication](https://hl7.org/fhir/smart-app-launch/STU2/client-confidential-asymmetric.html#authenticating-to-the-token-endpoint)
14
+ The JWT SHALL include the following claims, and SHALL be signed with the client’s private key.
15
+
16
+ | JWT Claim | Required? | Description |
17
+ | --- | --- | --- |
18
+ | `iss` | required | Issuer of the JWT -- the client's `client_id`, as determined during registration with the FHIR authorization server (note that this is the same as the value for the sub claim) |
19
+ | `sub` | required | The service's `client_id`, as determined during registration with the FHIR authorization server (note that this is the same as the value for the `iss` claim) |
20
+ | `aud` | required | The FHIR authorization server's "token URL" (the same URL to which this authentication JWT will be posted) |
21
+ | `exp` | required | Expiration time integer for this authentication JWT, expressed in seconds since the "Epoch" (1970-01-01T00:00:00Z UTC). This time SHALL be no more than five minutes in the future. |
22
+ | `jti` | required | A nonce string value that uniquely identifies this authentication JWT. |
23
+
24
+ The [OAuth 2.0 Authorization Framework Section 4.3.3](https://datatracker.ietf.org/doc/html/rfc6749#section-4.3.3)
25
+ describes the proper response for an invalid request in the client credentials grant flow:
26
+
27
+ "If the request failed client authentication or is invalid, the authorization server returns an
28
+ error response as described in [Section 5.2](https://tools.ietf.org/html/rfc6749#section-5.2)."
29
+ DESCRIPTION
30
+
31
+ input :client_auth_encryption_method,
32
+ :backend_services_requested_scope,
33
+ :backend_services_client_id,
34
+ :smart_token_url,
35
+ :backend_services_jwks_kid
36
+
37
+ http_client :token_endpoint do
38
+ url :smart_token_url
39
+ end
40
+
41
+ run do
42
+ post_request_content = BackendServicesAuthorizationRequestBuilder.build(encryption_method: client_auth_encryption_method,
43
+ scope: backend_services_requested_scope,
44
+ iss: backend_services_client_id,
45
+ sub: backend_services_client_id,
46
+ aud: smart_token_url,
47
+ client_assertion_type: 'not_an_assertion_type',
48
+ kid: backend_services_jwks_kid)
49
+
50
+ post(**{ client: :token_endpoint }.merge(post_request_content))
51
+
52
+ assert_response_status(400)
53
+ end
54
+ end
55
+ end
@@ -16,7 +16,8 @@ module SMARTAppLaunch
16
16
  :grant_type,
17
17
  :iss,
18
18
  :jti,
19
- :sub
19
+ :sub,
20
+ :kid
20
21
 
21
22
  def initialize(
22
23
  client_auth_encryption_method:,
@@ -24,7 +25,8 @@ module SMARTAppLaunch
24
25
  sub:,
25
26
  aud:,
26
27
  exp: 5.minutes.from_now.to_i,
27
- jti: SecureRandom.hex(32)
28
+ jti: SecureRandom.hex(32),
29
+ kid: nil
28
30
  )
29
31
  @client_auth_encryption_method = client_auth_encryption_method
30
32
  @iss = iss
@@ -35,29 +37,35 @@ module SMARTAppLaunch
35
37
  @client_assertion_type = client_assertion_type
36
38
  @exp = exp
37
39
  @jti = jti
40
+ @kid = kid
38
41
  end
39
42
 
40
43
  def private_key
41
- @private_key ||=
42
- JWKS.jwks
43
- .find { |key| key[:key_ops]&.include?('sign') && key[:alg] == client_auth_encryption_method }
44
+ @private_key ||= JWKS.jwks
45
+ .select { |key| key[:key_ops]&.include?('sign') }
46
+ .select { |key| key[:alg] == client_auth_encryption_method }
47
+ .find { |key| !kid || key[:kid] == kid }
44
48
  end
45
49
 
46
50
  def jwt_payload
47
51
  { iss:, sub:, aud:, exp:, jti: }.compact
48
52
  end
49
53
 
50
- def kid
51
- private_key.kid
54
+ def signing_key
55
+ private_key()
56
+ if @private_key.nil?
57
+ raise Inferno::Exceptions::AssertionException, "No signing key found for inputs: encryption method = '#{client_auth_encryption_method}' and kid = '#{kid}'"
58
+ end
59
+ return @private_key.signing_key
52
60
  end
53
61
 
54
- def signing_key
55
- private_key.signing_key
62
+ def key_id
63
+ @private_key['kid']
56
64
  end
57
65
 
58
66
  def client_assertion
59
67
  @client_assertion ||=
60
- JWT.encode jwt_payload, signing_key, client_auth_encryption_method, { alg: client_auth_encryption_method, kid:, typ: 'JWT' }
68
+ JWT.encode jwt_payload, signing_key, client_auth_encryption_method, { alg: client_auth_encryption_method, kid: key_id, typ: 'JWT' }
61
69
  end
62
70
  end
63
71
  end
@@ -6,7 +6,9 @@ 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'
11
+ require_relative 'backend_services_authorization_group'
10
12
 
11
13
  module SMARTAppLaunch
12
14
  class SMARTSTU2Suite < Inferno::TestSuite
@@ -55,6 +57,20 @@ module SMARTAppLaunch
55
57
  * `#{Inferno::Application[:base_url]}/custom/smart_stu2/.well-known/jwks.json`
56
58
  DESCRIPTION
57
59
 
60
+ input_instructions %(
61
+ When running tests at this level, the token introspection endpoint is not available as a manual input.
62
+ Instead, group 3 Token Introspection will assume the token introspection endpoint
63
+ will be output from group 1 Standalone Launch tests, specifically the SMART On FHIR Discovery tests that query
64
+ the .well-known/smart-configuration endpoint. However, including the token introspection
65
+ endpoint as part of the well-known ouput is NOT required and is not formally checked in the SMART On FHIR Discovery
66
+ tests. RFC-7662 on Token Introspection says that "The means by which the protected resource discovers the location of the introspection
67
+ endpoint are outside the scope of this specification" and the Token Introspection IG does not add any further
68
+ requirements to this.
69
+
70
+ If the token introspection endpoint of the system under test is NOT available at .well-known/smart-configuration,
71
+ please run the test groups individually and group 3 Token Introspection will include the introspection endpoint as a manual input.
72
+ )
73
+
58
74
  group do
59
75
  title 'Standalone Launch'
60
76
  id :smart_full_standalone_launch
@@ -204,5 +220,25 @@ module SMARTAppLaunch
204
220
  }
205
221
  }
206
222
  end
223
+
224
+ group do
225
+ title 'Backend Services'
226
+ id :smart_backend_services
227
+
228
+ input_instructions <<~INSTRUCTIONS
229
+ Please register the Inferno client with the authorization services with the
230
+ following JWK Set URL:
231
+
232
+ * `#{Inferno::Application[:base_url]}/custom/smart_stu2/.well-known/jwks.json`
233
+ INSTRUCTIONS
234
+
235
+ run_as_group
236
+
237
+ group from: :smart_discovery_stu2
238
+ group from: :backend_services_authorization
239
+ end
240
+
241
+ group from: :smart_token_introspection
242
+
207
243
  end
208
244
  end
@@ -14,25 +14,26 @@ module SMARTAppLaunch
14
14
  id :smart_token_exchange_stu2
15
15
 
16
16
  input :client_auth_encryption_method,
17
- title: 'Encryption Method (Confidential Asymmetric Client Auth Only)',
18
- type: 'radio',
19
- default: 'ES384',
20
- options: {
21
- list_options: [
22
- {
23
- label: 'ES384',
24
- value: 'ES384'
25
- },
26
- {
27
- label: 'RS384',
28
- value: 'RS384'
29
- }
30
- ]
17
+ title: 'Encryption Method (Confidential Asymmetric Client Auth Only)',
18
+ type: 'radio',
19
+ default: 'ES384',
20
+ options: {
21
+ list_options: [
22
+ {
23
+ label: 'ES384',
24
+ value: 'ES384'
25
+ },
26
+ {
27
+ label: 'RS384',
28
+ value: 'RS384'
31
29
  }
30
+ ]
31
+ }
32
32
 
33
33
  input :client_auth_type,
34
34
  title: 'Client Authentication Method',
35
35
  type: 'radio',
36
+ default: 'public',
36
37
  options: {
37
38
  list_options: [
38
39
  {
@@ -71,6 +71,8 @@ module SMARTAppLaunch
71
71
  output token_retrieval_time: Time.now.iso8601
72
72
 
73
73
  token_response_body = JSON.parse(request.response_body)
74
+
75
+
74
76
  output smart_credentials: {
75
77
  refresh_token: token_response_body['refresh_token'],
76
78
  access_token: token_response_body['access_token'],
@@ -80,6 +82,7 @@ module SMARTAppLaunch
80
82
  token_retrieval_time: token_retrieval_time,
81
83
  token_url: smart_token_url
82
84
  }.to_json
85
+
83
86
  end
84
87
  end
85
88
  end
@@ -0,0 +1,26 @@
1
+ require_relative 'standalone_launch_group_stu2'
2
+
3
+ module SMARTAppLaunch
4
+ class SMARTTokenIntrospectionAccessTokenGroup < Inferno::TestGroup
5
+ title 'Request New Access Token to Introspect'
6
+ run_as_group
7
+
8
+ id :smart_token_introspection_access_token_group
9
+
10
+ description %(
11
+ These tests are repeated from the Standalone Launch tests in order to receive a new, active access token that
12
+ will be provided for token introspection. This test group may be skipped if the tester can obtain an access token
13
+ __and__ the contents of the access token response body by some other means.
14
+
15
+ These tests are currently designed such that the token introspection URL must be present in the SMART well-known endpoint.
16
+
17
+ )
18
+
19
+ input_instructions %(
20
+ Register Inferno as a Standalone SMART App and provide the registration details below.
21
+ )
22
+
23
+ group from: :smart_discovery_stu2
24
+ group from: :smart_standalone_launch_stu2
25
+ end
26
+ end
@@ -0,0 +1,69 @@
1
+ require_relative 'token_introspection_access_token_group'
2
+ require_relative 'token_introspection_response_group'
3
+ require_relative 'token_introspection_request_group'
4
+
5
+ module SMARTAppLaunch
6
+ class SMARTTokenIntrospectionGroup < Inferno::TestGroup
7
+ title 'Token Introspection'
8
+ id :smart_token_introspection
9
+ description %(
10
+ # Background
11
+
12
+ OAuth 2.0 Token introspection, as described in [RFC-7662](https://datatracker.ietf.org/doc/html/rfc7662), allows
13
+ an authorized resource server to query an OAuth 2.0 authorization server for metadata on a token. The
14
+ [SMART App Launch STU2 Implementation Guide Section on Token Introspection](https://hl7.org/fhir/smart-app-launch/STU2/token-introspection.html)
15
+ states that "SMART on FHIR EHRs SHOULD support token introspection, which allows a broader ecosystem of resource servers
16
+ to leverage authorization decisions managed by a single authorization server."
17
+
18
+ # Test Methodology
19
+
20
+ In these tests, Inferno acts as an authorized resource server that queries the authorization server about an access
21
+ token, rather than a client to a FHIR resource server as in the previous SMART App Launch tests.
22
+ Ideally, Inferno should be registered with the authorization server as an authorized resource server
23
+ capable of accessing the token introspection endpoint through client credentials, per the SMART IG recommendations.
24
+ However, the SMART IG only formally REQUIRES "some form of authorization" to access
25
+ the token introspection endpoint and does not specifiy any one specific approach. As such, the token introspection tests are
26
+ broken up into three groups that each complete a discrete step in the token introspection process:
27
+
28
+ 1. **Request Access Token Group** - optional but recommended, repeats a subset of Standalone Launch tests
29
+ in order to receive a new access token with an authorization code grant. If skipped, testers will need to
30
+ obtain an access token out-of-band and manually provide values from the access token response as inputs to
31
+ the Validate Token Response group.
32
+ 2. **Issue Token Introspection Request Group** - optional but recommended, completes the introspection requests.
33
+ If skipped, testers will need to complete an introspection request out-of-band and manually provide the introspection
34
+ responses as inputs to the Validate Token Response group.
35
+ 3. **Validate Token Introspection Response Group** - required, validates the contents of the introspection responses.
36
+
37
+ Running all three test groups in order is the simplest and is highly recommended if the environment under test
38
+ can support it, as outputs from one group will feed the inputs of the next group. However, test groups can be run
39
+ independently if needed.
40
+
41
+ See the individual test groups for more details and guidance.
42
+ )
43
+ group from: :smart_token_introspection_access_token_group
44
+ group from: :smart_token_introspection_request_group
45
+ group from: :smart_token_introspection_response_group
46
+
47
+ input_order :url, :standalone_client_id, :standalone_client_secret,
48
+ :authorization_method, :use_pkce, :pkce_code_challenge_method,
49
+ :standalone_requested_scopes, :client_auth_encryption_method,
50
+ :client_auth_type, :custom_authorization_header,
51
+ :optional_introspection_request_params
52
+ input_instructions %(
53
+ Executing tests at this level will run all three Token Introspection groups back-to-back. If test groups need
54
+ to be run independently, exit this window and select a specific test group instead.
55
+
56
+ These tests are currently designed such that the token introspection URL must be present in the SMART well-known endpoint.
57
+
58
+ If the introspection endpoint is protected, testers must enter their own HTTP Authorization header for the introspection request. See
59
+ [RFC 7616 The 'Basic' HTTP Authentication Scheme](https://datatracker.ietf.org/doc/html/rfc7617) for the most common
60
+ approach that uses client credentials. Testers may also provide any additional parameters needed for their authorization
61
+ server to complete the introspection request.
62
+
63
+ **Note:** For both the Authorization header and request parameters, user-input
64
+ values will be sent exactly as entered and therefore the tester must
65
+ URI-encode any appropriate values.
66
+ )
67
+
68
+ end
69
+ end
@@ -0,0 +1,122 @@
1
+ require_relative 'token_exchange_test'
2
+ require_relative 'token_refresh_body_test'
3
+ require_relative 'well_known_endpoint_test'
4
+ require_relative 'standalone_launch_group'
5
+
6
+ module SMARTAppLaunch
7
+ class SMARTTokenIntrospectionRequestGroup < Inferno::TestGroup
8
+ title 'Issue Token Introspection Request'
9
+ run_as_group
10
+
11
+ id :smart_token_introspection_request_group
12
+ description %(
13
+ This group of tests executes the token introspection requests and ensures the correct HTTP response is returned
14
+ but does not validate the contents of the token introspection response.
15
+
16
+ If Inferno cannot reasonably be configured to be authorized to access the token introspection endpoint, these tests
17
+ can be skipped. Instead, an out-of-band token introspection request must be completed and the response body
18
+ manually provided as input for the Validate Introspection Response test group.
19
+ )
20
+
21
+ input_instructions %(
22
+ If the Request New Access Token group was executed, the access token input will auto-populate with that token.
23
+ Otherwise an active access token needs to be obtained out-of-band and input.
24
+
25
+ Per [RFC-7662](https://datatracker.ietf.org/doc/html/rfc7662#section-2), "the definition of an active token is
26
+ currently dependent upon the authorization server, but this is commonly a token that has been issued by this
27
+ authorization server, is not expired, has not been revoked, and is valid for use at the protected resource making
28
+ the introspection call."
29
+
30
+ If the introspection endpoint is protected, testers must enter their own HTTP Authorization header for the introspection request. See
31
+ [RFC 7616 The 'Basic' HTTP Authentication Scheme](https://datatracker.ietf.org/doc/html/rfc7617) for the most common
32
+ approach that uses client credentials. Testers may also provide any additional parameters needed for their authorization
33
+ server to complete the introspection request.
34
+
35
+ **Note:** For both the Authorization header and request parameters, user-input
36
+ values will be sent exactly as entered and therefore the tester must URI-encode any appropriate values.
37
+ )
38
+
39
+ input :well_known_introspection_url,
40
+ title: 'Token Introspection Endpoint URL',
41
+ description: 'The complete URL of the token introspection endpoint.'
42
+
43
+ input :custom_authorization_header,
44
+ title: 'HTTP Authorization Header for Introspection Request',
45
+ type: 'textarea',
46
+ optional: true,
47
+ description: %(
48
+ Include header name, auth scheme, and auth parameters.
49
+ Ex: 'Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW'
50
+ )
51
+
52
+ input :optional_introspection_request_params,
53
+ title: 'Additional Introspection Request Parameters',
54
+ type: 'textarea',
55
+ optional: true,
56
+ description: %(
57
+ Any additional parameters to append to the request body, separated by &. Example: 'param1=abc&param2=def'
58
+ )
59
+
60
+ test do
61
+ title 'Token introspection endpoint returns a response when provided an active token'
62
+ description %(
63
+ This test will execute a token introspection request for an active token and ensure a 200 status and valid JSON
64
+ body are returned in the response.
65
+ )
66
+
67
+ input :standalone_access_token,
68
+ title: 'Access Token',
69
+ description: 'The access token to be introspected. MUST be active.'
70
+
71
+
72
+ output :active_token_introspection_response_body
73
+
74
+ run do
75
+
76
+ # If this is being chained from an earlier test, it might be blank if not present in the well-known endpoint
77
+ skip_if well_known_introspection_url.nil?, 'No introspection URL present in SMART well-known endpoint.'
78
+
79
+ headers = {'Accept' => 'application/json', 'Content-Type' => 'application/x-www-form-urlencoded'}
80
+ body = "token=#{standalone_access_token}"
81
+
82
+ if custom_authorization_header.present?
83
+ parsed_header = custom_authorization_header.split(':', 2)
84
+ assert parsed_header.length == 2, "Incorrect custom HTTP header format input, expected: '<header name>: <header value>'"
85
+ headers[parsed_header[0]] = parsed_header[1].strip
86
+ end
87
+
88
+ if optional_introspection_request_params.present?
89
+ body += "&#{optional_introspection_request_params}"
90
+ end
91
+
92
+ post(well_known_introspection_url, body: body, headers: headers)
93
+
94
+ assert_response_status(200)
95
+ output active_token_introspection_response_body: request.response_body
96
+ end
97
+
98
+ end
99
+
100
+ test do
101
+ title 'Token introspection endpoint returns a response when provided an invalid token'
102
+ description %(
103
+ This test will execute a token introspection request for an invalid token and ensure a 200 status and valid JSON
104
+ body are returned in response.
105
+ )
106
+
107
+ output :invalid_token_introspection_response_body
108
+ run do
109
+
110
+ # If this is being chained from an earlier test, it might be blank if not present in the well-known endpoint
111
+ skip_if well_known_introspection_url.nil?, 'No introspection URL present in SMART well-known endpoint.'
112
+
113
+ headers = {'Accept' => 'application/json', 'Content-Type' => 'application/x-www-form-urlencoded'}
114
+ body = "token=invalid_token_value"
115
+ post(well_known_introspection_url, body: body, headers: headers)
116
+
117
+ assert_response_status(200)
118
+ output invalid_token_introspection_response_body: request.response_body
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,191 @@
1
+ require_relative 'token_introspection_request_group'
2
+ require_relative 'token_exchange_test'
3
+
4
+ module SMARTAppLaunch
5
+ class SMARTTokenIntrospectionResponseGroup < Inferno::TestGroup
6
+ title 'Validate Token Introspection Response'
7
+ run_as_group
8
+
9
+ id :smart_token_introspection_response_group
10
+ description %(
11
+ This group of tests validates the contents of the token introspection response by comparing the fields and/or
12
+ values in the token introspection response to the fields and/or values of the original access token response
13
+ in which the access token was given to the client.
14
+ )
15
+
16
+ input_instructions %(
17
+ There are two categories of input for this test group:
18
+
19
+ 1. The access token response values, which will dictate what the tests will expect to find in the token
20
+ introspection response. If the Request New Access Token group was run, these inputs will auto-populate.
21
+
22
+ 2. The token introspection response bodies. If the Issue Introspection Request test group was run, these will
23
+ auto-populate; otherwise, the tester will need to an run out-of-band INTROSPECTION requests for a. An ACTIVE
24
+ access token, AND b. An INACTIVE OR INVALID token
25
+
26
+ See [RFC-7662](https://datatracker.ietf.org/doc/html/rfc7662#section-2) for details on active vs inactive tokens.
27
+ )
28
+
29
+ test do
30
+ title 'Token introspection response for an active token contains required fields'
31
+
32
+ description %(
33
+ This test will check whether the metadata in the token introspection response is correct for an active token and
34
+ that the response data matches the data in the original access token and/or access token response from the
35
+ authorization server, including the following:
36
+
37
+ Required:
38
+ * `active` claim is set to true
39
+ * `scope`, `client_id`, and `exp` claim(s) match between introspection response and access token
40
+
41
+ It is not possible to know what the expected value for `exp` is in advance, so Inferno tests that the claim is
42
+ present and represents a time greater than or equal to 10 minutes in the past.
43
+
44
+ Conditionally Required:
45
+ * IF launch context parameter(s) included in access token, introspection response includes claim(s) for
46
+ launch context parameter(s)
47
+ * Parameters checked for are `patient` and `encounter`
48
+ * IF identity token was included as part of access token response, `iss` and `sub` claims are present in the
49
+ introspection response and match those of the orignal ID token
50
+
51
+ Optional but Recommended:
52
+ * IF identity token was included as part of access token response, `fhirUser` claim SHOULD be present in
53
+ introspection response and should match the claim in the ID token
54
+ )
55
+
56
+ input :standalone_client_id,
57
+ title: 'Access Token client_id',
58
+ description: 'ID of the client that requested the access token being introspected'
59
+
60
+
61
+ input :standalone_received_scopes,
62
+ title: 'Expected Introspection Response Value: scope',
63
+ description: 'A space-separated list of scopes from the original access token response body'
64
+
65
+ input :standalone_id_token,
66
+ title: 'Access Token Response: id_token',
67
+ type: 'textarea',
68
+ optional: true,
69
+ description: 'The ID token from the original access token response body, IF it was present'
70
+
71
+ input :standalone_patient_id,
72
+ title: 'Expected Introspection Response for Patient Launch Context Parameter',
73
+ optional: true,
74
+ description: 'The value for patient launch context from the original access token response body, IF it was present'
75
+
76
+ input :standalone_encounter_id,
77
+ title: 'Expected Introspection Response for Encounter Launch Context Parameter',
78
+ optional: true,
79
+ description: 'The value for encounter launch context from the original access token response body, IF it was present'
80
+
81
+ input :active_token_introspection_response_body,
82
+ title: 'Active Token Introspection Response Body',
83
+ type: 'textarea',
84
+ description: 'The JSON body of the token introspection response when provided an ACTIVE token'
85
+
86
+ def get_json_claim_value(json_response, claim_key)
87
+ claim_value = json_response[claim_key]
88
+ assert claim_value != nil, "Failure: introspection response has no claim for '#{claim_key}'"
89
+ return claim_value
90
+ end
91
+
92
+ def assert_introspection_response_match(json_response, claim_key, expected_value)
93
+ expected_value = expected_value.strip
94
+ claim_value = get_json_claim_value(json_response, claim_key)
95
+ claim_value = claim_value.strip
96
+ assert claim_value.eql?(expected_value),
97
+ "Failure: expected introspection response value for '#{claim_key}' to match expected value '#{expected_value}'"
98
+ end
99
+
100
+ run do
101
+ skip_if active_token_introspection_response_body.nil?, 'No introspection response available to validate.'
102
+ assert_valid_json(active_token_introspection_response_body)
103
+ active_introspection_response_body_parsed = JSON.parse(active_token_introspection_response_body)
104
+
105
+ # Required Fields
106
+ assert active_introspection_response_body_parsed['active'] == true, "Failure: expected introspection response for 'active' to be Boolean value true for valid token"
107
+ assert_introspection_response_match(active_introspection_response_body_parsed, 'client_id', standalone_client_id)
108
+
109
+ response_scope_value = get_json_claim_value(active_introspection_response_body_parsed, 'scope')
110
+
111
+ # splitting contents and comparing values allows a scope lists with the same contents but different orders to still pass
112
+ response_scopes_split = response_scope_value.split()
113
+ expected_scopes_split = standalone_received_scopes.split()
114
+
115
+ assert response_scopes_split.length() == expected_scopes_split.length(),
116
+ "Failure: number of scopes in introspection response, #{response_scopes_split.length()}, does not match number of scopes in access token response, #{expected_scopes_split.length()}"
117
+
118
+ expected_scopes_split.each do |scope|
119
+ assert response_scopes_split.include?(scope), "Failure: expected scope '#{scope}' not present in introspection response scopes"
120
+ end
121
+
122
+ # Cannot verify exact value for exp, so instead ensure its value represents a time >= 10 minutes in the past
123
+ exp = active_introspection_response_body_parsed['exp']
124
+ assert exp != nil, "Failure: introspection response has no claim for 'exp'"
125
+ current_time = Time.now.to_i
126
+ assert exp.to_i >= current_time - 600, "Failure: expired token, exp claim of #{exp} for active token is more than 10 minutes in the past"
127
+
128
+ # Conditional fields
129
+ assert_introspection_response_match(active_introspection_response_body_parsed, 'patient', standalone_patient_id) if standalone_patient_id.present?
130
+ assert_introspection_response_match(active_introspection_response_body_parsed, 'encounter', standalone_encounter_id) if standalone_encounter_id.present?
131
+
132
+ # ID Token Fields
133
+ if standalone_id_token.present?
134
+ id_payload, id_header =
135
+ JWT.decode(
136
+ standalone_id_token,
137
+ nil,
138
+ false
139
+ )
140
+
141
+ # Required fields if ID token present
142
+ id_token_iss = id_payload['iss']
143
+ id_token_sub = id_payload['sub']
144
+
145
+ assert id_token_iss != nil, "Failure: ID token from access token response does not have 'iss' claim"
146
+ assert id_token_sub != nil, "Failure: ID token from access token response does not have 'sub' claim"
147
+ assert_introspection_response_match(active_introspection_response_body_parsed, 'iss', id_token_iss)
148
+ assert_introspection_response_match(active_introspection_response_body_parsed, 'sub', id_token_sub)
149
+
150
+ # fhirUser not required but recommended
151
+ fhirUser_id_claim = id_payload['fhirUser']
152
+ fhirUser_intr_claim = active_introspection_response_body_parsed['fhirUser']
153
+
154
+ info do
155
+ assert fhirUser_intr_claim != nil, "Introspection response SHOULD include claim for fhirUser because ID token present in access token response" if fhirUser_id_claim != nil
156
+ assert fhirUser_intr_claim.eql?(fhirUser_id_claim), "Introspection response claim for fhirUser SHOULD match value in ID token" if fhirUser_id_claim != nil
157
+ end
158
+ end
159
+ end
160
+ end
161
+
162
+ test do
163
+ title 'Token introspection response for an invalid token contains required fields'
164
+
165
+ description %(
166
+ From [RFC7662 OAuth2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2):
167
+ "If the introspection call is properly authorized but the token is not
168
+ active, does not exist on this server, or the protected resource is
169
+ not allowed to introspect this particular token, then the
170
+ authorization server MUST return an introspection response with the
171
+ "active" field set to "false". Note that to avoid disclosing too
172
+ much of the authorization server's state to a third party, the
173
+ authorization server SHOULD NOT include any additional information
174
+ about an inactive token, including why the token is inactive."
175
+ )
176
+
177
+ input :invalid_token_introspection_response_body,
178
+ title: 'Invalid Token Introspection Response Body',
179
+ type: 'textarea',
180
+ description: 'The JSON body of the token introspection response when provided an INVALID token'
181
+
182
+ run do
183
+ skip_if invalid_token_introspection_response_body.nil?, 'No invalid introspection response available to validate.'
184
+ assert_valid_json(invalid_token_introspection_response_body)
185
+ invalid_token_introspection_response_body_parsed = JSON.parse(invalid_token_introspection_response_body)
186
+ assert invalid_token_introspection_response_body_parsed['active'] == false, "Failure: expected introspection response for 'active' to be Boolean value false for invalid token"
187
+ assert invalid_token_introspection_response_body_parsed.size == 1, "Failure: expected only 'active' field to be present in introspection response for invalid token"
188
+ end
189
+ end
190
+ end
191
+ end
@@ -3,6 +3,69 @@ module SMARTAppLaunch
3
3
  STRING_FIELDS = ['access_token', 'token_type', 'scope', 'refresh_token'].freeze
4
4
  NUMERIC_FIELDS = ['expires_in'].freeze
5
5
 
6
+ # All resource types from DSTU3, STU3, R4, R4B, and R5
7
+ FHIR_RESOURCE_TYPES = [
8
+ "Account", "ActivityDefinition", "ActorDefinition",
9
+ "AdministrableProductDefinition", "AdverseEvent", "AllergyIntolerance",
10
+ "Appointment", "AppointmentResponse", "ArtifactAssessment", "AuditEvent",
11
+ "Basic", "Binary", "BiologicallyDerivedProduct",
12
+ "BiologicallyDerivedProductDispense", "BodySite", "BodyStructure",
13
+ "Bundle", "CapabilityStatement", "CarePlan", "CareTeam", "CatalogEntry",
14
+ "ChargeItem", "ChargeItemDefinition", "Citation", "Claim",
15
+ "ClaimResponse", "ClinicalImpression", "ClinicalUseDefinition",
16
+ "CodeSystem", "Communication", "CommunicationRequest",
17
+ "CompartmentDefinition", "Composition", "ConceptMap", "Condition",
18
+ "ConditionDefinition", "Conformance", "Consent", "Contract", "Coverage",
19
+ "CoverageEligibilityRequest", "CoverageEligibilityResponse",
20
+ "DataElement", "DetectedIssue", "Device", "DeviceAssociation",
21
+ "DeviceComponent", "DeviceDefinition", "DeviceDispense", "DeviceMetric",
22
+ "DeviceRequest", "DeviceUsage", "DeviceUseRequest", "DeviceUseStatement",
23
+ "DiagnosticOrder", "DiagnosticReport", "DocumentManifest",
24
+ "DocumentReference", "EffectEvidenceSynthesis", "EligibilityRequest",
25
+ "EligibilityResponse", "Encounter", "EncounterHistory", "Endpoint",
26
+ "EnrollmentRequest", "EnrollmentResponse", "EpisodeOfCare",
27
+ "EventDefinition", "Evidence", "EvidenceReport", "EvidenceVariable",
28
+ "ExampleScenario", "ExpansionProfile", "ExplanationOfBenefit",
29
+ "FamilyMemberHistory", "Flag", "FormularyItem", "GenomicStudy", "Goal",
30
+ "GraphDefinition", "Group", "GuidanceResponse", "HealthcareService",
31
+ "ImagingManifest", "ImagingObjectSelection", "ImagingSelection",
32
+ "ImagingStudy", "Immunization", "ImmunizationEvaluation",
33
+ "ImmunizationRecommendation", "ImplementationGuide", "Ingredient",
34
+ "InsurancePlan", "InventoryItem", "InventoryReport", "Invoice", "Library",
35
+ "Linkage", "List", "Location", "ManufacturedItemDefinition", "Measure",
36
+ "MeasureReport", "Media", "Medication", "MedicationAdministration",
37
+ "MedicationDispense", "MedicationKnowledge", "MedicationOrder",
38
+ "MedicationRequest", "MedicationStatement", "MedicinalProduct",
39
+ "MedicinalProductAuthorization", "MedicinalProductContraindication",
40
+ "MedicinalProductDefinition", "MedicinalProductIndication",
41
+ "MedicinalProductIngredient", "MedicinalProductInteraction",
42
+ "MedicinalProductManufactured", "MedicinalProductPackaged",
43
+ "MedicinalProductPharmaceutical", "MedicinalProductUndesirableEffect",
44
+ "MessageDefinition", "MessageHeader", "MolecularSequence", "NamingSystem",
45
+ "NutritionIntake", "NutritionOrder", "NutritionProduct", "Observation",
46
+ "ObservationDefinition", "OperationDefinition", "OperationOutcome",
47
+ "Order", "OrderResponse", "Organization", "OrganizationAffiliation",
48
+ "PackagedProductDefinition", "Patient", "PaymentNotice",
49
+ "PaymentReconciliation", "Permission", "Person", "PlanDefinition",
50
+ "Practitioner", "PractitionerRole", "Procedure", "ProcedureRequest",
51
+ "ProcessRequest", "ProcessResponse", "Provenance", "Questionnaire",
52
+ "QuestionnaireResponse", "ReferralRequest", "RegulatedAuthorization",
53
+ "RelatedPerson", "RequestGroup", "RequestOrchestration", "Requirements",
54
+ "ResearchDefinition", "ResearchElementDefinition", "ResearchStudy",
55
+ "ResearchSubject", "RiskAssessment", "RiskEvidenceSynthesis", "Schedule",
56
+ "SearchParameter", "Sequence", "ServiceDefinition", "ServiceRequest",
57
+ "Slot", "Specimen", "SpecimenDefinition", "StructureDefinition",
58
+ "StructureMap", "Subscription", "SubscriptionStatus", "SubscriptionTopic",
59
+ "Substance", "SubstanceDefinition", "SubstanceNucleicAcid",
60
+ "SubstancePolymer", "SubstanceProtein", "SubstanceReferenceInformation",
61
+ "SubstanceSourceMaterial", "SubstanceSpecification", "SupplyDelivery",
62
+ "SupplyRequest", "Task", "TerminologyCapabilities", "TestPlan",
63
+ "TestReport", "TestScript", "Transport", "ValueSet", "VerificationResult",
64
+ "VisionPrescription"
65
+ ].to_set.freeze
66
+
67
+ FHIR_ID_REGEX = /[A-Za-z0-9\-\.]{1,64}(\/_history\/[A-Za-z0-9\-\.]{1,64})?(#[A-Za-z0-9\-\.]{1,64})?/.freeze
68
+
6
69
  def validate_required_fields_present(body, required_fields)
7
70
  missing_fields = required_fields.select { |field| body[field].blank? }
8
71
  missing_fields_string = missing_fields.map { |field| "`#{field}`" }.join(', ')
@@ -49,5 +112,25 @@ module SMARTAppLaunch
49
112
  "Expected `#{field}` to be a Numeric, but found #{body[field].class.name}"
50
113
  end
51
114
  end
115
+
116
+ def validate_fhir_context(fhir_context)
117
+ return if fhir_context.nil?
118
+
119
+ assert fhir_context.is_a?(Array), "`fhirContext` field is a #{fhir_context.class.name}, but should be an Array"
120
+
121
+ fhir_context.each do |reference|
122
+ assert reference.is_a?(String), "`#{reference.inspect}` is not a string"
123
+ end
124
+
125
+ fhir_context.each do |reference|
126
+ assert !reference.start_with?('http'), "`#{reference}` is not a relative reference"
127
+
128
+ resource_type, id = reference.split('/')
129
+ assert FHIR_RESOURCE_TYPES.include?(resource_type),
130
+ "`#{resource_type}` is not a valid FHIR resource type"
131
+
132
+ assert id.match?(FHIR_ID_REGEX), "`#{id}` is not a valid FHIR id"
133
+ end
134
+ end
52
135
  end
53
136
  end
@@ -11,6 +11,8 @@ module SMARTAppLaunch
11
11
  has been denied. `access_token`, `token_type`, and `scope` are required.
12
12
  `token_type` must be Bearer. `expires_in` is required for token
13
13
  refreshes.
14
+
15
+ The format of the optional `fhirContext` field is validated if present.
14
16
  )
15
17
  id :smart_token_response_body
16
18
 
@@ -48,6 +50,8 @@ module SMARTAppLaunch
48
50
  assert access_token.present?, 'Token response did not contain an access token'
49
51
  assert token_response_body['token_type']&.casecmp('Bearer')&.zero?,
50
52
  '`token_type` field must have a value of `Bearer`'
53
+
54
+ validate_fhir_context(token_response_body['fhirContext'])
51
55
  end
52
56
  end
53
57
  end
@@ -1,3 +1,3 @@
1
1
  module SMARTAppLaunch
2
- VERSION = '0.3.0'.freeze
2
+ VERSION = '0.4.1'.freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smart_app_launch_test_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stephen MacVicar
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-08-08 00:00:00.000000000 Z
11
+ date: 2024-01-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: inferno_core
@@ -24,20 +24,34 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: 0.4.2
27
+ - !ruby/object:Gem::Dependency
28
+ name: json-jwt
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 1.15.3
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 1.15.3
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: jwt
29
43
  requirement: !ruby/object:Gem::Requirement
30
44
  requirements:
31
45
  - - "~>"
32
46
  - !ruby/object:Gem::Version
33
- version: '2.2'
47
+ version: '2.6'
34
48
  type: :runtime
35
49
  prerelease: false
36
50
  version_requirements: !ruby/object:Gem::Requirement
37
51
  requirements:
38
52
  - - "~>"
39
53
  - !ruby/object:Gem::Version
40
- version: '2.2'
54
+ version: '2.6'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: tls_test_kit
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -134,6 +148,13 @@ files:
134
148
  - lib/smart_app_launch/app_launch_test.rb
135
149
  - lib/smart_app_launch/app_redirect_test.rb
136
150
  - lib/smart_app_launch/app_redirect_test_stu2.rb
151
+ - lib/smart_app_launch/backend_services_authorization_group.rb
152
+ - lib/smart_app_launch/backend_services_authorization_request_builder.rb
153
+ - lib/smart_app_launch/backend_services_authorization_request_success_test.rb
154
+ - lib/smart_app_launch/backend_services_authorization_response_body_test.rb
155
+ - lib/smart_app_launch/backend_services_invalid_client_assertion_test.rb
156
+ - lib/smart_app_launch/backend_services_invalid_grant_type_test.rb
157
+ - lib/smart_app_launch/backend_services_invalid_jwt_test.rb
137
158
  - lib/smart_app_launch/client_assertion_builder.rb
138
159
  - lib/smart_app_launch/code_received_test.rb
139
160
  - lib/smart_app_launch/discovery_stu1_group.rb
@@ -158,6 +179,10 @@ files:
158
179
  - lib/smart_app_launch/standalone_launch_group_stu2.rb
159
180
  - lib/smart_app_launch/token_exchange_stu2_test.rb
160
181
  - lib/smart_app_launch/token_exchange_test.rb
182
+ - lib/smart_app_launch/token_introspection_access_token_group.rb
183
+ - lib/smart_app_launch/token_introspection_group.rb
184
+ - lib/smart_app_launch/token_introspection_request_group.rb
185
+ - lib/smart_app_launch/token_introspection_response_group.rb
161
186
  - lib/smart_app_launch/token_payload_validation.rb
162
187
  - lib/smart_app_launch/token_refresh_body_test.rb
163
188
  - lib/smart_app_launch/token_refresh_group.rb