udap_security_test_kit 0.11.2 → 0.11.4

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 (40) 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/access_ac_group.rb +25 -0
  6. data/lib/udap_security_test_kit/client_suite/access_ac_interaction_test.rb +59 -0
  7. data/lib/udap_security_test_kit/client_suite/access_cc_group.rb +23 -0
  8. data/lib/udap_security_test_kit/client_suite/access_cc_interaction_test.rb +49 -0
  9. data/lib/udap_security_test_kit/client_suite/authorization_request_verification_test.rb +83 -0
  10. data/lib/udap_security_test_kit/client_suite/client_descriptions.rb +70 -0
  11. data/lib/udap_security_test_kit/client_suite/client_options.rb +20 -0
  12. data/lib/udap_security_test_kit/client_suite/oidc_jwks.json +32 -0
  13. data/lib/udap_security_test_kit/client_suite/oidc_jwks.rb +27 -0
  14. data/lib/udap_security_test_kit/client_suite/registration_ac_group.rb +18 -0
  15. data/lib/udap_security_test_kit/client_suite/registration_ac_verification_test.rb +38 -0
  16. data/lib/udap_security_test_kit/client_suite/registration_cc_group.rb +18 -0
  17. data/lib/udap_security_test_kit/client_suite/registration_cc_verification_test.rb +38 -0
  18. data/lib/udap_security_test_kit/client_suite/registration_interaction_test.rb +57 -0
  19. data/lib/udap_security_test_kit/client_suite/registration_request_verification.rb +242 -0
  20. data/lib/udap_security_test_kit/client_suite/token_request_ac_verification_test.rb +49 -0
  21. data/lib/udap_security_test_kit/client_suite/token_request_cc_verification_test.rb +49 -0
  22. data/lib/udap_security_test_kit/client_suite/token_request_verification.rb +223 -0
  23. data/lib/udap_security_test_kit/client_suite/token_use_verification_test.rb +40 -0
  24. data/lib/udap_security_test_kit/client_suite.rb +107 -0
  25. data/lib/udap_security_test_kit/docs/demo/FHIR Request.postman_collection.json +81 -0
  26. data/lib/udap_security_test_kit/docs/udap_client_suite_description.md +163 -0
  27. data/lib/udap_security_test_kit/endpoints/echoing_fhir_responder_endpoint.rb +96 -0
  28. data/lib/udap_security_test_kit/endpoints/mock_udap_server/authorization_endpoint.rb +28 -0
  29. data/lib/udap_security_test_kit/endpoints/mock_udap_server/registration_endpoint.rb +31 -0
  30. data/lib/udap_security_test_kit/endpoints/mock_udap_server/token_endpoint.rb +56 -0
  31. data/lib/udap_security_test_kit/endpoints/mock_udap_server/udap_authorization_response_creation.rb +63 -0
  32. data/lib/udap_security_test_kit/endpoints/mock_udap_server/udap_registration_response_creation.rb +28 -0
  33. data/lib/udap_security_test_kit/endpoints/mock_udap_server/udap_token_response_creation.rb +218 -0
  34. data/lib/udap_security_test_kit/endpoints/mock_udap_server.rb +382 -0
  35. data/lib/udap_security_test_kit/metadata.rb +4 -4
  36. data/lib/udap_security_test_kit/tags.rb +12 -0
  37. data/lib/udap_security_test_kit/urls.rb +52 -0
  38. data/lib/udap_security_test_kit/version.rb +2 -2
  39. data/lib/udap_security_test_kit.rb +8 -2
  40. metadata +36 -2
@@ -0,0 +1,96 @@
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.issued_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
+ response.headers['Access-Control-Allow-Origin'] = '*'
18
+ response.status = 200
19
+
20
+ # look for read of provided resources
21
+ read_response = tester_provided_read_response_body
22
+ if read_response.present?
23
+ response.body = read_response.to_json
24
+ return
25
+ end
26
+
27
+ # If the tester provided a response, echo it
28
+ # otherwise, operation outcome
29
+ echo_response = JSON.parse(result.input_json)
30
+ .find { |input| input['name'].include?('echoed_fhir_response') }
31
+ &.dig('value')
32
+ if echo_response.present?
33
+ response.body = echo_response
34
+ return
35
+ end
36
+
37
+ response.status = 400
38
+ response.body = FHIR::OperationOutcome.new(
39
+ issue: FHIR::OperationOutcome::Issue.new(
40
+ severity: 'fatal', code: 'required',
41
+ details: FHIR::CodeableConcept.new(text: 'No response provided to echo.')
42
+ )
43
+ ).to_json
44
+ end
45
+
46
+ def update_result
47
+ if MockUDAPServer.request_has_expired_token?(request)
48
+ MockUDAPServer.update_response_for_expired_token(response, 'Bearer token')
49
+ return
50
+ end
51
+
52
+ nil # never update for now
53
+ end
54
+
55
+ def tags
56
+ [ACCESS_TAG]
57
+ end
58
+
59
+ def tester_provided_read_response_body
60
+ resource_type = request.params[:one]
61
+ id = request.params[:two]
62
+
63
+ return unless resource_type.present? && id.present?
64
+
65
+ resource_type_class =
66
+ begin
67
+ FHIR.const_get(resource_type)
68
+ rescue NameError
69
+ nil
70
+ end
71
+ return unless resource_type_class.present?
72
+
73
+ resource_bundle = ehr_input_bundle
74
+ return unless resource_bundle.present?
75
+
76
+ find_resource_in_bundle(resource_bundle, resource_type_class, id)
77
+ end
78
+
79
+ def ehr_input_bundle
80
+ ehr_bundle_input =
81
+ JSON.parse(result.input_json).find { |input| input['name'] == 'fhir_read_resources_bundle' }&.dig('value')
82
+ ehr_bundle = FHIR.from_contents(ehr_bundle_input) if ehr_bundle_input.present?
83
+ return ehr_bundle if ehr_bundle.is_a?(FHIR::Bundle)
84
+
85
+ nil
86
+ rescue StandardError
87
+ nil
88
+ end
89
+
90
+ def find_resource_in_bundle(bundle, resource_type_class, id)
91
+ bundle.entry&.find do |entry|
92
+ entry.resource.is_a?(resource_type_class) && entry.resource.id == id
93
+ end&.resource
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../tags'
4
+ require_relative 'udap_authorization_response_creation'
5
+
6
+ module UDAPSecurityTestKit
7
+ module MockUDAPServer
8
+ class AuthorizationEndpoint < Inferno::DSL::SuiteEndpoint
9
+ include UDAPAuthorizationResponseCreation
10
+
11
+ def test_run_identifier
12
+ request.params[:client_id]
13
+ end
14
+
15
+ def make_response
16
+ make_udap_authorization_response
17
+ end
18
+
19
+ def update_result
20
+ nil # never update for now
21
+ end
22
+
23
+ def tags
24
+ [AUTHORIZATION_TAG, AUTHORIZATION_CODE_TAG, UDAP_TAG]
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../tags'
4
+ require_relative '../mock_udap_server'
5
+ require_relative 'udap_registration_response_creation'
6
+
7
+ module UDAPSecurityTestKit
8
+ module MockUDAPServer
9
+ class RegistrationEndpoint < Inferno::DSL::SuiteEndpoint
10
+ include UDAPRegistrationResponseCreation
11
+
12
+ def test_run_identifier
13
+ MockUDAPServer.client_uri_to_client_id(
14
+ MockUDAPServer.udap_client_uri_from_registration_payload(MockUDAPServer.parsed_io_body(request))
15
+ )
16
+ end
17
+
18
+ def make_response
19
+ make_udap_registration_response
20
+ end
21
+
22
+ def update_result
23
+ nil # never update for now
24
+ end
25
+
26
+ def tags
27
+ [REGISTRATION_TAG, UDAP_TAG]
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../tags'
4
+ require_relative '../../urls'
5
+ require_relative '../mock_udap_server'
6
+ require_relative 'udap_token_response_creation'
7
+
8
+ module UDAPSecurityTestKit
9
+ module MockUDAPServer
10
+ class TokenEndpoint < Inferno::DSL::SuiteEndpoint
11
+ include UDAPTokenResponseCreation
12
+ include URLs
13
+
14
+ def test_run_identifier
15
+ case request.params[:grant_type]
16
+ when CLIENT_CREDENTIALS_TAG
17
+ MockUDAPServer.client_id_from_client_assertion(request.params[:client_assertion])
18
+ when AUTHORIZATION_CODE_TAG
19
+ MockUDAPServer.issued_token_to_client_id(request.params[:code])
20
+ when REFRESH_TOKEN_TAG
21
+ MockUDAPServer.issued_token_to_client_id(
22
+ MockUDAPServer.refresh_token_to_authorization_code(request.params[:refresh_token])
23
+ )
24
+ end
25
+ end
26
+
27
+ def make_response
28
+ case request.params[:grant_type]
29
+ when CLIENT_CREDENTIALS_TAG
30
+ make_udap_client_credential_token_response
31
+ when AUTHORIZATION_CODE_TAG
32
+ make_udap_authorization_code_token_response
33
+ when REFRESH_TOKEN_TAG
34
+ make_udap_refresh_token_response
35
+ else
36
+ MockUDAPServer.update_response_for_error(
37
+ response,
38
+ "unsupported grant_type: #{request.params[:grant_type]}"
39
+ )
40
+ end
41
+ end
42
+
43
+ def update_result
44
+ nil # never update for now
45
+ end
46
+
47
+ def tags
48
+ tags = [TOKEN_TAG, UDAP_TAG]
49
+ if [CLIENT_CREDENTIALS_TAG, AUTHORIZATION_CODE_TAG, REFRESH_TOKEN_TAG].include?(request.params[:grant_type])
50
+ tags << request.params[:grant_type]
51
+ end
52
+ tags
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,63 @@
1
+ require 'jwt'
2
+ require_relative '../mock_udap_server'
3
+
4
+ module UDAPSecurityTestKit
5
+ module MockUDAPServer
6
+ module UDAPAuthorizationResponseCreation
7
+ def make_udap_authorization_response
8
+ redirect_uri = request.params[:redirect_uri]
9
+ registered_redirect_uri_list = udap_registered_redirect_uris
10
+
11
+ if redirect_uri.blank?
12
+ # need one from the registered list
13
+ if registered_redirect_uri_list.blank?
14
+ response.status = 400
15
+ response.body = {
16
+ error: 'Bad request',
17
+ message: 'Missing required redirect_uri parameter with no default provided in the registration.'
18
+ }.to_json
19
+ response.content_type = 'application/json'
20
+ return
21
+ elsif registered_redirect_uri_list.length > 1
22
+ response.status = 400
23
+ response.body = {
24
+ error: 'Bad request',
25
+ message: 'Missing required redirect_uri parameter with multiple options provided in the registration.'
26
+ }.to_json
27
+ response.content_type = 'application/json'
28
+ return
29
+ else
30
+ redirect_uri = registered_redirect_uri_list.first
31
+ end
32
+ end
33
+
34
+ client_id = request.params[:client_id]
35
+ state = request.params[:state]
36
+
37
+ exp_min = 10
38
+ token = MockUDAPServer.client_id_to_token(client_id, exp_min)
39
+ code_query_string = "code=#{ERB::Util.url_encode(token)}"
40
+ query_string =
41
+ if state.present?
42
+ "#{code_query_string}&state=#{ERB::Util.url_encode(state)}"
43
+ else
44
+ code_query_string
45
+ end
46
+ response.headers['Location'] = "#{redirect_uri}#{redirect_uri.include?('?') ? '&' : '?'}#{query_string}"
47
+ response.status = 302
48
+ end
49
+
50
+ def udap_registered_redirect_uris
51
+ registered_software_statement = MockUDAPServer.udap_registration_software_statement(test_run.test_session_id)
52
+ return unless registered_software_statement.present?
53
+
54
+ registration_jwt_body, _registration_jwt_header = JWT.decode(registered_software_statement, nil, false)
55
+ return [] unless registration_jwt_body['redirect'].present?
56
+ return registration_jwt_body['redirect'] if registration_jwt_body['redirect'].is_a?(Array)
57
+
58
+ # invalid registration, but we'll succeed here and fail during registration verification
59
+ [registration_jwt_body['redirect']]
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,28 @@
1
+ require_relative '../mock_udap_server'
2
+
3
+ module UDAPSecurityTestKit
4
+ module MockUDAPServer
5
+ module UDAPRegistrationResponseCreation
6
+ def make_udap_registration_response
7
+ parsed_body = MockUDAPServer.parsed_io_body(request)
8
+ client_id = MockUDAPServer.client_uri_to_client_id(
9
+ MockUDAPServer.udap_client_uri_from_registration_payload(parsed_body)
10
+ )
11
+ ss_jwt = MockUDAPServer.udap_software_statement_jwt(parsed_body)
12
+
13
+ response_body = {
14
+ client_id:,
15
+ software_statement: ss_jwt
16
+ }
17
+ response_body.merge!(MockUDAPServer.jwt_claims(ss_jwt).except(['iss', 'sub', 'exp', 'iat', 'jti']))
18
+
19
+ response.body = response_body.to_json
20
+ response.headers['Cache-Control'] = 'no-store'
21
+ response.headers['Pragma'] = 'no-cache'
22
+ response.headers['Access-Control-Allow-Origin'] = '*'
23
+ response.content_type = 'application/json'
24
+ response.status = 201
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,218 @@
1
+ require_relative '../../urls'
2
+ require_relative '../../tags'
3
+ require_relative '../mock_udap_server'
4
+ require_relative '../../client_suite/oidc_jwks'
5
+
6
+ module UDAPSecurityTestKit
7
+ module MockUDAPServer
8
+ module UDAPTokenResponseCreation
9
+ def make_udap_authorization_code_token_response # rubocop:disable Metrics/CyclomaticComplexity
10
+ authorization_code = request.params[:code]
11
+ client_id = MockUDAPServer.issued_token_to_client_id(authorization_code)
12
+ software_statement = MockUDAPServer.udap_registration_software_statement(test_run.test_session_id)
13
+ return unless udap_authenticated?(request.params[:client_assertion], software_statement)
14
+
15
+ if MockUDAPServer.token_expired?(authorization_code)
16
+ MockUDAPServer.update_response_for_expired_token(response, 'Authorization code')
17
+ return
18
+ end
19
+
20
+ return if request.params[:code_verifier].present? && !udap_pkce_valid?(authorization_code)
21
+
22
+ exp_min = 60
23
+ response_body = {
24
+ access_token: MockUDAPServer.client_id_to_token(client_id, exp_min),
25
+ token_type: 'Bearer',
26
+ expires_in: 60 * exp_min
27
+ }
28
+
29
+ launch_context =
30
+ begin
31
+ input_string = JSON.parse(result.input_json)&.find do |input|
32
+ input['name'] == 'launch_context'
33
+ end&.dig('value')
34
+ JSON.parse(input_string) if input_string.present?
35
+ rescue JSON::ParserError
36
+ nil
37
+ end
38
+ additional_context = udap_requested_scope_context(udap_registered_scope(software_statement), authorization_code,
39
+ launch_context)
40
+
41
+ response.body = additional_context.merge(response_body).to_json # response body values take priority
42
+ response.headers['Cache-Control'] = 'no-store'
43
+ response.headers['Pragma'] = 'no-cache'
44
+ response.headers['Access-Control-Allow-Origin'] = '*'
45
+ response.content_type = 'application/json'
46
+ response.status = 200
47
+ end
48
+
49
+ def make_udap_refresh_token_response # rubocop:disable Metrics/CyclomaticComplexity
50
+ refresh_token = request.params[:refresh_token]
51
+ authorization_code = MockUDAPServer.refresh_token_to_authorization_code(refresh_token)
52
+ client_id = MockUDAPServer.issued_token_to_client_id(authorization_code)
53
+ software_statement = MockUDAPServer.udap_registration_software_statement(test_run.test_session_id)
54
+ return unless udap_authenticated?(request.params[:client_assertion], software_statement)
55
+
56
+ # no expiration checks for refresh tokens
57
+
58
+ authorization_request = MockUDAPServer.authorization_request_for_code(authorization_code,
59
+ test_run.test_session_id)
60
+ if authorization_request.blank?
61
+ MockUDAPServer.update_response_for_error(
62
+ response,
63
+ "no authorization request found for refresh token #{refresh_token}"
64
+ )
65
+ return
66
+ end
67
+ auth_code_request_inputs = MockUDAPServer.authorization_code_request_details(authorization_request)
68
+ if auth_code_request_inputs.blank?
69
+ MockUDAPServer.update_response_for_error(
70
+ response,
71
+ 'invalid authorization request details'
72
+ )
73
+ return
74
+ end
75
+
76
+ exp_min = 60
77
+ response_body = {
78
+ access_token: MockUDAPServer.client_id_to_token(client_id, exp_min),
79
+ token_type: 'Bearer',
80
+ expires_in: 60 * exp_min
81
+ }
82
+
83
+ launch_context =
84
+ begin
85
+ input_string = JSON.parse(result.input_json)&.find do |input|
86
+ input['name'] == 'launch_context'
87
+ end&.dig('value')
88
+ JSON.parse(input_string) if input_string.present?
89
+ rescue JSON::ParserError
90
+ nil
91
+ end
92
+ additional_context = udap_requested_scope_context(udap_registered_scope(software_statement), authorization_code,
93
+ launch_context)
94
+
95
+ response.body = additional_context.merge(response_body).to_json # response body values take priority
96
+ response.headers['Cache-Control'] = 'no-store'
97
+ response.headers['Pragma'] = 'no-cache'
98
+ response.headers['Access-Control-Allow-Origin'] = '*'
99
+ response.content_type = 'application/json'
100
+ response.status = 200
101
+ end
102
+
103
+ def make_udap_client_credential_token_response
104
+ assertion = request.params[:client_assertion]
105
+ client_id = MockUDAPServer.client_id_from_client_assertion(assertion)
106
+ software_statement = MockUDAPServer.udap_registration_software_statement(test_run.test_session_id)
107
+ return unless udap_authenticated?(request.params[:client_assertion], software_statement)
108
+
109
+ exp_min = 60
110
+ response_body = {
111
+ access_token: MockUDAPServer.client_id_to_token(client_id, exp_min),
112
+ token_type: 'Bearer',
113
+ expires_in: 60 * exp_min
114
+ }
115
+
116
+ response.body = response_body.to_json
117
+ response.headers['Cache-Control'] = 'no-store'
118
+ response.headers['Pragma'] = 'no-cache'
119
+ response.headers['Access-Control-Allow-Origin'] = '*'
120
+ response.content_type = 'application/json'
121
+ response.status = 200
122
+ end
123
+
124
+ def udap_authenticated?(assertion, software_statement)
125
+ signature_error = MockUDAPServer.udap_token_signature_verification(assertion, software_statement)
126
+
127
+ if signature_error.present?
128
+ MockUDAPServer.update_response_for_error(response, signature_error)
129
+ return false
130
+ end
131
+
132
+ true
133
+ end
134
+
135
+ def udap_requested_scope_context(requested_scopes, authorization_code, launch_context)
136
+ context = launch_context.present? ? launch_context : {}
137
+ scopes_list = requested_scopes&.split || []
138
+
139
+ if scopes_list.include?('offline_access') || scopes_list.include?('online_access')
140
+ context[:refresh_token] = MockUDAPServer.authorization_code_to_refresh_token(authorization_code)
141
+ end
142
+
143
+ context[:id_token] = udap_construct_id_token(scopes_list.include?('fhirUser')) if scopes_list.include?('openid')
144
+
145
+ context
146
+ end
147
+
148
+ def udap_construct_id_token(include_fhir_user) # rubocop:disable Metrics/CyclomaticComplexity
149
+ client_id = JSON.parse(result.input_json)&.find do |input|
150
+ input['name'] == 'client_id'
151
+ end&.dig('value')
152
+ fhir_user_relative_reference = JSON.parse(result.input_json)&.find do |input|
153
+ input['name'] == 'fhir_user_relative_reference'
154
+ end&.dig('value')
155
+
156
+ subject_id = if fhir_user_relative_reference.present?
157
+ fhir_user_relative_reference.downcase.gsub('/', '-')
158
+ else
159
+ SecureRandom.uuid
160
+ end
161
+
162
+ claims = {
163
+ iss: client_fhir_base_url,
164
+ sub: subject_id,
165
+ aud: client_id,
166
+ exp: 1.year.from_now.to_i,
167
+ iat: Time.now.to_i
168
+ }
169
+ if include_fhir_user && fhir_user_relative_reference.present?
170
+ claims[:fhirUser] = "#{client_fhir_base_url}/#{fhir_user_relative_reference}"
171
+ end
172
+
173
+ algorithm = 'RS256'
174
+ private_key = OIDCJWKS.jwks
175
+ .select { |key| key[:key_ops]&.include?('sign') }
176
+ .select { |key| key[:alg] == algorithm }
177
+ .first
178
+
179
+ JWT.encode claims, private_key.signing_key, algorithm, { alg: algorithm, kid: private_key.kid, typ: 'JWT' }
180
+ end
181
+
182
+ def udap_pkce_valid?(authorization_code)
183
+ authorization_request = MockUDAPServer.authorization_request_for_code(authorization_code,
184
+ test_run.test_session_id)
185
+ if authorization_request.blank?
186
+ MockUDAPServer.update_response_for_error(
187
+ response,
188
+ "Could not check code_verifier: no authorization request found that returned code #{authorization_code}"
189
+ )
190
+ return false
191
+ end
192
+ auth_code_request_inputs = MockUDAPServer.authorization_code_request_details(authorization_request)
193
+ if auth_code_request_inputs.blank?
194
+ MockUDAPServer.update_response_for_error(
195
+ response,
196
+ "Could not check code_verifier: invalid authorization request details for code #{authorization_code}"
197
+ )
198
+ return false
199
+ end
200
+
201
+ verifier = request.params[:code_verifier]
202
+ challenge = auth_code_request_inputs&.dig('code_challenge')
203
+ method = auth_code_request_inputs&.dig('code_challenge_method')
204
+ MockUDAPServer.pkce_valid?(verifier, challenge, method, response)
205
+ end
206
+
207
+ def udap_registered_scope(software_statement_jwt)
208
+ claims, _headers = begin
209
+ JWT.decode(software_statement_jwt, nil, false)
210
+ rescue StandardError
211
+ return nil
212
+ end
213
+
214
+ claims['scope']
215
+ end
216
+ end
217
+ end
218
+ end