Module: UDAPSecurityTestKit::MockUDAPServer

Defined in:
lib/udap_security_test_kit/endpoints/mock_udap_server.rb,
lib/udap_security_test_kit/endpoints/mock_udap_server/token_endpoint.rb,
lib/udap_security_test_kit/endpoints/mock_udap_server/registration_endpoint.rb,
lib/udap_security_test_kit/endpoints/mock_udap_server/authorization_endpoint.rb,
lib/udap_security_test_kit/endpoints/mock_udap_server/introspection_endpoint.rb,
lib/udap_security_test_kit/endpoints/mock_udap_server/udap_token_response_creation.rb,
lib/udap_security_test_kit/endpoints/mock_udap_server/udap_registration_response_creation.rb,
lib/udap_security_test_kit/endpoints/mock_udap_server/udap_authorization_response_creation.rb,
lib/udap_security_test_kit/endpoints/mock_udap_server/udap_introspection_response_creation.rb

Defined Under Namespace

Modules: UDAPAuthorizationResponseCreation, UDAPIntrospectionResponseCreation, UDAPRegistrationResponseCreation, UDAPTokenResponseCreation Classes: AuthorizationEndpoint, IntrospectionEndpoint, RegistrationEndpoint, TokenEndpoint

Constant Summary collapse

SUPPORTED_SCOPES =
['openid', 'system/*.read', 'user/*.read', 'patient/*.read'].freeze

Class Method Summary collapse

Class Method Details

.authorization_code_request_details(inferno_request) ⇒ Object



375
376
377
378
379
380
381
382
383
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 375

def authorization_code_request_details(inferno_request)
  return unless inferno_request.present?

  if inferno_request.verb.downcase == 'get'
    Rack::Utils.parse_query(URI(inferno_request.url)&.query)
  elsif inferno_request.verb.downcase == 'post'
    Rack::Utils.parse_query(inferno_request.request_body)
  end
end

.authorization_code_to_refresh_token(code) ⇒ Object



182
183
184
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 182

def authorization_code_to_refresh_token(code)
  "#{code}_rt"
end

.authorization_request_for_code(code, test_session_id) ⇒ Object



363
364
365
366
367
368
369
370
371
372
373
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 363

def authorization_request_for_code(code, test_session_id)
  authorization_requests = Inferno::Repositories::Requests.new.tagged_requests(test_session_id, [AUTHORIZATION_TAG])
  authorization_requests.find do |request|
    location_header = request.response_headers.find { |header| header.name.downcase == 'location' }
    if location_header.present? && location_header.value.present?
      Rack::Utils.parse_query(URI(location_header.value)&.query)&.dig('code') == code
    else
      false
    end
  end
end

.calculate_s256_challenge(verifier) ⇒ Object



359
360
361
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 359

def calculate_s256_challenge(verifier)
  Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
end

.check_jwt_timing(issue_claim, expiration_claim, request_time) ⇒ Object

rubocop:disable Metrics/CyclomaticComplexity



291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 291

def check_jwt_timing(issue_claim, expiration_claim, request_time) # rubocop:disable Metrics/CyclomaticComplexity
  add_message('error', 'Registration software statement `iat` claim is missing') unless issue_claim.present?
  add_message('error', 'Registration software statement `exp` claim is missing') unless expiration_claim.present?
  return unless issue_claim.present? && expiration_claim.present?

  unless issue_claim.is_a?(Numeric)
    add_message('error',
                "Registration software statement `iat` claim is invalid: expected a number, got '#{issue_claim}'")
  end
  unless expiration_claim.is_a?(Numeric)
    add_message('error',
                'Registration software statement `exp` claim is invalid: ' \
                "expected a number, got '#{expiration_claim}'")
  end
  return unless issue_claim.is_a?(Numeric) && expiration_claim.is_a?(Numeric)

  issue_time = Time.at(issue_claim)
  expiration_time = Time.at(expiration_claim)
  unless expiration_time > issue_time
    add_message('error',
                'Registration software statement `exp` claim is invalid: ' \
                'cannot be before the `iat` claim.')
  end
  unless expiration_time <= issue_time + 5.minutes
    add_message('error',
                'Registration software statement `exp` claim is invalid: ' \
                'cannot be more than 5 minutes after the `iat` claim.')
  end
  unless issue_time <= request_time
    add_message('error',
                'Registration software statement `iat` claim is invalid: ' \
                'cannot be after the request time.')
  end
  unless expiration_time > request_time
    add_message('error',
                'Registration software statement `exp` claim is invalid: ' \
                'it has expired.')
  end

  nil
end

.client_id_from_client_assertion(client_assertion_jwt) ⇒ Object



285
286
287
288
289
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 285

def client_id_from_client_assertion(client_assertion_jwt)
  return unless client_assertion_jwt.present?

  jwt_claims(client_assertion_jwt)&.dig('iss')
end

.client_id_to_client_uri(client_id) ⇒ Object



146
147
148
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 146

def client_id_to_client_uri(client_id)
  Base64.urlsafe_decode64(client_id)
end

.client_id_to_token(client_id, exp_min) ⇒ Object



150
151
152
153
154
155
156
157
158
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 150

def client_id_to_token(client_id, exp_min)
  token_structure = {
    client_id:,
    expiration: exp_min.minutes.from_now.to_i,
    nonce: SecureRandom.hex(8)
  }.to_json

  Base64.urlsafe_encode64(token_structure, padding: false)
end

.client_uri_to_client_id(client_uri) ⇒ Object



142
143
144
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 142

def client_uri_to_client_id(client_uri)
  Base64.urlsafe_encode64(client_uri, padding: false)
end

.decode_token(token) ⇒ Object



160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 160

def decode_token(token)
  token_to_decode =
    if issued_token_is_refresh_token?(token)
      refresh_token_to_authorization_code(token)
    else
      token
    end
  return unless token_to_decode.present?

  JSON.parse(Base64.urlsafe_decode64(token_to_decode))
rescue StandardError
  nil
end

.issued_token_is_refresh_token?(token) ⇒ Boolean

Returns:

  • (Boolean)


178
179
180
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 178

def issued_token_is_refresh_token?(token)
  token.end_with?('_rt')
end

.issued_token_to_client_id(token) ⇒ Object



174
175
176
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 174

def issued_token_to_client_id(token)
  decode_token(token)&.dig('client_id')
end

.jwt_claims(encoded_jwt) ⇒ Object



123
124
125
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 123

def jwt_claims(encoded_jwt)
  JWT.decode(encoded_jwt, nil, false)[0]
end

.openid_connect_metadata(suite_id) ⇒ Object



39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 39

def (suite_id)
  base_url = "#{Inferno::Application['base_url']}/custom/#{suite_id}"
  response_body = {
    issuer: base_url + FHIR_PATH,
    authorization_endpoint: base_url + AUTHORIZATION_PATH,
    token_endpoint: base_url + TOKEN_PATH,
    jwks_uri: base_url + OIDC_JWKS_PATH,
    response_types_supported: ['code', 'id_token', 'token id_token'],
    subject_types_supported: ['pairwise', 'public'],
    id_token_signing_alg_values_supported: ['RS256']
  }.to_json

  [200, { 'Content-Type' => 'application/json', 'Access-Control-Allow-Origin' => '*' }, [response_body]]
end

.parsed_io_body(request) ⇒ Object



112
113
114
115
116
117
118
119
120
121
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 112

def parsed_io_body(request)
  parsed_body = begin
    JSON.parse(request.body.read)
  rescue JSON::ParserError
    nil
  end
  request.body.rewind

  parsed_body
end

.parsed_request_body(request) ⇒ Object



106
107
108
109
110
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 106

def parsed_request_body(request)
  JSON.parse(request.request_body)
rescue JSON::ParserError
  nil
end

.pkce_error(verifier, challenge, method) ⇒ Object



333
334
335
336
337
338
339
340
341
342
343
344
345
346
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 333

def pkce_error(verifier, challenge, method)
  if verifier.blank?
    'pkce check failed: no verifier provided'
  elsif challenge.blank?
    'pkce check failed: no challenge code provided'
  elsif method == 'S256'
    return nil unless challenge != calculate_s256_challenge(verifier)

    "invalid S256 pkce verifier: got '#{calculate_s256_challenge(verifier)}' " \
      "expected '#{challenge}'"
  else
    "invalid pkce challenge method '#{method}'"
  end
end

.pkce_valid?(verifier, challenge, method, response) ⇒ Boolean

Returns:

  • (Boolean)


348
349
350
351
352
353
354
355
356
357
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 348

def pkce_valid?(verifier, challenge, method, response)
  pkce_error = pkce_error(verifier, challenge, method)

  if pkce_error.present?
    update_response_for_error(response, pkce_error)
    false
  else
    true
  end
end

.refresh_token_to_authorization_code(refresh_token) ⇒ Object



186
187
188
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 186

def refresh_token_to_authorization_code(refresh_token)
  refresh_token[..-4]
end

.request_has_expired_token?(request) ⇒ Boolean

Returns:

  • (Boolean)


190
191
192
193
194
195
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 190

def request_has_expired_token?(request)
  return false if request.params[:session_path].present?

  token = request.headers['authorization']&.delete_prefix('Bearer ')
  token_expired?(token)
end

.root_ca_certObject



74
75
76
77
78
79
80
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 74

def root_ca_cert
  File.read(
    ENV.fetch('UDAP_ROOT_CA_CERT_FILE',
              File.join(__dir__, '..',
                        'certs', 'infernoCA.pem'))
  )
end

.root_ca_private_keyObject



82
83
84
85
86
87
88
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 82

def root_ca_private_key
  File.read(
    ENV.fetch('UDAP_CA_PRIVATE_KEY_FILE',
              File.join(__dir__, '..',
                        'certs', 'infernoCA.key'))
  )
end

.test_kit_certObject



90
91
92
93
94
95
96
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 90

def test_kit_cert
  File.read(
    ENV.fetch('UDAP_TEST_KIT_CERT_FILE',
              File.join(__dir__, '..',
                        'certs', 'TestClient.pem'))
  )
end

.test_kit_private_keyObject



98
99
100
101
102
103
104
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 98

def test_kit_private_key
  File.read(
    ENV.fetch('UDAP_TEST_KIT_PRIVATE_KEY_FILE',
              File.join(__dir__, '..',
                        'certs', 'TestClientPrivateKey.key'))
  )
end

.token_expired?(token, check_time = nil) ⇒ Boolean

Returns:

  • (Boolean)


197
198
199
200
201
202
203
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 197

def token_expired?(token, check_time = nil)
  decoded_token = decode_token(token)
  return false unless decoded_token&.dig('expiration').present?

  check_time = Time.now.to_i unless check_time.present?
  decoded_token['expiration'] < check_time
end

.udap_assertion_signature_verification(assertion_jwt, signing_cert, algorithm) ⇒ Object



257
258
259
260
261
262
263
264
265
266
267
268
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 257

def udap_assertion_signature_verification(assertion_jwt, signing_cert, algorithm)
  return 'missing `alg` header' unless algorithm.present?

  signature_validation_result = UDAPSecurityTestKit::UDAPJWTValidator.validate_signature(
    assertion_jwt,
    algorithm,
    signing_cert
  )
  return if signature_validation_result[:success]

  signature_validation_result[:error_message]
end

.udap_claim_from_registration_payload(reg_body, claim_key) ⇒ Object



131
132
133
134
135
136
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 131

def udap_claim_from_registration_payload(reg_body, claim_key)
  software_statement_jwt = udap_software_statement_jwt(reg_body)
  return unless software_statement_jwt.present?

  jwt_claims(software_statement_jwt)&.dig(claim_key)
end

.udap_client_uri_from_registration_payload(reg_body) ⇒ Object



127
128
129
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 127

def udap_client_uri_from_registration_payload(reg_body)
  udap_claim_from_registration_payload(reg_body, 'iss')
end

.udap_reg_signature_verification(assertion_jwt) ⇒ Object



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 214

def udap_reg_signature_verification(assertion_jwt)
  assertion_body, assertion_header = JWT.decode(assertion_jwt, nil, false)
  return 'missing `x5c` header' if assertion_header['x5c'].blank?

  leaf_cert_der = Base64.decode64(assertion_header['x5c'].first)
  leaf_cert = OpenSSL::X509::Certificate.new(leaf_cert_der)

  signature_error = udap_assertion_signature_verification(assertion_jwt, leaf_cert, assertion_header['alg'])
  return signature_error if signature_error.present?

  # check the certificate's SAN extension for the issuer name
  issuer = assertion_body['iss']
  begin
    alt_names =
      leaf_cert.extensions
        .find { |extension| extension.oid == 'subjectAltName' }.value
  rescue NoMethodError
    return 'Could not find Subject Alternative Name extension in leaf certificate'
  end
  return if alt_names.include?("URI:#{issuer}")

  "`iss` claim `#{issuer}` not found in Subject Alternative Name extension " \
    "from the `x5c` JWT header: `#{alt_names}`"
end

.udap_registration_software_statement(test_session_id) ⇒ Object



270
271
272
273
274
275
276
277
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 270

def udap_registration_software_statement(test_session_id)
  registration_requests =
    Inferno::Repositories::Requests.new.tagged_requests(test_session_id, [UDAP_TAG, REGISTRATION_TAG])
  return unless registration_requests.present?

  parsed_body = MockUDAPServer.parsed_request_body(registration_requests.last)
  parsed_body&.dig('software_statement')
end

.udap_server_metadata(suite_id) ⇒ Object



15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 15

def (suite_id)
  base_url = "#{Inferno::Application['base_url']}/custom/#{suite_id}"
  response_body = {
    udap_versions_supported: ['1'],
    udap_profiles_supported: ['udap_dcr', 'udap_authn', 'udap_authz'],
    udap_authorization_extensions_supported: ['hl7-b2b'],
    udap_authorization_extensions_required: [],
    udap_certifications_supported: [],
    udap_certifications_required: [],
    grant_types_supported: ['authorization_code', 'client_credentials', 'refresh_token'],
    scopes_supported: SUPPORTED_SCOPES,
    registration_endpoint: base_url + REGISTRATION_PATH,
    registration_endpoint_jwt_signing_alg_values_supported: ['RS256', 'RS384', 'ES384'],
    authorization_endpoint: base_url + AUTHORIZATION_PATH,
    token_endpoint: base_url + TOKEN_PATH,
    token_endpoint_auth_methods_supported: ['private_key_jwt'],
    token_endpoint_auth_signing_alg_values_supported: ['RS256', 'RS384', 'ES384'],
    introspection_endpoint: base_url + INTROSPECTION_PATH,
    signed_metadata: (base_url)
  }.to_json

  [200, { 'Content-Type' => 'application/json', 'Access-Control-Allow-Origin' => '*' }, [response_body]]
end

.udap_signed_metadata_jwt(base_url) ⇒ Object



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 54

def (base_url)
  jwt_claim_hash = {
    iss: base_url + FHIR_PATH,
    sub: base_url + FHIR_PATH,
    exp: 5.minutes.from_now.to_i,
    iat: Time.now.to_i,
    jti: SecureRandom.hex(32),
    token_endpoint: base_url + TOKEN_PATH,
    authorization_endpoint: base_url + AUTHORIZATION_PATH,
    registration_endpoint: base_url + REGISTRATION_PATH
  }.compact

  UDAPJWTBuilder.encode_jwt_with_x5c_header(
    jwt_claim_hash,
    test_kit_private_key,
    'RS256',
    [test_kit_cert]
  )
end

.udap_software_statement_jwt(reg_body) ⇒ Object



138
139
140
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 138

def udap_software_statement_jwt(reg_body)
  reg_body&.dig('software_statement')
end

.udap_token_signature_verification(assertion_jwt, registration_jwt) ⇒ Object



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 239

def udap_token_signature_verification(assertion_jwt, registration_jwt)
  _assertion_body, assertion_header = JWT.decode(assertion_jwt, nil, false)
  return 'missing `x5c` header' if assertion_header['x5c'].blank?

  leaf_cert_der = Base64.decode64(assertion_header['x5c'].first)
  leaf_cert = OpenSSL::X509::Certificate.new(leaf_cert_der)

  signature_error = udap_assertion_signature_verification(assertion_jwt, leaf_cert, assertion_header['alg'])
  return signature_error if signature_error.present?
  return unless registration_jwt.present?

  # check trust
  _registration_body, registration_header = JWT.decode(registration_jwt, nil, false)
  return if assertion_header['x5c'].first == registration_header['x5c'].first

  'signing cert does not match registration cert'
end

.update_response_for_error(response, error_message) ⇒ Object



279
280
281
282
283
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 279

def update_response_for_error(response, error_message)
  response.status = 401
  response.format = :json
  response.body = { error: 'invalid_client', error_description: error_message }.to_json
end

.update_response_for_expired_token(response, type) ⇒ Object



205
206
207
208
209
210
211
212
# File 'lib/udap_security_test_kit/endpoints/mock_udap_server.rb', line 205

def update_response_for_expired_token(response, type)
  response.status = 401
  response.format = :json
  response.body = FHIR::OperationOutcome.new(
    issue: FHIR::OperationOutcome::Issue.new(severity: 'fatal', code: 'expired',
                                             details: FHIR::CodeableConcept.new(text: "#{type} has expired"))
  ).to_json
end