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.
- checksums.yaml +4 -4
- data/config/presets/SMART_RunClientAgainstServer.json.erb +57 -9
- data/config/presets/SMART_RunServerAgainstClient_ConfidentialAsymmetric.json.erb +183 -0
- data/config/presets/SMART_RunServerAgainstClient_ConfidentialSymmetric.json.erb +157 -0
- data/config/presets/SMART_RunServerAgainstClient_Public.json.erb +155 -0
- data/lib/smart_app_launch/backend_services_invalid_client_assertion_test.rb +1 -1
- data/lib/smart_app_launch/backend_services_invalid_jwt_test.rb +1 -1
- data/lib/smart_app_launch/client_stu2_2_suite.rb +60 -19
- data/lib/smart_app_launch/client_suite/access_alca_interaction_test.rb +75 -0
- data/lib/smart_app_launch/client_suite/access_alcs_interaction_test.rb +75 -0
- data/lib/smart_app_launch/client_suite/access_alp_interaction_test.rb +75 -0
- data/lib/smart_app_launch/client_suite/access_bsca_interaction_test.rb +46 -0
- data/lib/smart_app_launch/client_suite/access_group.rb +85 -0
- data/lib/smart_app_launch/client_suite/authentication_verification.rb +86 -0
- data/lib/smart_app_launch/client_suite/authorization_request_verification_test.rb +108 -0
- data/lib/smart_app_launch/client_suite/client_descriptions.rb +114 -0
- data/lib/smart_app_launch/client_suite/client_options.rb +35 -0
- data/lib/smart_app_launch/client_suite/oidc_jwks.json +32 -0
- data/lib/smart_app_launch/client_suite/oidc_jwks.rb +27 -0
- data/lib/smart_app_launch/client_suite/registration_alca_group.rb +15 -0
- data/lib/smart_app_launch/client_suite/registration_alca_verification_test.rb +57 -0
- data/lib/smart_app_launch/client_suite/registration_alcs_group.rb +15 -0
- data/lib/smart_app_launch/client_suite/registration_alcs_verification_test.rb +56 -0
- data/lib/smart_app_launch/client_suite/registration_alp_group.rb +16 -0
- data/lib/smart_app_launch/client_suite/registration_alp_verification_test.rb +50 -0
- data/lib/smart_app_launch/client_suite/registration_bsca_group.rb +15 -0
- data/lib/smart_app_launch/client_suite/registration_bsca_verification_test.rb +40 -0
- data/lib/smart_app_launch/client_suite/registration_verification.rb +58 -0
- data/lib/smart_app_launch/client_suite/token_request_alca_verification_test.rb +53 -0
- data/lib/smart_app_launch/client_suite/token_request_alcs_verification_test.rb +53 -0
- data/lib/smart_app_launch/client_suite/token_request_alp_verification_test.rb +48 -0
- data/lib/smart_app_launch/client_suite/token_request_bsca_verification_test.rb +53 -0
- data/lib/smart_app_launch/client_suite/token_request_verification.rb +116 -0
- data/lib/smart_app_launch/client_suite/{client_token_use_verification_test.rb → token_use_verification_test.rb} +1 -8
- data/lib/smart_app_launch/docs/smart_stu2_2_client_suite_description.md +128 -41
- data/lib/smart_app_launch/endpoints/echoing_fhir_responder_endpoint.rb +96 -0
- data/lib/smart_app_launch/endpoints/mock_smart_server/authorization_endpoint.rb +27 -0
- data/lib/smart_app_launch/endpoints/mock_smart_server/introspection_endpoint.rb +33 -0
- data/lib/smart_app_launch/endpoints/mock_smart_server/smart_authorization_response_creation.rb +30 -0
- data/lib/smart_app_launch/endpoints/mock_smart_server/smart_introspection_response_creation.rb +46 -0
- data/lib/smart_app_launch/endpoints/mock_smart_server/smart_token_response_creation.rb +250 -0
- data/lib/smart_app_launch/endpoints/mock_smart_server/token_endpoint.rb +58 -0
- data/lib/smart_app_launch/endpoints/mock_smart_server.rb +130 -69
- data/lib/smart_app_launch/metadata.rb +19 -14
- data/lib/smart_app_launch/tags.rb +9 -1
- data/lib/smart_app_launch/token_payload_validation.rb +2 -2
- data/lib/smart_app_launch/urls.rb +12 -0
- data/lib/smart_app_launch/version.rb +2 -2
- metadata +38 -11
- data/config/presets/SMART_RunServerAgainstClient.json.erb +0 -42
- data/lib/smart_app_launch/client_suite/client_access_group.rb +0 -26
- data/lib/smart_app_launch/client_suite/client_access_interaction_test.rb +0 -64
- data/lib/smart_app_launch/client_suite/client_registration_group.rb +0 -15
- data/lib/smart_app_launch/client_suite/client_registration_verification_test.rb +0 -52
- data/lib/smart_app_launch/client_suite/client_token_request_verification_test.rb +0 -146
- data/lib/smart_app_launch/endpoints/echoing_fhir_responder.rb +0 -52
- data/lib/smart_app_launch/endpoints/mock_smart_server/token.rb +0 -27
data/lib/smart_app_launch/endpoints/mock_smart_server/smart_authorization_response_creation.rb
ADDED
@@ -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
|
data/lib/smart_app_launch/endpoints/mock_smart_server/smart_introspection_response_creation.rb
ADDED
@@ -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 = ['
|
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
|
-
|
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
|
30
|
-
|
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
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
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
|
-
|
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
|
-
|
63
|
-
|
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
|
-
|
106
|
-
|
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
|
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 #{
|
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
|
-
|
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:
|
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
|
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
|