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.
Files changed (24) hide show
  1. checksums.yaml +4 -4
  2. data/config/presets/UDAP_RunClientAgainstServer.json.erb +20 -0
  3. data/config/presets/UDAP_RunServerAgainstClient.json.erb +272 -0
  4. data/lib/udap_security_test_kit/client_credentials_token_exchange_test.rb +1 -1
  5. data/lib/udap_security_test_kit/client_suite/client_access_group.rb +22 -0
  6. data/lib/udap_security_test_kit/client_suite/client_access_interaction_test.rb +53 -0
  7. data/lib/udap_security_test_kit/client_suite/client_registration_group.rb +26 -0
  8. data/lib/udap_security_test_kit/client_suite/client_registration_interaction_test.rb +50 -0
  9. data/lib/udap_security_test_kit/client_suite/client_registration_verification_test.rb +244 -0
  10. data/lib/udap_security_test_kit/client_suite/client_token_request_verification_test.rb +178 -0
  11. data/lib/udap_security_test_kit/client_suite/client_token_use_verification_test.rb +43 -0
  12. data/lib/udap_security_test_kit/client_suite.rb +78 -0
  13. data/lib/udap_security_test_kit/docs/demo/FHIR Request.postman_collection.json +81 -0
  14. data/lib/udap_security_test_kit/docs/udap_client_suite_description.md +120 -0
  15. data/lib/udap_security_test_kit/endpoints/echoing_fhir_responder.rb +52 -0
  16. data/lib/udap_security_test_kit/endpoints/mock_udap_server/registration.rb +57 -0
  17. data/lib/udap_security_test_kit/endpoints/mock_udap_server/token.rb +27 -0
  18. data/lib/udap_security_test_kit/endpoints/mock_udap_server.rb +301 -0
  19. data/lib/udap_security_test_kit/metadata.rb +5 -5
  20. data/lib/udap_security_test_kit/tags.rb +8 -0
  21. data/lib/udap_security_test_kit/urls.rb +45 -0
  22. data/lib/udap_security_test_kit/version.rb +2 -1
  23. data/lib/udap_security_test_kit.rb +8 -2
  24. 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 '2025-01-09'
26
+ last_updated LAST_UPDATED
27
27
  version VERSION
28
28
  maturity 'Low'
29
29
  authors 'inferno@groups.mitre.org'
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UDAPSecurityTestKit
4
+ REGISTRATION_TAG = 'registration'
5
+ TOKEN_TAG = 'token'
6
+ UDAP_TAG = 'udap'
7
+ ACCESS_TAG = 'access'
8
+ end
@@ -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
@@ -1,3 +1,4 @@
1
1
  module UDAPSecurityTestKit
2
- VERSION = '0.11.1'.freeze
2
+ VERSION = '0.11.3'.freeze
3
+ LAST_UPDATED = '2025-04-07'.freeze
3
4
  end