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