smart_app_launch_test_kit 0.6.1 → 0.6.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/config/presets/SMART_RunClientAgainstServer.json.erb +57 -9
  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_invalid_client_assertion_test.rb +1 -1
  7. data/lib/smart_app_launch/backend_services_invalid_jwt_test.rb +1 -1
  8. data/lib/smart_app_launch/client_stu2_2_suite.rb +60 -19
  9. data/lib/smart_app_launch/client_suite/access_alca_interaction_test.rb +75 -0
  10. data/lib/smart_app_launch/client_suite/access_alcs_interaction_test.rb +75 -0
  11. data/lib/smart_app_launch/client_suite/access_alp_interaction_test.rb +75 -0
  12. data/lib/smart_app_launch/client_suite/access_bsca_interaction_test.rb +46 -0
  13. data/lib/smart_app_launch/client_suite/access_group.rb +85 -0
  14. data/lib/smart_app_launch/client_suite/authentication_verification.rb +86 -0
  15. data/lib/smart_app_launch/client_suite/authorization_request_verification_test.rb +108 -0
  16. data/lib/smart_app_launch/client_suite/client_descriptions.rb +114 -0
  17. data/lib/smart_app_launch/client_suite/client_options.rb +35 -0
  18. data/lib/smart_app_launch/client_suite/oidc_jwks.json +32 -0
  19. data/lib/smart_app_launch/client_suite/oidc_jwks.rb +27 -0
  20. data/lib/smart_app_launch/client_suite/registration_alca_group.rb +15 -0
  21. data/lib/smart_app_launch/client_suite/registration_alca_verification_test.rb +57 -0
  22. data/lib/smart_app_launch/client_suite/registration_alcs_group.rb +15 -0
  23. data/lib/smart_app_launch/client_suite/registration_alcs_verification_test.rb +56 -0
  24. data/lib/smart_app_launch/client_suite/registration_alp_group.rb +16 -0
  25. data/lib/smart_app_launch/client_suite/registration_alp_verification_test.rb +50 -0
  26. data/lib/smart_app_launch/client_suite/registration_bsca_group.rb +15 -0
  27. data/lib/smart_app_launch/client_suite/registration_bsca_verification_test.rb +40 -0
  28. data/lib/smart_app_launch/client_suite/registration_verification.rb +58 -0
  29. data/lib/smart_app_launch/client_suite/token_request_alca_verification_test.rb +53 -0
  30. data/lib/smart_app_launch/client_suite/token_request_alcs_verification_test.rb +53 -0
  31. data/lib/smart_app_launch/client_suite/token_request_alp_verification_test.rb +48 -0
  32. data/lib/smart_app_launch/client_suite/token_request_bsca_verification_test.rb +53 -0
  33. data/lib/smart_app_launch/client_suite/token_request_verification.rb +116 -0
  34. data/lib/smart_app_launch/client_suite/{client_token_use_verification_test.rb → token_use_verification_test.rb} +1 -8
  35. data/lib/smart_app_launch/docs/smart_stu2_2_client_suite_description.md +128 -41
  36. data/lib/smart_app_launch/endpoints/echoing_fhir_responder_endpoint.rb +96 -0
  37. data/lib/smart_app_launch/endpoints/mock_smart_server/authorization_endpoint.rb +27 -0
  38. data/lib/smart_app_launch/endpoints/mock_smart_server/introspection_endpoint.rb +33 -0
  39. data/lib/smart_app_launch/endpoints/mock_smart_server/smart_authorization_response_creation.rb +30 -0
  40. data/lib/smart_app_launch/endpoints/mock_smart_server/smart_introspection_response_creation.rb +46 -0
  41. data/lib/smart_app_launch/endpoints/mock_smart_server/smart_token_response_creation.rb +250 -0
  42. data/lib/smart_app_launch/endpoints/mock_smart_server/token_endpoint.rb +58 -0
  43. data/lib/smart_app_launch/endpoints/mock_smart_server.rb +130 -69
  44. data/lib/smart_app_launch/metadata.rb +19 -14
  45. data/lib/smart_app_launch/tags.rb +9 -1
  46. data/lib/smart_app_launch/token_payload_validation.rb +2 -2
  47. data/lib/smart_app_launch/urls.rb +12 -0
  48. data/lib/smart_app_launch/version.rb +2 -2
  49. metadata +38 -11
  50. data/config/presets/SMART_RunServerAgainstClient.json.erb +0 -42
  51. data/lib/smart_app_launch/client_suite/client_access_group.rb +0 -26
  52. data/lib/smart_app_launch/client_suite/client_access_interaction_test.rb +0 -64
  53. data/lib/smart_app_launch/client_suite/client_registration_group.rb +0 -15
  54. data/lib/smart_app_launch/client_suite/client_registration_verification_test.rb +0 -52
  55. data/lib/smart_app_launch/client_suite/client_token_request_verification_test.rb +0 -146
  56. data/lib/smart_app_launch/endpoints/echoing_fhir_responder.rb +0 -52
  57. data/lib/smart_app_launch/endpoints/mock_smart_server/token.rb +0 -27
@@ -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
+ return make_smart_client_credential_token_response if request.params[:grant_type] == CLIENT_CREDENTIALS_TAG
27
+
28
+ suite_options_list = Inferno::Repositories::TestSessions.new.find(result.test_session_id)&.suite_options
29
+ suite_options_hash = suite_options_list&.map { |option| [option.id, option.value] }&.to_h
30
+ smart_authentication_approach = SMARTClientOptions.smart_authentication_approach(suite_options_hash)
31
+
32
+ case request.params[:grant_type]
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
@@ -1,12 +1,15 @@
1
1
  require 'jwt'
2
2
  require 'faraday'
3
3
  require 'time'
4
+ require 'base64'
5
+ require 'rack/utils'
4
6
  require_relative '../urls'
5
7
  require_relative '../tags'
8
+ require_relative '../client_suite/client_options'
6
9
 
7
10
  module SMARTAppLaunch
8
11
  module MockSMARTServer
9
- SUPPORTED_SCOPES = ['openid', 'system/*.read', 'user/*.read', 'patient/*.read'].freeze
12
+ SUPPORTED_SCOPES = ['system/*.read', 'user/*.read', 'patient/*.read'].freeze
10
13
 
11
14
  module_function
12
15
 
@@ -14,81 +17,45 @@ module SMARTAppLaunch
14
17
  base_url = "#{Inferno::Application['base_url']}/custom/#{suite_id}"
15
18
  response_body = {
16
19
  token_endpoint_auth_signing_alg_values_supported: ['RS384', 'ES384'],
17
- capabilities: ['client-confidential-asymmetric'],
20
+ capabilities: ['client-confidential-asymmetric', 'launch-ehr' ,'launch-standalone', 'authorize-post',
21
+ 'client-public', 'client-confidential-symmetric', 'permission-offline', 'permission-online',
22
+ 'permission-patient', 'permission-user', 'permission-v1', 'permission-v2',
23
+ 'context-ehr-patient', 'context-ehr-encounter',
24
+ 'context-standalone-patient', 'context-standalone-encounter',
25
+ 'context-banner', 'context-style'],
18
26
  code_challenge_methods_supported: ['S256'],
19
- token_endpoint_auth_methods_supported: ['private_key_jwt'],
27
+ token_endpoint_auth_methods_supported: ['private_key_jwt', 'client_secret_basic', 'client_secret_post'],
20
28
  issuer: base_url + FHIR_PATH,
21
- grant_types_supported: ['client_credentials'],
29
+ grant_types_supported: ['client_credentials', 'authorization_code'],
22
30
  scopes_supported: SUPPORTED_SCOPES,
23
- token_endpoint: base_url + TOKEN_PATH
31
+ authorization_endpoint: base_url + AUTHORIZATION_PATH,
32
+ token_endpoint: base_url + TOKEN_PATH,
33
+ introspection_endpoint: base_url + INTROSPECTION_PATH
24
34
  }.to_json
25
35
 
26
36
  [200, { 'Content-Type' => 'application/json', 'Access-Control-Allow-Origin' => '*' }, [response_body]]
27
37
  end
28
38
 
29
- def make_smart_token_response(request, response, result)
30
- assertion = request.params[:client_assertion]
31
- client_id = client_id_from_client_assertion(assertion)
32
-
33
- key_set_input = JSON.parse(result.input_json)&.find do |input|
34
- input['name'] == 'smart_jwk_set'
35
- end&.dig('value')
36
- signature_error = smart_assertion_signature_verification(assertion, key_set_input)
37
-
38
- if signature_error.present?
39
- update_response_for_invalid_assertion(response, signature_error)
40
- return
41
- end
42
-
43
- exp_min = 60
39
+ def openid_connect_metadata(suite_id)
40
+ base_url = "#{Inferno::Application['base_url']}/custom/#{suite_id}"
44
41
  response_body = {
45
- access_token: client_id_to_token(client_id, exp_min),
46
- token_type: 'Bearer',
47
- expires_in: 60 * exp_min,
48
- scope: request.params[:scope]
49
- }
42
+ issuer: base_url + FHIR_PATH,
43
+ authorization_endpoint: base_url + AUTHORIZATION_PATH,
44
+ token_endpoint: base_url + TOKEN_PATH,
45
+ jwks_uri: base_url + OIDC_JWKS_PATH,
46
+ response_types_supported: ['code', 'id_token', 'token id_token'],
47
+ subject_types_supported: ['pairwise', 'public'],
48
+ id_token_signing_alg_values_supported: ['RS256']
49
+ }.to_json
50
50
 
51
- response.body = response_body.to_json
52
- response.headers['Cache-Control'] = 'no-store'
53
- response.headers['Pragma'] = 'no-cache'
54
- response.headers['Access-Control-Allow-Origin'] = '*'
55
- response.content_type = 'application/json'
56
- response.status = 200
51
+ [200, { 'Content-Type' => 'application/json', 'Access-Control-Allow-Origin' => '*' }, [response_body]]
57
52
  end
58
53
 
59
54
  def client_id_from_client_assertion(client_assertion_jwt)
60
55
  return unless client_assertion_jwt.present?
61
56
 
62
- jwt_claims(client_assertion_jwt)&.dig('iss')
63
- end
64
-
65
- def parsed_request_body(request)
66
- JSON.parse(request.request_body)
67
- rescue JSON::ParserError
68
- nil
69
- end
70
-
71
- def parsed_io_body(request)
72
- parsed_body = begin
73
- JSON.parse(request.body.read)
74
- rescue JSON::ParserError
75
- nil
76
- end
77
- request.body.rewind
78
-
79
- parsed_body
80
- end
81
-
82
- def jwt_claims(encoded_jwt)
83
- JWT.decode(encoded_jwt, nil, false)[0]
84
- end
85
-
86
- def client_uri_to_client_id(client_uri)
87
- Base64.urlsafe_encode64(client_uri, padding: false)
88
- end
89
-
90
- def client_id_to_client_uri(client_id)
91
- Base64.urlsafe_decode64(client_id)
57
+ claims, _header = JWT.decode(client_assertion_jwt, nil, false)[0]
58
+ claims&.dig('iss')
92
59
  end
93
60
 
94
61
  def client_id_to_token(client_id, exp_min)
@@ -102,15 +69,35 @@ module SMARTAppLaunch
102
69
  end
103
70
 
104
71
  def decode_token(token)
105
- JSON.parse(Base64.urlsafe_decode64(token))
106
- rescue JSON::ParserError
72
+ token_to_decode =
73
+ if issued_token_is_refresh_token(token)
74
+ refresh_token_to_authorization_code(token)
75
+ else
76
+ token
77
+ end
78
+ return unless token_to_decode.present?
79
+
80
+ JSON.parse(Base64.urlsafe_decode64(token_to_decode))
81
+ rescue StandardError
107
82
  nil
108
83
  end
109
84
 
110
- def token_to_client_id(token)
85
+ def issued_token_to_client_id(token)
111
86
  decode_token(token)&.dig('client_id')
112
87
  end
113
88
 
89
+ def issued_token_is_refresh_token(token)
90
+ token.end_with?('_rt')
91
+ end
92
+
93
+ def authorization_code_to_refresh_token(code)
94
+ "#{code}_rt"
95
+ end
96
+
97
+ def refresh_token_to_authorization_code(refresh_token)
98
+ refresh_token[..-4]
99
+ end
100
+
114
101
  def jwk_set(jku, warning_messages = []) # rubocop:disable Metrics/CyclomaticComplexity
115
102
  jwk_set = JWT::JWK::Set.new
116
103
 
@@ -132,7 +119,7 @@ module SMARTAppLaunch
132
119
  begin
133
120
  JSON.parse(retrieved.body)
134
121
  rescue JSON::ParserError
135
- warning_messages << "Failed to fetch valid json from jwks uri #{jwk_set}."
122
+ warning_messages << "Failed to fetch valid json from jwks uri #{jku}."
136
123
  nil
137
124
  end
138
125
  else
@@ -160,18 +147,23 @@ module SMARTAppLaunch
160
147
  return false if request.params[:session_path].present?
161
148
 
162
149
  token = request.headers['authorization']&.delete_prefix('Bearer ')
150
+ token_expired?(token)
151
+ end
152
+
153
+ def token_expired?(token, check_time = nil)
163
154
  decoded_token = decode_token(token)
164
155
  return false unless decoded_token&.dig('expiration').present?
165
156
 
166
- decoded_token['expiration'] < Time.now.to_i
157
+ check_time = Time.now.to_i unless check_time.present?
158
+ decoded_token['expiration'] < check_time
167
159
  end
168
160
 
169
- def update_response_for_expired_token(response)
161
+ def update_response_for_expired_token(response, type)
170
162
  response.status = 401
171
163
  response.format = :json
172
164
  response.body = FHIR::OperationOutcome.new(
173
165
  issue: FHIR::OperationOutcome::Issue.new(severity: 'fatal', code: 'expired',
174
- details: FHIR::CodeableConcept.new(text: 'Bearer token has expired'))
166
+ details: FHIR::CodeableConcept.new(text: "#{type} has expired"))
175
167
  ).to_json
176
168
  end
177
169
 
@@ -208,10 +200,79 @@ module SMARTAppLaunch
208
200
  parsed_key_set&.find { |key| key.kid == kid }
209
201
  end
210
202
 
211
- def update_response_for_invalid_assertion(response, error_message)
203
+ def update_response_for_error(response, error_message)
212
204
  response.status = 401
213
205
  response.format = :json
214
206
  response.body = { error: 'invalid_client', error_description: error_message }.to_json
215
207
  end
208
+
209
+ def confidential_symmetric_header_value_error(authorization_header_value, client_id, client_secret)
210
+ unless authorization_header_value.present?
211
+ return 'authorization header missing from confidential symmetric client request'
212
+ end
213
+ unless authorization_header_value.start_with?('Basic ')
214
+ return 'authorization header for confidential symmetric client request does not use Basic auth'
215
+ end
216
+
217
+ client_and_secret =
218
+ begin
219
+ Base64.strict_decode64(authorization_header_value.delete_prefix('Basic '))
220
+ rescue
221
+ return 'Basic authorization header could not be decoded'
222
+ end
223
+ expected_client_and_secret = "#{client_id}:#{client_secret}"
224
+ unless client_and_secret == expected_client_and_secret
225
+ return 'basic authorization header has the wrong decoded value - ' \
226
+ "expected '#{expected_client_and_secret}', got '#{client_and_secret}'"
227
+ end
228
+
229
+ nil
230
+ end
231
+
232
+ def pkce_error(verifier, challenge, method)
233
+ if verifier.blank?
234
+ 'pkce check failed: no verifier provided'
235
+ elsif challenge.blank?
236
+ 'pkce check failed: no challenge code provided'
237
+ elsif method == 'S256'
238
+ return nil unless challenge != AppRedirectTest.calculate_s256_challenge(verifier)
239
+
240
+ "invalid S256 pkce verifier: got '#{AppRedirectTest.calculate_s256_challenge(verifier)}' " \
241
+ "expected '#{challenge}'"
242
+ else
243
+ "invalid pkce challenge method '#{method}'"
244
+ end
245
+ end
246
+
247
+ def pkce_valid?(verifier, challenge, method, response)
248
+ pkce_error = pkce_error(verifier, challenge, method)
249
+
250
+ if pkce_error.present?
251
+ update_response_for_error(response, pkce_error)
252
+ false
253
+ else
254
+ true
255
+ end
256
+ end
257
+
258
+ def authorization_request_for_code(code, test_session_id)
259
+ authorization_requests = Inferno::Repositories::Requests.new.tagged_requests(test_session_id, [AUTHORIZATION_TAG])
260
+ authorization_requests.find do |request|
261
+ location_header = request.response_headers.find { |header| header.name.downcase == 'location' }
262
+ if location_header.present? && location_header.value.present?
263
+ Rack::Utils.parse_query(URI(location_header.value)&.query)&.dig('code') == code
264
+ else
265
+ false
266
+ end
267
+ end
268
+ end
269
+
270
+ def authorization_code_request_details(inferno_request)
271
+ if inferno_request.verb.downcase == 'get'
272
+ Rack::Utils.parse_query(URI(inferno_request.url)&.query)
273
+ elsif inferno_request.verb.downcase == 'post'
274
+ Rack::Utils.parse_query(inferno_request.request_body)
275
+ end
276
+ end
216
277
  end
217
278
  end