udap_security_test_kit 0.11.1 → 0.11.3
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/config/presets/UDAP_RunClientAgainstServer.json.erb +20 -0
- data/config/presets/UDAP_RunServerAgainstClient.json.erb +272 -0
- data/lib/udap_security_test_kit/client_credentials_token_exchange_test.rb +1 -1
- data/lib/udap_security_test_kit/client_suite/client_access_group.rb +22 -0
- data/lib/udap_security_test_kit/client_suite/client_access_interaction_test.rb +53 -0
- data/lib/udap_security_test_kit/client_suite/client_registration_group.rb +26 -0
- data/lib/udap_security_test_kit/client_suite/client_registration_interaction_test.rb +50 -0
- data/lib/udap_security_test_kit/client_suite/client_registration_verification_test.rb +244 -0
- data/lib/udap_security_test_kit/client_suite/client_token_request_verification_test.rb +178 -0
- data/lib/udap_security_test_kit/client_suite/client_token_use_verification_test.rb +43 -0
- data/lib/udap_security_test_kit/client_suite.rb +78 -0
- data/lib/udap_security_test_kit/docs/demo/FHIR Request.postman_collection.json +81 -0
- data/lib/udap_security_test_kit/docs/udap_client_suite_description.md +120 -0
- data/lib/udap_security_test_kit/endpoints/echoing_fhir_responder.rb +52 -0
- data/lib/udap_security_test_kit/endpoints/mock_udap_server/registration.rb +57 -0
- data/lib/udap_security_test_kit/endpoints/mock_udap_server/token.rb +27 -0
- data/lib/udap_security_test_kit/endpoints/mock_udap_server.rb +301 -0
- data/lib/udap_security_test_kit/metadata.rb +5 -5
- data/lib/udap_security_test_kit/tags.rb +8 -0
- data/lib/udap_security_test_kit/urls.rb +45 -0
- data/lib/udap_security_test_kit/version.rb +2 -1
- data/lib/udap_security_test_kit.rb +8 -2
- metadata +20 -2
@@ -0,0 +1,120 @@
|
|
1
|
+
## Overview
|
2
|
+
|
3
|
+
The UDAP Security Client Test Suite verifies the conformance of
|
4
|
+
client systems to the STU 1.0.0 version of the HL7® FHIR®
|
5
|
+
[Security for Scalable Registration, Authentication, and Authorization (UDAP Security) FHIR IG](https://hl7.org/fhir/us/udap-security/STU1/).
|
6
|
+
|
7
|
+
## Scope
|
8
|
+
|
9
|
+
The UDAP Security Client Test Suite verifies that systems correctly implement
|
10
|
+
the [UDAP Security IG](https://hl7.org/fhir/us/udap-security/STU1/)
|
11
|
+
for authorizating and/or authenticating with a server in order to gain
|
12
|
+
access to HL7® FHIR® APIs. At this time, the suite only contains tests for
|
13
|
+
the [Business-to-Business Client Credentials flow](https://hl7.org/fhir/us/udap-security/STU1/b2b.html).
|
14
|
+
|
15
|
+
These tests are a **DRAFT** intended to allow implementers to perform
|
16
|
+
preliminary checks of their systems against UDAP Security IG
|
17
|
+
and [provide feedback](https://github.com/inferno-framework/udap-security-test-kit/issues)
|
18
|
+
on the tests. Future versions of these tests may verify other
|
19
|
+
requirements and may change the test verification logic.
|
20
|
+
|
21
|
+
## Test Methodology
|
22
|
+
|
23
|
+
For these tests Inferno simulates a UDAP server that supports the business-to-business
|
24
|
+
client credentials flow. Testers will
|
25
|
+
1. Provide to Inferno the client URI with which they will register their system.
|
26
|
+
2. Make a dynamic registration request to Inferno using the provided client URI
|
27
|
+
and including the X.509 certificate used to sign the registeration and subsequent
|
28
|
+
token requests which must also have the client URI as a Subject Alternative Name (SAN)
|
29
|
+
value in the certificate.
|
30
|
+
3. Obtain an access token with a request using the client Id returned during registration
|
31
|
+
and signed using same X.509 certificate supplied during registration.
|
32
|
+
4. Use that access token on a FHIR API request.
|
33
|
+
|
34
|
+
The simulated UDAP server is relatively permissive in the sense that it will often
|
35
|
+
provide successful responses even when the request is not conformant. When
|
36
|
+
requesting tokens, Inferno will return an access token as long as it can find
|
37
|
+
the client id and the signature is valid. This allows incomplete systems to
|
38
|
+
run the tests. However, these non-conformant requests will be flagged by
|
39
|
+
the tests as failures so that systems will not pass the tests without being
|
40
|
+
fully conformant.
|
41
|
+
|
42
|
+
## Running the Tests
|
43
|
+
|
44
|
+
### Quick Start
|
45
|
+
|
46
|
+
The following inputs must be provided by the tester at a minimum to execute
|
47
|
+
any tests in this suite:
|
48
|
+
1. **UDAP Client URI**: The UDAP Client URI that will be used to register with
|
49
|
+
Inferno's simulated UDAP server.
|
50
|
+
|
51
|
+
The *Additional Inputs* section below describes options available to customize
|
52
|
+
the behavior of Inferno's server simulation.
|
53
|
+
|
54
|
+
### Demonstration
|
55
|
+
|
56
|
+
To try out these tests without a UDAP client implementation, these tests can be exercised
|
57
|
+
using the UDAP Security server test suite and a simple HTTP request generator. The following
|
58
|
+
steps use [Postman](https://www.postman.com/) to generate the access request using
|
59
|
+
[this collection](https://github.com/inferno-framework/udap-security-test-kit/blob/main/lib/udap_security_test_kit/docs/demo/FHIR%20Request.postman_collection.json).
|
60
|
+
Install the app and import the collection before following these steps.
|
61
|
+
|
62
|
+
1. Start an instance of the UDAP Security Client test suite.
|
63
|
+
2. From the drop down in the upper left, select preset "Demo: Run Against the UDAP Security Server Suite".
|
64
|
+
3. Click the "RUN ALL TESTS" button in the upper right and click "SUBMIT"
|
65
|
+
4. In a new tab, start an instance of the UDAP Security Server Test Suite
|
66
|
+
5. From the drop down in the upper left, select preset "Demo: Run Against the UDAP Security Client Suite"
|
67
|
+
6. Select test group **2** UDAP Client Credentials Flow from the left panel, click the "RUN ALL TESTS" button
|
68
|
+
in the upper right, and click "SUBMIT"
|
69
|
+
7. In the Client suite tab, click the link in the wait dialog to continue the tests.
|
70
|
+
8. In the Server suite tab, find the access token to use for the data access request by opening
|
71
|
+
test **2.3.01** OAuth token exchange request succeeds when supplied correct information, click
|
72
|
+
on the "REQUESTS" tab, clicking on the "DETAILS" button, and expanding the "Response Body".
|
73
|
+
Copy the "access_token" value, which will be a ~100 character string of letters and numbers (e.g., eyJjbGllbnRfaWQiOiJzbWFydF9jbGllbnRfdGVzdF9kZW1vIiwiZXhwaXJhdGlvbiI6MTc0MzUxNDk4Mywibm9uY2UiOiJlZDI5MWIwNmZhMTE4OTc4In0)
|
74
|
+
9. Open Postman and open the "FHIR Request" Collection. Click the "Variables" tab and add the
|
75
|
+
copied access token as the current value of the `bearer_token` variable. Also update the
|
76
|
+
`base_url` value for where the test is running (see details on the "Overview" tab).
|
77
|
+
Save the collection.
|
78
|
+
10. Select the "Patient Read" request and click "Send". A FHIR Patient resource should be returned.
|
79
|
+
11. Return to the client tests and click the link to continue and complete the tests.
|
80
|
+
|
81
|
+
The client tests should pass. On the server side some of the registration tests will fail. This is
|
82
|
+
expected as the Server tests make several intentionally invalid token requests. Inferno's simulated UDAP
|
83
|
+
server responds successfully to those requests when the client id can be identified, but flags them as
|
84
|
+
not conformant causing these expected failures. Because responding successfully to non-conformant
|
85
|
+
registration requests is itself not conformant there are corresponding failures on the server test.
|
86
|
+
|
87
|
+
### Additional Inputs
|
88
|
+
|
89
|
+
One additional input is available to support testers
|
90
|
+
- **FHIR Response to Echo**: The focus of this test kit is on the auth protocol, so the
|
91
|
+
simulated FHIR server implemented in this test suite is very simple and will by default
|
92
|
+
return a FHIR OperationOutcome to any request made. Testers may provide a static
|
93
|
+
FHIR JSON body for Inferno to return instead. In this case, the simulation is a simple
|
94
|
+
echo and Inferno does not check that the response if appropriate for the request made.
|
95
|
+
|
96
|
+
## Current Limitations
|
97
|
+
|
98
|
+
This test kit is still in draft form and does not test all of the requirements and features
|
99
|
+
described in the UDAP Security IG for clients. Notably, only the B2B client credentials flow
|
100
|
+
is tested at this time.
|
101
|
+
|
102
|
+
The following sections list other known gaps and limitations.
|
103
|
+
|
104
|
+
### UDAP Server Simulation Limitations
|
105
|
+
|
106
|
+
This test suite contains a simulation of a UDAP server which is not fully
|
107
|
+
general and not all conformant clients may be able to interact with it. One
|
108
|
+
specific example is that the UDAP configuration metadata available at
|
109
|
+
`.well-known/udap` for the simulated server is fixed and cannot be changed by
|
110
|
+
testers at this time. Despite the current limitations, the intention is for Inferno to
|
111
|
+
support a variety of conformant choices, so please report issues that prevent conformant
|
112
|
+
systems from passing in the [github repository's issues page](https://github.com/inferno-framework/udap-security-test-kit/issues/).
|
113
|
+
|
114
|
+
### FHIR Server Simulation Limitations
|
115
|
+
|
116
|
+
The FHIR server simulation used to support clients in demonstrating their ability to access
|
117
|
+
FHIR APIs using access tokens obtained using the UDAP flows is very limited. Testers are currently
|
118
|
+
able to provide a single static response that will be echoed for any FHIR request made. While
|
119
|
+
Inferno will never implement a fully general FHIR server simulation, additional options may be added
|
120
|
+
in the future based on community feedback.
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../urls'
|
4
|
+
require_relative '../tags'
|
5
|
+
require_relative 'mock_udap_server'
|
6
|
+
|
7
|
+
module UDAPSecurityTestKit
|
8
|
+
class EchoingFHIRResponderEndpoint < Inferno::DSL::SuiteEndpoint
|
9
|
+
def test_run_identifier
|
10
|
+
MockUDAPServer.token_to_client_id(request.headers['authorization']&.delete_prefix('Bearer '))
|
11
|
+
end
|
12
|
+
|
13
|
+
def make_response
|
14
|
+
return if response.status == 401 # set in update_result (expired token handling there)
|
15
|
+
|
16
|
+
response.content_type = 'application/fhir+json'
|
17
|
+
|
18
|
+
# If the tester provided a response, echo it
|
19
|
+
# otherwise, operation outcome
|
20
|
+
echo_response = JSON.parse(result.input_json)
|
21
|
+
.find { |input| input['name'].include?('echoed_fhir_response') }
|
22
|
+
&.dig('value')
|
23
|
+
|
24
|
+
unless echo_response.present?
|
25
|
+
response.status = 400
|
26
|
+
response.body = FHIR::OperationOutcome.new(
|
27
|
+
issue: FHIR::OperationOutcome::Issue.new(
|
28
|
+
severity: 'fatal', code: 'required',
|
29
|
+
details: FHIR::CodeableConcept.new(text: 'No response provided to echo.')
|
30
|
+
)
|
31
|
+
).to_json
|
32
|
+
return
|
33
|
+
end
|
34
|
+
|
35
|
+
response.status = 200
|
36
|
+
response.body = echo_response
|
37
|
+
end
|
38
|
+
|
39
|
+
def update_result
|
40
|
+
if MockUDAPServer.request_has_expired_token?(request)
|
41
|
+
MockUDAPServer.update_response_for_expired_token(response)
|
42
|
+
return
|
43
|
+
end
|
44
|
+
|
45
|
+
nil # never update for now
|
46
|
+
end
|
47
|
+
|
48
|
+
def tags
|
49
|
+
[ACCESS_TAG]
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../../urls'
|
4
|
+
require_relative '../../tags'
|
5
|
+
require_relative '../mock_udap_server'
|
6
|
+
|
7
|
+
module UDAPSecurityTestKit
|
8
|
+
module MockUDAPServer
|
9
|
+
class RegistrationEndpoint < Inferno::DSL::SuiteEndpoint
|
10
|
+
def test_run_identifier
|
11
|
+
MockUDAPServer.client_uri_to_client_id(
|
12
|
+
client_uri_from_registration_payload(MockUDAPServer.parsed_io_body(request))
|
13
|
+
)
|
14
|
+
end
|
15
|
+
|
16
|
+
def make_response
|
17
|
+
parsed_body = MockUDAPServer.parsed_io_body(request)
|
18
|
+
client_id = MockUDAPServer.client_uri_to_client_id(client_uri_from_registration_payload(parsed_body))
|
19
|
+
ss_jwt = request_software_statement_jwt(parsed_body)
|
20
|
+
|
21
|
+
response_body = {
|
22
|
+
client_id:,
|
23
|
+
software_statement: ss_jwt
|
24
|
+
}
|
25
|
+
response_body.merge!(MockUDAPServer.jwt_claims(ss_jwt).except(['iss', 'sub', 'exp', 'iat', 'jti']))
|
26
|
+
|
27
|
+
response.body = response_body.to_json
|
28
|
+
response.headers['Cache-Control'] = 'no-store'
|
29
|
+
response.headers['Pragma'] = 'no-cache'
|
30
|
+
response.headers['Access-Control-Allow-Origin'] = '*'
|
31
|
+
response.content_type = 'application/json'
|
32
|
+
response.status = 201
|
33
|
+
end
|
34
|
+
|
35
|
+
def update_result
|
36
|
+
nil # never update for now
|
37
|
+
end
|
38
|
+
|
39
|
+
def tags
|
40
|
+
[REGISTRATION_TAG, UDAP_TAG]
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def client_uri_from_registration_payload(reg_body)
|
46
|
+
software_statement_jwt = request_software_statement_jwt(reg_body)
|
47
|
+
return unless software_statement_jwt.present?
|
48
|
+
|
49
|
+
MockUDAPServer.jwt_claims(software_statement_jwt)&.dig('iss')
|
50
|
+
end
|
51
|
+
|
52
|
+
def request_software_statement_jwt(reg_body)
|
53
|
+
reg_body&.dig('software_statement')
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../../urls'
|
4
|
+
require_relative '../../tags'
|
5
|
+
require_relative '../mock_udap_server'
|
6
|
+
|
7
|
+
module UDAPSecurityTestKit
|
8
|
+
module MockUDAPServer
|
9
|
+
class TokenEndpoint < Inferno::DSL::SuiteEndpoint
|
10
|
+
def test_run_identifier
|
11
|
+
MockUDAPServer.client_id_from_client_assertion(request.params[:client_assertion])
|
12
|
+
end
|
13
|
+
|
14
|
+
def make_response
|
15
|
+
MockUDAPServer.make_udap_token_response(request, response, test_run.test_session_id)
|
16
|
+
end
|
17
|
+
|
18
|
+
def update_result
|
19
|
+
nil # never update for now
|
20
|
+
end
|
21
|
+
|
22
|
+
def tags
|
23
|
+
[TOKEN_TAG, UDAP_TAG]
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,301 @@
|
|
1
|
+
require 'jwt'
|
2
|
+
require 'faraday'
|
3
|
+
require 'time'
|
4
|
+
require_relative '../urls'
|
5
|
+
require_relative '../tags'
|
6
|
+
require_relative '../udap_jwt_builder'
|
7
|
+
|
8
|
+
module UDAPSecurityTestKit
|
9
|
+
module MockUDAPServer
|
10
|
+
SUPPORTED_SCOPES = ['openid', 'system/*.read', 'user/*.read', 'patient/*.read'].freeze
|
11
|
+
|
12
|
+
module_function
|
13
|
+
|
14
|
+
def udap_server_metadata(suite_id)
|
15
|
+
base_url = "#{Inferno::Application['base_url']}/custom/#{suite_id}"
|
16
|
+
response_body = {
|
17
|
+
udap_versions_supported: ['1'],
|
18
|
+
udap_profiles_supported: ['udap_dcr', 'udap_authn', 'udap_authz'],
|
19
|
+
udap_authorization_extensions_supported: ['hl7-b2b'],
|
20
|
+
udap_authorization_extensions_required: [],
|
21
|
+
udap_certifications_supported: [],
|
22
|
+
udap_certifications_required: [],
|
23
|
+
grant_types_supported: ['client_credentials'],
|
24
|
+
scopes_supported: SUPPORTED_SCOPES,
|
25
|
+
token_endpoint: base_url + TOKEN_PATH,
|
26
|
+
token_endpoint_auth_methods_supported: ['private_key_jwt'],
|
27
|
+
token_endpoint_auth_signing_alg_values_supported: ['RS256', 'RS384', 'ES384'],
|
28
|
+
registration_endpoint: base_url + REGISTRATION_PATH,
|
29
|
+
registration_endpoint_jwt_signing_alg_values_supported: ['RS256', 'RS384', 'ES384'],
|
30
|
+
signed_metadata: udap_signed_metadata_jwt(base_url)
|
31
|
+
}.to_json
|
32
|
+
|
33
|
+
[200, { 'Content-Type' => 'application/json', 'Access-Control-Allow-Origin' => '*' }, [response_body]]
|
34
|
+
end
|
35
|
+
|
36
|
+
def make_udap_token_response(request, response, test_session_id)
|
37
|
+
assertion = request.params[:client_assertion]
|
38
|
+
client_id = client_id_from_client_assertion(assertion)
|
39
|
+
|
40
|
+
software_statement = udap_registration_software_statement(test_session_id)
|
41
|
+
signature_error = udap_token_signature_verification(assertion, software_statement)
|
42
|
+
|
43
|
+
if signature_error.present?
|
44
|
+
update_response_for_invalid_assertion(response, signature_error)
|
45
|
+
return
|
46
|
+
end
|
47
|
+
|
48
|
+
exp_min = 60
|
49
|
+
response_body = {
|
50
|
+
access_token: client_id_to_token(client_id, exp_min),
|
51
|
+
token_type: 'Bearer',
|
52
|
+
expires_in: 60 * exp_min
|
53
|
+
}
|
54
|
+
|
55
|
+
response.body = response_body.to_json
|
56
|
+
response.headers['Cache-Control'] = 'no-store'
|
57
|
+
response.headers['Pragma'] = 'no-cache'
|
58
|
+
response.headers['Access-Control-Allow-Origin'] = '*'
|
59
|
+
response.content_type = 'application/json'
|
60
|
+
response.status = 200
|
61
|
+
end
|
62
|
+
|
63
|
+
def udap_signed_metadata_jwt(base_url)
|
64
|
+
jwt_claim_hash = {
|
65
|
+
iss: base_url + FHIR_PATH,
|
66
|
+
sub: base_url + FHIR_PATH,
|
67
|
+
exp: 5.minutes.from_now.to_i,
|
68
|
+
iat: Time.now.to_i,
|
69
|
+
jti: SecureRandom.hex(32),
|
70
|
+
token_endpoint: base_url + TOKEN_PATH,
|
71
|
+
registration_endpoint: base_url + REGISTRATION_PATH
|
72
|
+
}.compact
|
73
|
+
|
74
|
+
UDAPJWTBuilder.encode_jwt_with_x5c_header(
|
75
|
+
jwt_claim_hash,
|
76
|
+
test_kit_private_key,
|
77
|
+
'RS256',
|
78
|
+
[test_kit_cert]
|
79
|
+
)
|
80
|
+
end
|
81
|
+
|
82
|
+
def root_ca_cert
|
83
|
+
File.read(
|
84
|
+
ENV.fetch('UDAP_ROOT_CA_CERT_FILE',
|
85
|
+
File.join(__dir__, '..',
|
86
|
+
'certs', 'infernoCA.pem'))
|
87
|
+
)
|
88
|
+
end
|
89
|
+
|
90
|
+
def root_ca_private_key
|
91
|
+
File.read(
|
92
|
+
ENV.fetch('UDAP_CA_PRIVATE_KEY_FILE',
|
93
|
+
File.join(__dir__, '..',
|
94
|
+
'certs', 'infernoCA.key'))
|
95
|
+
)
|
96
|
+
end
|
97
|
+
|
98
|
+
def test_kit_cert
|
99
|
+
File.read(
|
100
|
+
ENV.fetch('UDAP_TEST_KIT_CERT_FILE',
|
101
|
+
File.join(__dir__, '..',
|
102
|
+
'certs', 'TestClient.pem'))
|
103
|
+
)
|
104
|
+
end
|
105
|
+
|
106
|
+
def test_kit_private_key
|
107
|
+
File.read(
|
108
|
+
ENV.fetch('UDAP_TEST_KIT_PRIVATE_KEY_FILE',
|
109
|
+
File.join(__dir__, '..',
|
110
|
+
'certs', 'TestClientPrivateKey.key'))
|
111
|
+
)
|
112
|
+
end
|
113
|
+
|
114
|
+
def parsed_request_body(request)
|
115
|
+
JSON.parse(request.request_body)
|
116
|
+
rescue JSON::ParserError
|
117
|
+
nil
|
118
|
+
end
|
119
|
+
|
120
|
+
def parsed_io_body(request)
|
121
|
+
parsed_body = begin
|
122
|
+
JSON.parse(request.body.read)
|
123
|
+
rescue JSON::ParserError
|
124
|
+
nil
|
125
|
+
end
|
126
|
+
request.body.rewind
|
127
|
+
|
128
|
+
parsed_body
|
129
|
+
end
|
130
|
+
|
131
|
+
def jwt_claims(encoded_jwt)
|
132
|
+
JWT.decode(encoded_jwt, nil, false)[0]
|
133
|
+
end
|
134
|
+
|
135
|
+
def client_uri_to_client_id(client_uri)
|
136
|
+
Base64.urlsafe_encode64(client_uri, padding: false)
|
137
|
+
end
|
138
|
+
|
139
|
+
def client_id_to_client_uri(client_id)
|
140
|
+
Base64.urlsafe_decode64(client_id)
|
141
|
+
end
|
142
|
+
|
143
|
+
def client_id_to_token(client_id, exp_min)
|
144
|
+
token_structure = {
|
145
|
+
client_id:,
|
146
|
+
expiration: exp_min.minutes.from_now.to_i,
|
147
|
+
nonce: SecureRandom.hex(8)
|
148
|
+
}.to_json
|
149
|
+
|
150
|
+
Base64.urlsafe_encode64(token_structure, padding: false)
|
151
|
+
end
|
152
|
+
|
153
|
+
def decode_token(token)
|
154
|
+
JSON.parse(Base64.urlsafe_decode64(token))
|
155
|
+
rescue JSON::ParserError
|
156
|
+
nil
|
157
|
+
end
|
158
|
+
|
159
|
+
def token_to_client_id(token)
|
160
|
+
decode_token(token)&.dig('client_id')
|
161
|
+
end
|
162
|
+
|
163
|
+
def request_has_expired_token?(request)
|
164
|
+
return false if request.params[:session_path].present?
|
165
|
+
|
166
|
+
token = request.headers['authorization']&.delete_prefix('Bearer ')
|
167
|
+
decoded_token = decode_token(token)
|
168
|
+
return false unless decoded_token&.dig('expiration').present?
|
169
|
+
|
170
|
+
decoded_token['expiration'] < Time.now.to_i
|
171
|
+
end
|
172
|
+
|
173
|
+
def update_response_for_expired_token(response)
|
174
|
+
response.status = 401
|
175
|
+
response.format = :json
|
176
|
+
response.body = FHIR::OperationOutcome.new(
|
177
|
+
issue: FHIR::OperationOutcome::Issue.new(severity: 'fatal', code: 'expired',
|
178
|
+
details: FHIR::CodeableConcept.new(text: 'Bearer token has expired'))
|
179
|
+
).to_json
|
180
|
+
end
|
181
|
+
|
182
|
+
def udap_reg_signature_verification(assertion_jwt)
|
183
|
+
assertion_body, assertion_header = JWT.decode(assertion_jwt, nil, false)
|
184
|
+
return 'missing `x5c` header' if assertion_header['x5c'].blank?
|
185
|
+
|
186
|
+
leaf_cert_der = Base64.decode64(assertion_header['x5c'].first)
|
187
|
+
leaf_cert = OpenSSL::X509::Certificate.new(leaf_cert_der)
|
188
|
+
|
189
|
+
signature_error = udap_assertion_signature_verification(assertion_jwt, leaf_cert, assertion_header['alg'])
|
190
|
+
return signature_error if signature_error.present?
|
191
|
+
|
192
|
+
# check the certificate's SAN extension for the issuer name
|
193
|
+
issuer = assertion_body['iss']
|
194
|
+
begin
|
195
|
+
alt_names =
|
196
|
+
leaf_cert.extensions
|
197
|
+
.find { |extension| extension.oid == 'subjectAltName' }.value
|
198
|
+
rescue NoMethodError
|
199
|
+
return 'Could not find Subject Alternative Name extension in leaf certificate'
|
200
|
+
end
|
201
|
+
return if alt_names.include?("URI:#{issuer}")
|
202
|
+
|
203
|
+
"`iss` claim `#{issuer}` not found in Subject Alternative Name extension " \
|
204
|
+
"from the `x5c` JWT header: `#{alt_names}`"
|
205
|
+
end
|
206
|
+
|
207
|
+
def udap_token_signature_verification(assertion_jwt, registration_jwt)
|
208
|
+
_assertion_body, assertion_header = JWT.decode(assertion_jwt, nil, false)
|
209
|
+
return 'missing `x5c` header' if assertion_header['x5c'].blank?
|
210
|
+
|
211
|
+
leaf_cert_der = Base64.decode64(assertion_header['x5c'].first)
|
212
|
+
leaf_cert = OpenSSL::X509::Certificate.new(leaf_cert_der)
|
213
|
+
|
214
|
+
signature_error = udap_assertion_signature_verification(assertion_jwt, leaf_cert, assertion_header['alg'])
|
215
|
+
return signature_error if signature_error.present?
|
216
|
+
return unless registration_jwt.present?
|
217
|
+
|
218
|
+
# check trust
|
219
|
+
_registration_body, registration_header = JWT.decode(registration_jwt, nil, false)
|
220
|
+
return if assertion_header['x5c'].first == registration_header['x5c'].first
|
221
|
+
|
222
|
+
'signing cert does not match registration cert'
|
223
|
+
end
|
224
|
+
|
225
|
+
def udap_assertion_signature_verification(assertion_jwt, signing_cert, algorithm)
|
226
|
+
return 'missing `alg` header' unless algorithm.present?
|
227
|
+
|
228
|
+
signature_validation_result = UDAPSecurityTestKit::UDAPJWTValidator.validate_signature(
|
229
|
+
assertion_jwt,
|
230
|
+
algorithm,
|
231
|
+
signing_cert
|
232
|
+
)
|
233
|
+
return if signature_validation_result[:success]
|
234
|
+
|
235
|
+
signature_validation_result[:error_message]
|
236
|
+
end
|
237
|
+
|
238
|
+
def udap_registration_software_statement(test_session_id)
|
239
|
+
registration_requests =
|
240
|
+
Inferno::Repositories::Requests.new.tagged_requests(test_session_id, [UDAP_TAG, REGISTRATION_TAG])
|
241
|
+
return unless registration_requests.present?
|
242
|
+
|
243
|
+
parsed_body = MockUDAPServer.parsed_request_body(registration_requests.last)
|
244
|
+
parsed_body&.dig('software_statement')
|
245
|
+
end
|
246
|
+
|
247
|
+
def update_response_for_invalid_assertion(response, error_message)
|
248
|
+
response.status = 401
|
249
|
+
response.format = :json
|
250
|
+
response.body = { error: 'invalid_client', error_description: error_message }.to_json
|
251
|
+
end
|
252
|
+
|
253
|
+
def client_id_from_client_assertion(client_assertion_jwt)
|
254
|
+
return unless client_assertion_jwt.present?
|
255
|
+
|
256
|
+
jwt_claims(client_assertion_jwt)&.dig('iss')
|
257
|
+
end
|
258
|
+
|
259
|
+
def check_jwt_timing(issue_claim, expiration_claim, request_time) # rubocop:disable Metrics/CyclomaticComplexity
|
260
|
+
add_message('error', 'Registration software statement `iat` claim is missing') unless issue_claim.present?
|
261
|
+
add_message('error', 'Registration software statement `exp` claim is missing') unless expiration_claim.present?
|
262
|
+
return unless issue_claim.present? && expiration_claim.present?
|
263
|
+
|
264
|
+
unless issue_claim.is_a?(Numeric)
|
265
|
+
add_message('error',
|
266
|
+
"Registration software statement `iat` claim is invalid: expected a number, got '#{issue_claim}'")
|
267
|
+
end
|
268
|
+
unless expiration_claim.is_a?(Numeric)
|
269
|
+
add_message('error',
|
270
|
+
'Registration software statement `exp` claim is invalid: ' \
|
271
|
+
"expected a number, got '#{expiration_claim}'")
|
272
|
+
end
|
273
|
+
return unless issue_claim.is_a?(Numeric) && expiration_claim.is_a?(Numeric)
|
274
|
+
|
275
|
+
issue_time = Time.at(issue_claim)
|
276
|
+
expiration_time = Time.at(expiration_claim)
|
277
|
+
unless expiration_time > issue_time
|
278
|
+
add_message('error',
|
279
|
+
'Registration software statement `exp` claim is invalid: ' \
|
280
|
+
'cannot be before the `iat` claim.')
|
281
|
+
end
|
282
|
+
unless expiration_time <= issue_time + 5.minutes
|
283
|
+
add_message('error',
|
284
|
+
'Registration software statement `exp` claim is invalid: ' \
|
285
|
+
'cannot be more than 5 minutes after the `iat` claim.')
|
286
|
+
end
|
287
|
+
unless issue_time <= request_time
|
288
|
+
add_message('error',
|
289
|
+
'Registration software statement `iat` claim is invalid: ' \
|
290
|
+
'cannot be after the request time.')
|
291
|
+
end
|
292
|
+
unless expiration_time > request_time
|
293
|
+
add_message('error',
|
294
|
+
'Registration software statement `exp` claim is invalid: ' \
|
295
|
+
'it has expired.')
|
296
|
+
end
|
297
|
+
|
298
|
+
nil
|
299
|
+
end
|
300
|
+
end
|
301
|
+
end
|
@@ -3,9 +3,9 @@ require_relative 'version'
|
|
3
3
|
module UDAPSecurityTestKit
|
4
4
|
class Metadata < Inferno::TestKit
|
5
5
|
id :udap_security
|
6
|
-
title 'UDAP Security'
|
6
|
+
title 'UDAP Security Test Kit'
|
7
7
|
description <<~DESCRIPTION
|
8
|
-
This is a collection of tests to verify server conformance to the [HL7 UDAP Security
|
8
|
+
This is a collection of tests to verify client and server conformance to the [HL7 UDAP Security
|
9
9
|
STU 1.0 IG](https://hl7.org/fhir/us/udap-security/STU1/index.html)
|
10
10
|
<!-- break -->
|
11
11
|
Specifically, this test
|
@@ -14,16 +14,16 @@ module UDAPSecurityTestKit
|
|
14
14
|
- [Discovery](https://hl7.org/fhir/us/udap-security/STU1/discovery.html)
|
15
15
|
- [Dynamic Client Registration](https://hl7.org/fhir/us/udap-security/STU1/registration.html)
|
16
16
|
- [Consumer-Facing Authorization & Authentication](https://hl7.org/fhir/us/udap-security/STU1/consumer.html)
|
17
|
+
(server only)
|
17
18
|
- [Business-to-Business (B2B) Authorization & Authentication](https://hl7.org/fhir/us/udap-security/STU1/b2b.html)
|
18
19
|
|
19
20
|
[Tiered OAuth for User
|
20
21
|
Authentication](https://hl7.org/fhir/us/udap-security/STU1/user.html) is not a
|
21
22
|
required capability and is not assessed.
|
22
|
-
This test kit also does not assess client conformance.
|
23
23
|
DESCRIPTION
|
24
|
-
suite_ids [:udap_security]
|
24
|
+
suite_ids [:udap_security, :udap_security_client]
|
25
25
|
tags ['UDAP Security']
|
26
|
-
last_updated
|
26
|
+
last_updated LAST_UPDATED
|
27
27
|
version VERSION
|
28
28
|
maturity 'Low'
|
29
29
|
authors 'inferno@groups.mitre.org'
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UDAPSecurityTestKit
|
4
|
+
FHIR_PATH = '/fhir'
|
5
|
+
RESUME_PASS_PATH = '/resume_pass'
|
6
|
+
RESUME_FAIL_PATH = '/resume_fail'
|
7
|
+
AUTH_SERVER_PATH = '/auth'
|
8
|
+
UDAP_DISCOVERY_PATH = "#{FHIR_PATH}/.well-known/udap".freeze
|
9
|
+
TOKEN_PATH = "#{AUTH_SERVER_PATH}/token".freeze
|
10
|
+
REGISTRATION_PATH = "#{AUTH_SERVER_PATH}/register".freeze
|
11
|
+
|
12
|
+
module URLs
|
13
|
+
def client_base_url
|
14
|
+
@client_base_url ||= "#{Inferno::Application['base_url']}/custom/#{client_suite_id}"
|
15
|
+
end
|
16
|
+
|
17
|
+
def client_fhir_base_url
|
18
|
+
@client_fhir_base_url ||= client_base_url + FHIR_PATH
|
19
|
+
end
|
20
|
+
|
21
|
+
def client_resume_pass_url
|
22
|
+
@client_resume_pass_url ||= client_base_url + RESUME_PASS_PATH
|
23
|
+
end
|
24
|
+
|
25
|
+
def client_resume_fail_url
|
26
|
+
@client_resume_fail_url ||= client_base_url + RESUME_FAIL_PATH
|
27
|
+
end
|
28
|
+
|
29
|
+
def client_udap_discovery_url
|
30
|
+
@client_udap_discovery_url ||= client_base_url + UDAP_DISCOVERY_PATH
|
31
|
+
end
|
32
|
+
|
33
|
+
def client_token_url
|
34
|
+
@client_token_url ||= client_base_url + TOKEN_PATH
|
35
|
+
end
|
36
|
+
|
37
|
+
def client_registration_url
|
38
|
+
@client_registration_url ||= client_base_url + REGISTRATION_PATH
|
39
|
+
end
|
40
|
+
|
41
|
+
def client_suite_id
|
42
|
+
UDAPSecurityTestKit::UDAPSecurityClientTestSuite.id
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|