smart_app_launch_test_kit 0.6.0 → 0.6.2

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/config/presets/SMART_RunClientAgainstServer.json.erb +79 -0
  3. data/config/presets/SMART_RunServerAgainstClient_ConfidentialAsymmetric.json.erb +183 -0
  4. data/config/presets/SMART_RunServerAgainstClient_ConfidentialSymmetric.json.erb +157 -0
  5. data/config/presets/SMART_RunServerAgainstClient_Public.json.erb +155 -0
  6. data/lib/smart_app_launch/backend_services_authorization_group.rb +0 -2
  7. data/lib/smart_app_launch/backend_services_authorization_request_success_test.rb +5 -2
  8. data/lib/smart_app_launch/backend_services_authorization_response_body_test.rb +6 -2
  9. data/lib/smart_app_launch/backend_services_invalid_client_assertion_test.rb +1 -1
  10. data/lib/smart_app_launch/backend_services_invalid_jwt_test.rb +1 -1
  11. data/lib/smart_app_launch/client_stu2_2_suite.rb +120 -0
  12. data/lib/smart_app_launch/client_suite/access_alca_interaction_test.rb +75 -0
  13. data/lib/smart_app_launch/client_suite/access_alcs_interaction_test.rb +75 -0
  14. data/lib/smart_app_launch/client_suite/access_alp_interaction_test.rb +75 -0
  15. data/lib/smart_app_launch/client_suite/access_bsca_interaction_test.rb +46 -0
  16. data/lib/smart_app_launch/client_suite/access_group.rb +85 -0
  17. data/lib/smart_app_launch/client_suite/authentication_verification.rb +86 -0
  18. data/lib/smart_app_launch/client_suite/authorization_request_verification_test.rb +108 -0
  19. data/lib/smart_app_launch/client_suite/client_descriptions.rb +114 -0
  20. data/lib/smart_app_launch/client_suite/client_options.rb +35 -0
  21. data/lib/smart_app_launch/client_suite/oidc_jwks.json +32 -0
  22. data/lib/smart_app_launch/client_suite/oidc_jwks.rb +27 -0
  23. data/lib/smart_app_launch/client_suite/registration_alca_group.rb +15 -0
  24. data/lib/smart_app_launch/client_suite/registration_alca_verification_test.rb +57 -0
  25. data/lib/smart_app_launch/client_suite/registration_alcs_group.rb +15 -0
  26. data/lib/smart_app_launch/client_suite/registration_alcs_verification_test.rb +56 -0
  27. data/lib/smart_app_launch/client_suite/registration_alp_group.rb +16 -0
  28. data/lib/smart_app_launch/client_suite/registration_alp_verification_test.rb +50 -0
  29. data/lib/smart_app_launch/client_suite/registration_bsca_group.rb +15 -0
  30. data/lib/smart_app_launch/client_suite/registration_bsca_verification_test.rb +40 -0
  31. data/lib/smart_app_launch/client_suite/registration_verification.rb +58 -0
  32. data/lib/smart_app_launch/client_suite/token_request_alca_verification_test.rb +53 -0
  33. data/lib/smart_app_launch/client_suite/token_request_alcs_verification_test.rb +53 -0
  34. data/lib/smart_app_launch/client_suite/token_request_alp_verification_test.rb +48 -0
  35. data/lib/smart_app_launch/client_suite/token_request_bsca_verification_test.rb +53 -0
  36. data/lib/smart_app_launch/client_suite/token_request_verification.rb +116 -0
  37. data/lib/smart_app_launch/client_suite/token_use_verification_test.rb +40 -0
  38. data/lib/smart_app_launch/docs/demo/FHIR Request.postman_collection.json +81 -0
  39. data/lib/smart_app_launch/docs/smart_stu2_2_client_suite_description.md +208 -0
  40. data/lib/smart_app_launch/endpoints/echoing_fhir_responder_endpoint.rb +96 -0
  41. data/lib/smart_app_launch/endpoints/mock_smart_server/authorization_endpoint.rb +27 -0
  42. data/lib/smart_app_launch/endpoints/mock_smart_server/introspection_endpoint.rb +33 -0
  43. data/lib/smart_app_launch/endpoints/mock_smart_server/smart_authorization_response_creation.rb +30 -0
  44. data/lib/smart_app_launch/endpoints/mock_smart_server/smart_introspection_response_creation.rb +46 -0
  45. data/lib/smart_app_launch/endpoints/mock_smart_server/smart_token_response_creation.rb +250 -0
  46. data/lib/smart_app_launch/endpoints/mock_smart_server/token_endpoint.rb +58 -0
  47. data/lib/smart_app_launch/endpoints/mock_smart_server.rb +278 -0
  48. data/lib/smart_app_launch/metadata.rb +21 -16
  49. data/lib/smart_app_launch/smart_stu2_2_suite.rb +2 -1
  50. data/lib/smart_app_launch/smart_stu2_suite.rb +2 -1
  51. data/lib/smart_app_launch/tags.rb +15 -0
  52. data/lib/smart_app_launch/token_introspection_response_group.rb +1 -1
  53. data/lib/smart_app_launch/token_payload_validation.rb +2 -2
  54. data/lib/smart_app_launch/urls.rb +52 -0
  55. data/lib/smart_app_launch/version.rb +2 -2
  56. data/lib/smart_app_launch_test_kit.rb +1 -0
  57. metadata +45 -2
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+ require_relative '../../tags'
3
+ require_relative '../mock_smart_server'
4
+ require_relative 'smart_introspection_response_creation'
5
+
6
+ module SMARTAppLaunch
7
+ module MockSMARTServer
8
+ class IntrospectionEndpoint < Inferno::DSL::SuiteEndpoint
9
+ include SMARTIntrospectionResponseCreation
10
+
11
+ def test_run_identifier
12
+ MockSMARTServer.issued_token_to_client_id(request.params[:token])
13
+ end
14
+
15
+ def make_response
16
+ response.body = make_smart_introspection_response.to_json
17
+ response.headers['Cache-Control'] = 'no-store'
18
+ response.headers['Pragma'] = 'no-cache'
19
+ response.headers['Access-Control-Allow-Origin'] = '*'
20
+ response.content_type = 'application/json'
21
+ response.status = 200
22
+ end
23
+
24
+ def update_result
25
+ nil # never update for now
26
+ end
27
+
28
+ def tags
29
+ [INTROSPECTION_TAG, SMART_TAG]
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,30 @@
1
+ require_relative '../../tags'
2
+ require_relative '../mock_smart_server'
3
+ require_relative '../../client_suite/oidc_jwks'
4
+
5
+ module SMARTAppLaunch
6
+ module MockSMARTServer
7
+ module SMARTAuthorizationResponseCreation
8
+ def make_smart_authorization_response
9
+ redirect_uri = request.params[:redirect_uri]
10
+ if redirect_uri.blank?
11
+ response.status = 400
12
+ response.body = {
13
+ error: 'Bad request',
14
+ message: 'Missing required redirect_uri parameter.'}.to_json
15
+ response.content_type = 'application/json'
16
+ return
17
+ end
18
+
19
+ client_id = request.params[:client_id]
20
+ state = request.params[:state]
21
+
22
+ exp_min = 10
23
+ token = MockSMARTServer.client_id_to_token(client_id, exp_min)
24
+ query_string = "code=#{ERB::Util.url_encode(token)}&state=#{ERB::Util.url_encode(state)}"
25
+ response.headers['Location'] = "#{redirect_uri}#{redirect_uri.include?('?') ? '&' : '?'}#{query_string}"
26
+ response.status = 302
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,46 @@
1
+ require_relative '../../tags'
2
+ require_relative '../mock_smart_server'
3
+
4
+ module SMARTAppLaunch
5
+ module MockSMARTServer
6
+ module SMARTIntrospectionResponseCreation
7
+ def make_smart_introspection_response
8
+ target_token = request.params[:token]
9
+ introspection_inactive_response_body = { active: false }
10
+
11
+ return introspection_inactive_response_body if MockSMARTServer.token_expired?(target_token)
12
+
13
+ token_requests = Inferno::Repositories::Requests.new.tagged_requests(test_run.test_session_id, [TOKEN_TAG])
14
+ original_response_body = nil
15
+ original_token_request = token_requests.find do |request|
16
+ next unless request.status == 200
17
+
18
+ original_response_body = JSON.parse(request.response_body)
19
+ [original_response_body['access_token'], original_response_body['refresh_token']].include?(target_token)
20
+ end
21
+ return introspection_inactive_response_body unless original_token_request.present?
22
+
23
+ decoded_token = MockSMARTServer.decode_token(target_token)
24
+ introspection_active_response_body = {
25
+ active: true,
26
+ client_id: decoded_token['client_id'],
27
+ exp: decoded_token['expiration']
28
+ }
29
+ original_response_body.each do |element, value|
30
+ next if ['access_token', 'refresh_token', 'token_type', 'expires_in'].include?(element)
31
+ next if introspection_active_response_body.key?(element)
32
+
33
+ introspection_active_response_body[element] = value
34
+ end
35
+ if original_response_body.key?('id_token')
36
+ user_claims, _header = JWT.decode(original_response_body['id_token'], nil, false)
37
+ introspection_active_response_body['iss'] = user_claims['iss']
38
+ introspection_active_response_body['sub'] = user_claims['sub']
39
+ introspection_active_response_body['fhirUser'] = user_claims['fhirUser'] if user_claims['fhirUser'].present?
40
+ end
41
+
42
+ introspection_active_response_body
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,250 @@
1
+ require_relative '../../tags'
2
+ require_relative '../mock_smart_server'
3
+ require_relative '../../client_suite/oidc_jwks'
4
+
5
+ module SMARTAppLaunch
6
+ module MockSMARTServer
7
+ module SMARTTokenResponseCreation
8
+ def make_smart_authorization_code_token_response(smart_authentication_approach)
9
+ authorization_code = request.params[:code]
10
+ client_id = MockSMARTServer.issued_token_to_client_id(authorization_code)
11
+ return unless authenticated?(client_id, smart_authentication_approach)
12
+
13
+ if MockSMARTServer.token_expired?(authorization_code)
14
+ MockSMARTServer.update_response_for_expired_token(response, 'Authorization code')
15
+ return
16
+ end
17
+
18
+ authorization_request = MockSMARTServer.authorization_request_for_code(authorization_code,
19
+ test_run.test_session_id)
20
+ if authorization_request.blank?
21
+ MockSMARTServer.update_response_for_error(
22
+ response,
23
+ "no authorization request found for code #{authorization_code}"
24
+ )
25
+ return
26
+ end
27
+ auth_code_request_inputs = MockSMARTServer.authorization_code_request_details(authorization_request)
28
+ if auth_code_request_inputs.blank?
29
+ MockSMARTServer.update_response_for_error(
30
+ response,
31
+ 'invalid authorization request details'
32
+ )
33
+ return
34
+ end
35
+
36
+ return if request.params[:code_verifier].present? && !smart_pkce_valid?(auth_code_request_inputs)
37
+
38
+ exp_min = 60
39
+ response_body = {
40
+ access_token: MockSMARTServer.client_id_to_token(client_id, exp_min),
41
+ token_type: 'Bearer',
42
+ expires_in: 60 * exp_min,
43
+ scope: auth_code_request_inputs['scope']
44
+ }
45
+
46
+ launch_context =
47
+ begin
48
+ input_string = JSON.parse(result.input_json)&.find do |input|
49
+ input['name'] == 'launch_context'
50
+ end&.dig('value')
51
+ JSON.parse(input_string) if input_string.present?
52
+ rescue JSON::ParserError
53
+ nil
54
+ end
55
+ additional_context = smart_requested_scope_context(auth_code_request_inputs['scope'], authorization_code,
56
+ launch_context)
57
+
58
+ response.body = additional_context.merge(response_body).to_json # response body values take priority
59
+ response.headers['Cache-Control'] = 'no-store'
60
+ response.headers['Pragma'] = 'no-cache'
61
+ response.headers['Access-Control-Allow-Origin'] = '*'
62
+ response.content_type = 'application/json'
63
+ response.status = 200
64
+ end
65
+
66
+ def make_smart_refresh_token_response(smart_authentication_approach)
67
+ refresh_token = request.params[:refresh_token]
68
+ authorization_code = MockSMARTServer.refresh_token_to_authorization_code(refresh_token)
69
+ client_id = MockSMARTServer.issued_token_to_client_id(authorization_code)
70
+ return unless authenticated?(client_id, smart_authentication_approach)
71
+
72
+ # no expiration checks for refresh tokens
73
+
74
+ authorization_request = MockSMARTServer.authorization_request_for_code(authorization_code,
75
+ test_run.test_session_id)
76
+ if authorization_request.blank?
77
+ MockSMARTServer.update_response_for_error(
78
+ response,
79
+ "no authorization request found for refresh token #{refresh_token}"
80
+ )
81
+ return
82
+ end
83
+ auth_code_request_inputs = MockSMARTServer.authorization_code_request_details(authorization_request)
84
+ if auth_code_request_inputs.blank?
85
+ MockSMARTServer.update_response_for_error(
86
+ response,
87
+ 'invalid authorization request details'
88
+ )
89
+ return
90
+ end
91
+
92
+ exp_min = 60
93
+ response_body = {
94
+ access_token: MockSMARTServer.client_id_to_token(client_id, exp_min),
95
+ token_type: 'Bearer',
96
+ expires_in: 60 * exp_min,
97
+ scope: request.params[:scope].present? ? request.params[:scope] : auth_code_request_inputs['scope']
98
+ }
99
+
100
+ launch_context =
101
+ begin
102
+ input_string = JSON.parse(result.input_json)&.find do |input|
103
+ input['name'] == 'launch_context'
104
+ end&.dig('value')
105
+ JSON.parse(input_string) if input_string.present?
106
+ rescue JSON::ParserError
107
+ nil
108
+ end
109
+ additional_context = smart_requested_scope_context(auth_code_request_inputs['scope'], authorization_code,
110
+ launch_context)
111
+
112
+ response.body = additional_context.merge(response_body).to_json # response body values take priority
113
+ response.headers['Cache-Control'] = 'no-store'
114
+ response.headers['Pragma'] = 'no-cache'
115
+ response.headers['Access-Control-Allow-Origin'] = '*'
116
+ response.content_type = 'application/json'
117
+ response.status = 200
118
+ end
119
+
120
+ def make_smart_client_credential_token_response
121
+ assertion = request.params[:client_assertion]
122
+ client_id = MockSMARTServer.client_id_from_client_assertion(assertion)
123
+
124
+ # by loading from DB rather than result inputs don't have to be associated with specific tests
125
+ # e.g., key set input present on registration and auth checks, not during wait tests
126
+ key_set_input = Inferno::Repositories::SessionData.new.load(
127
+ test_session_id: result.test_session_id, name: 'smart_jwk_set'
128
+ )
129
+ return unless confidential_asymmetric_authenticated?(key_set_input)
130
+
131
+ exp_min = 60
132
+ response_body = {
133
+ access_token: MockSMARTServer.client_id_to_token(client_id, exp_min),
134
+ token_type: 'Bearer',
135
+ expires_in: 60 * exp_min,
136
+ scope: request.params[:scope]
137
+ }
138
+
139
+ response.body = response_body.to_json
140
+ response.headers['Cache-Control'] = 'no-store'
141
+ response.headers['Pragma'] = 'no-cache'
142
+ response.headers['Access-Control-Allow-Origin'] = '*'
143
+ response.content_type = 'application/json'
144
+ response.status = 200
145
+ end
146
+
147
+ def smart_requested_scope_context(requested_scopes, authorization_code, launch_context)
148
+ context = launch_context.present? ? launch_context : {}
149
+ scopes_list = requested_scopes&.split || []
150
+
151
+ if scopes_list.include?('offline_access') || scopes_list.include?('online_access')
152
+ context[:refresh_token] = MockSMARTServer.authorization_code_to_refresh_token(authorization_code)
153
+ end
154
+
155
+ context[:id_token] = smart_construct_id_token(scopes_list.include?('fhirUser')) if scopes_list.include?('openid')
156
+
157
+ context
158
+ end
159
+
160
+ def smart_construct_id_token(include_fhir_user)
161
+ client_id = JSON.parse(result.input_json)&.find do |input|
162
+ input['name'] == 'client_id'
163
+ end&.dig('value')
164
+ fhir_user_relative_reference = JSON.parse(result.input_json)&.find do |input|
165
+ input['name'] == 'fhir_user_relative_reference'
166
+ end&.dig('value')
167
+ # TODO: how to generate the id - is this ok?
168
+ subject_id = if fhir_user_relative_reference.present?
169
+ fhir_user_relative_reference.downcase.gsub('/', '-')
170
+ else
171
+ SecureRandom.uuid
172
+ end
173
+
174
+ claims = {
175
+ iss: client_fhir_base_url,
176
+ sub: subject_id,
177
+ aud: client_id,
178
+ exp: 1.year.from_now.to_i,
179
+ iat: Time.now.to_i
180
+ }
181
+ if include_fhir_user && fhir_user_relative_reference.present?
182
+ claims[:fhirUser] = "#{client_fhir_base_url}/#{fhir_user_relative_reference}"
183
+ end
184
+
185
+ algorithm = 'RS256'
186
+ private_key = OIDCJWKS.jwks
187
+ .select { |key| key[:key_ops]&.include?('sign') }
188
+ .select { |key| key[:alg] == algorithm }
189
+ .first
190
+
191
+ JWT.encode claims, private_key.signing_key, algorithm, { alg: algorithm, kid: private_key.kid, typ: 'JWT' }
192
+ end
193
+
194
+ def smart_pkce_valid?(auth_code_request_inputs)
195
+ verifier = request.params[:code_verifier]
196
+ challenge = auth_code_request_inputs&.dig('code_challenge')
197
+ method = auth_code_request_inputs&.dig('code_challenge_method')
198
+ MockSMARTServer.pkce_valid?(verifier, challenge, method, response)
199
+ end
200
+
201
+ def authenticated?(client_id, smart_authentication_approach)
202
+ case smart_authentication_approach
203
+ when CONFIDENTIAL_ASYMMETRIC_TAG
204
+ key_set_input = Inferno::Repositories::SessionData.new.load(
205
+ test_session_id: result.test_session_id, name: 'smart_jwk_set'
206
+ )
207
+ return confidential_asymmetric_authenticated?(key_set_input)
208
+ when CONFIDENTIAL_SYMMETRIC_TAG
209
+ client_secret_input = Inferno::Repositories::SessionData.new.load(
210
+ test_session_id: result.test_session_id, name: 'smart_client_secret'
211
+ )
212
+ return confidential_symmetric_authenticated?(client_id, client_secret_input)
213
+ when PUBLIC_TAG
214
+ return true
215
+ end
216
+ end
217
+
218
+ def confidential_asymmetric_authenticated?(jwks)
219
+ assertion = request.params[:client_assertion]
220
+ if assertion.blank?
221
+ MockSMARTServer.update_response_for_error(
222
+ response,
223
+ 'client_assertion missing from confidential asymmetric client request'
224
+ )
225
+ return false
226
+ end
227
+
228
+ signature_error = MockSMARTServer.smart_assertion_signature_verification(assertion, jwks)
229
+
230
+ if signature_error.present?
231
+ MockSMARTServer.update_response_for_error(response, signature_error)
232
+ return false
233
+ end
234
+
235
+ true
236
+ end
237
+
238
+ def confidential_symmetric_authenticated?(client_id, client_secret)
239
+ auth_header_value = request.headers['authorization']
240
+ error = MockSMARTServer.confidential_symmetric_header_value_error(auth_header_value, client_id, client_secret)
241
+ if error.present?
242
+ MockSMARTServer.update_response_for_error(response, error)
243
+ return false
244
+ end
245
+
246
+ true
247
+ end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+ require_relative '../../tags'
3
+ require_relative '../mock_smart_server'
4
+ require_relative 'smart_token_response_creation'
5
+
6
+ module SMARTAppLaunch
7
+ module MockSMARTServer
8
+ class TokenEndpoint < Inferno::DSL::SuiteEndpoint
9
+ include URLs
10
+ include SMARTTokenResponseCreation
11
+
12
+ def test_run_identifier
13
+ case request.params[:grant_type]
14
+ when CLIENT_CREDENTIALS_TAG
15
+ MockSMARTServer.client_id_from_client_assertion(request.params[:client_assertion])
16
+ when AUTHORIZATION_CODE_TAG
17
+ MockSMARTServer.issued_token_to_client_id(request.params[:code])
18
+ when REFRESH_TOKEN_TAG
19
+ MockSMARTServer.issued_token_to_client_id(
20
+ MockSMARTServer.refresh_token_to_authorization_code(request.params[:refresh_token])
21
+ )
22
+ end
23
+ end
24
+
25
+ def make_response
26
+ suite_options_list = Inferno::Repositories::TestSessions.new.find(result.test_session_id)&.suite_options
27
+ suite_options_hash = suite_options_list&.map { |option| [option.id, option.value] }&.to_h
28
+ smart_authentication_approach = SMARTClientOptions.smart_authentication_approach(suite_options_hash)
29
+
30
+ case request.params[:grant_type]
31
+ when CLIENT_CREDENTIALS_TAG
32
+ make_smart_client_credential_token_response
33
+ when AUTHORIZATION_CODE_TAG
34
+ make_smart_authorization_code_token_response(smart_authentication_approach)
35
+ when REFRESH_TOKEN_TAG
36
+ make_smart_refresh_token_response(smart_authentication_approach)
37
+ else
38
+ MockSMARTServer.update_response_for_error(
39
+ response,
40
+ "unsupported grant_type: #{request.params[:grant_type]}"
41
+ )
42
+ end
43
+ end
44
+
45
+ def update_result
46
+ nil # never update for now
47
+ end
48
+
49
+ def tags
50
+ tags = [TOKEN_TAG, SMART_TAG]
51
+ if [CLIENT_CREDENTIALS_TAG, AUTHORIZATION_CODE_TAG, REFRESH_TOKEN_TAG].include?(request.params[:grant_type])
52
+ tags << request.params[:grant_type]
53
+ end
54
+ tags
55
+ end
56
+ end
57
+ end
58
+ end