tyler_efm_client 1.0.0.pre.alpha.1
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 +7 -0
- data/CHANGELOG.md +34 -0
- data/GITHUB_SETUP.md +221 -0
- data/Gemfile +11 -0
- data/LICENSE +21 -0
- data/PUBLICATION_GUIDE.md +146 -0
- data/README.md +298 -0
- data/Rakefile +14 -0
- data/build_gem.rb +73 -0
- data/config.example.json +15 -0
- data/docs/getting_started.md +293 -0
- data/examples/authentication_example.rb +64 -0
- data/examples/complete_workflow_example.rb +149 -0
- data/examples/getcaselist_example.rb +142 -0
- data/lib/tyler_efm_client/client.rb +627 -0
- data/lib/tyler_efm_client/errors.rb +19 -0
- data/lib/tyler_efm_client/result_types.rb +77 -0
- data/lib/tyler_efm_client/version.rb +6 -0
- data/lib/tyler_efm_client.rb +41 -0
- data/publish_gem.rb +155 -0
- data/samples/court_filing_workflow/README.md +332 -0
- data/test_gem.rb +170 -0
- metadata +164 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'openssl'
|
|
6
|
+
require 'base64'
|
|
7
|
+
require 'securerandom'
|
|
8
|
+
require 'time'
|
|
9
|
+
require 'digest'
|
|
10
|
+
require 'nokogiri'
|
|
11
|
+
require 'json'
|
|
12
|
+
|
|
13
|
+
module TylerEfmClient
|
|
14
|
+
##
|
|
15
|
+
# Tyler EFM Client for Electronic Court Filing (ECF) services.
|
|
16
|
+
#
|
|
17
|
+
# This client provides a simple interface for:
|
|
18
|
+
# 1. Authentication with Tyler EFM services
|
|
19
|
+
# 2. Making SOAP calls to any Tyler EFM service operation
|
|
20
|
+
#
|
|
21
|
+
# Key features:
|
|
22
|
+
# - Automatic certificate handling (PFX files)
|
|
23
|
+
# - WS-Security digital signatures (RSA-SHA1/SHA1 for Tyler compatibility)
|
|
24
|
+
# - Support for both User Service and Court Record Service operations
|
|
25
|
+
# - Flexible response formats (XML or JSON)
|
|
26
|
+
#
|
|
27
|
+
# @example Basic usage
|
|
28
|
+
# client = TylerEfmClient::Client.new
|
|
29
|
+
#
|
|
30
|
+
# # Authenticate
|
|
31
|
+
# auth_result = client.authenticate(
|
|
32
|
+
# base_url: "https://server.com/EFM/EFMUserService.svc",
|
|
33
|
+
# pfx_file: "certificate.pfx",
|
|
34
|
+
# pfx_password: "password",
|
|
35
|
+
# user_email: "user@example.com",
|
|
36
|
+
# user_password: "userpass"
|
|
37
|
+
# )
|
|
38
|
+
#
|
|
39
|
+
# if auth_result.success?
|
|
40
|
+
# # Call a service
|
|
41
|
+
# response = client.call_service(
|
|
42
|
+
# base_url: "https://server.com/efm/v5/CourtRecordService.svc",
|
|
43
|
+
# password_hash: auth_result.password_hash,
|
|
44
|
+
# operation: "GetCaseList",
|
|
45
|
+
# soap_body: "<GetCaseListRequest>...</GetCaseListRequest>",
|
|
46
|
+
# user_email: "user@example.com"
|
|
47
|
+
# )
|
|
48
|
+
# end
|
|
49
|
+
class Client
|
|
50
|
+
def initialize
|
|
51
|
+
@pkcs12_data = nil
|
|
52
|
+
@certificate = nil
|
|
53
|
+
@private_key = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# Authenticate with Tyler EFM User Service.
|
|
58
|
+
#
|
|
59
|
+
# @param base_url [String] Base URL for the EFM User Service (e.g., "https://server/EFM/EFMUserService.svc")
|
|
60
|
+
# @param pfx_file [String] Path to the PFX certificate file
|
|
61
|
+
# @param pfx_password [String] Password for the PFX certificate
|
|
62
|
+
# @param user_email [String] User's email address
|
|
63
|
+
# @param user_password [String] User's password
|
|
64
|
+
#
|
|
65
|
+
# @return [AuthenticationResult] Result with success status and authentication details
|
|
66
|
+
#
|
|
67
|
+
# @raise [TylerEfmClient::AuthenticationError] If authentication fails
|
|
68
|
+
# @raise [TylerEfmClient::CertificateError] If certificate loading fails
|
|
69
|
+
# @raise [TylerEfmClient::Error] If there are other errors (network, etc.)
|
|
70
|
+
def authenticate(base_url:, pfx_file:, pfx_password:, user_email:, user_password:)
|
|
71
|
+
begin
|
|
72
|
+
# Step 1: Load certificate
|
|
73
|
+
unless load_certificate(pfx_file, pfx_password)
|
|
74
|
+
return AuthenticationResult.new(
|
|
75
|
+
success: false,
|
|
76
|
+
error_code: 'CERT_LOAD_ERROR',
|
|
77
|
+
error_message: 'Failed to load certificate from PFX file'
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Step 2: Create authentication SOAP envelope
|
|
82
|
+
soap_envelope = create_auth_soap_envelope(user_email, user_password)
|
|
83
|
+
|
|
84
|
+
# Step 3: Make HTTP request
|
|
85
|
+
headers = {
|
|
86
|
+
'Content-Type' => 'text/xml; charset=utf-8',
|
|
87
|
+
'SOAPAction' => 'urn:tyler:efm:services/IEfmUserService/AuthenticateUser',
|
|
88
|
+
'User-Agent' => 'Tyler-EFM-Client-Ruby/1.0',
|
|
89
|
+
'Accept' => 'text/xml, application/soap+xml, */*'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
response = make_http_request(base_url, soap_envelope, headers)
|
|
93
|
+
|
|
94
|
+
if response.code.to_i == 200
|
|
95
|
+
parse_auth_response(response.body)
|
|
96
|
+
else
|
|
97
|
+
AuthenticationResult.new(
|
|
98
|
+
success: false,
|
|
99
|
+
error_code: "HTTP_#{response.code}",
|
|
100
|
+
error_message: "Authentication request failed: HTTP #{response.code}"
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
rescue => e
|
|
105
|
+
raise Error, "Authentication error: #{e.message}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
##
|
|
110
|
+
# Call any Tyler EFM SOAP service operation.
|
|
111
|
+
#
|
|
112
|
+
# @param base_url [String] Base URL for the EFM service (User Service or Court Record Service)
|
|
113
|
+
# @param password_hash [String] Password hash from successful authentication
|
|
114
|
+
# @param operation [String] Name of the SOAP operation (e.g., "GetCaseList", "GetCase")
|
|
115
|
+
# @param soap_body [String] SOAP body content as XML string
|
|
116
|
+
# @param user_email [String, nil] User's email (required for Court Record Service operations)
|
|
117
|
+
# @param pfx_file [String, nil] Path to PFX certificate (if not already loaded from authentication)
|
|
118
|
+
# @param pfx_password [String, nil] PFX password (if not already loaded from authentication)
|
|
119
|
+
# @param return_json [Boolean] If true, attempt to convert XML response to JSON (default: false)
|
|
120
|
+
# @param soap_action [String, nil] Custom SOAP action header (auto-generated if not provided)
|
|
121
|
+
#
|
|
122
|
+
# @return [ServiceResponse] Response with call results
|
|
123
|
+
#
|
|
124
|
+
# @raise [TylerEfmClient::ServiceError] If the service call fails
|
|
125
|
+
# @raise [TylerEfmClient::Error] If there are other errors
|
|
126
|
+
def call_service(base_url:, password_hash:, operation:, soap_body:, user_email: nil,
|
|
127
|
+
pfx_file: nil, pfx_password: nil, return_json: false, soap_action: nil)
|
|
128
|
+
begin
|
|
129
|
+
# Load certificate if not already loaded
|
|
130
|
+
if @certificate.nil? && pfx_file
|
|
131
|
+
unless load_certificate(pfx_file, pfx_password)
|
|
132
|
+
return ServiceResponse.new(
|
|
133
|
+
success: false,
|
|
134
|
+
status_code: 0,
|
|
135
|
+
raw_xml: '',
|
|
136
|
+
error_message: 'Failed to load certificate'
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if @certificate.nil?
|
|
142
|
+
return ServiceResponse.new(
|
|
143
|
+
success: false,
|
|
144
|
+
status_code: 0,
|
|
145
|
+
raw_xml: '',
|
|
146
|
+
error_message: 'No certificate loaded. Provide pfx_file or call authenticate() first.'
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Determine service type and create appropriate SOAP envelope
|
|
151
|
+
if base_url.include?('CourtRecord') || base_url.downcase.include?('courtrecord')
|
|
152
|
+
# Court Record Service - requires UserNameHeader
|
|
153
|
+
if user_email.nil?
|
|
154
|
+
return ServiceResponse.new(
|
|
155
|
+
success: false,
|
|
156
|
+
status_code: 0,
|
|
157
|
+
raw_xml: '',
|
|
158
|
+
error_message: 'user_email is required for Court Record Service operations'
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
soap_envelope = create_court_record_soap_envelope(user_email, password_hash, operation, soap_body)
|
|
162
|
+
else
|
|
163
|
+
# User Service - standard WS-Security only
|
|
164
|
+
soap_envelope = create_user_service_soap_envelope(operation, soap_body)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Auto-generate SOAP action if not provided
|
|
168
|
+
if soap_action.nil?
|
|
169
|
+
if base_url.include?('CourtRecord')
|
|
170
|
+
soap_action = "https://docs.oasis-open.org/legalxml-courtfiling/ns/v5.0WSDL/CourtRecordMDE/#{operation}"
|
|
171
|
+
else
|
|
172
|
+
soap_action = "urn:tyler:efm:services/IEfmUserService/#{operation}"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
headers = {
|
|
177
|
+
'Content-Type' => 'text/xml; charset=utf-8',
|
|
178
|
+
'SOAPAction' => soap_action,
|
|
179
|
+
'User-Agent' => 'Tyler-EFM-Client-Ruby/1.0',
|
|
180
|
+
'Accept' => 'text/xml, application/soap+xml, */*'
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
response = make_http_request(base_url, soap_envelope, headers)
|
|
184
|
+
|
|
185
|
+
# Parse response
|
|
186
|
+
json_data = nil
|
|
187
|
+
if return_json && response.code.to_i == 200
|
|
188
|
+
json_data = xml_to_json(response.body)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
ServiceResponse.new(
|
|
192
|
+
success: (response.code.to_i == 200),
|
|
193
|
+
status_code: response.code.to_i,
|
|
194
|
+
raw_xml: response.body,
|
|
195
|
+
json_data: json_data,
|
|
196
|
+
error_message: response.code.to_i == 200 ? nil : "HTTP #{response.code}"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
rescue => e
|
|
200
|
+
raise ServiceError, "Service call error: #{e.message}"
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
def load_certificate(pfx_file, pfx_password)
|
|
207
|
+
begin
|
|
208
|
+
pfx_data = File.binread(pfx_file)
|
|
209
|
+
@pkcs12_data = OpenSSL::PKCS12.new(pfx_data, pfx_password)
|
|
210
|
+
@certificate = @pkcs12_data.certificate
|
|
211
|
+
@private_key = @pkcs12_data.key
|
|
212
|
+
true
|
|
213
|
+
rescue => e
|
|
214
|
+
false
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def get_certificate_base64
|
|
219
|
+
cert_der = @certificate.to_der
|
|
220
|
+
Base64.strict_encode64(cert_der)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def canonicalize_xml(xml_element)
|
|
224
|
+
begin
|
|
225
|
+
# Use Nokogiri's canonicalize with exclusive C14N
|
|
226
|
+
xml_element.canonicalize(Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0)
|
|
227
|
+
rescue => e
|
|
228
|
+
xml_element.to_s
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def compute_sha1_digest(data)
|
|
233
|
+
Digest::SHA1.digest(data)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def sign_data_rsa_sha1(data)
|
|
237
|
+
@private_key.sign(OpenSSL::Digest::SHA1.new, data)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def create_auth_soap_envelope(email, password)
|
|
241
|
+
timestamp_id = '_0'
|
|
242
|
+
token_id = "X509-#{SecureRandom.uuid}"
|
|
243
|
+
|
|
244
|
+
# Create timestamp
|
|
245
|
+
now = Time.now.utc
|
|
246
|
+
created = now.strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
|
|
247
|
+
expires = (now + 300).strftime('%Y-%m-%dT%H:%M:%S.%3NZ') # 5 minutes
|
|
248
|
+
|
|
249
|
+
cert_base64 = get_certificate_base64
|
|
250
|
+
|
|
251
|
+
# Build SOAP envelope XML using Nokogiri
|
|
252
|
+
builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
|
|
253
|
+
xml['soap'].Envelope(
|
|
254
|
+
'xmlns:soap' => 'http://schemas.xmlsoap.org/soap/envelope/',
|
|
255
|
+
'xmlns:tns' => 'urn:tyler:efm:services'
|
|
256
|
+
) do
|
|
257
|
+
xml['soap'].Header do
|
|
258
|
+
xml['wsse'].Security(
|
|
259
|
+
'xmlns:wsse' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd',
|
|
260
|
+
'xmlns:wsu' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd',
|
|
261
|
+
'soap:mustUnderstand' => '1'
|
|
262
|
+
) do
|
|
263
|
+
xml['wsu'].Timestamp('wsu:Id' => timestamp_id) do
|
|
264
|
+
xml['wsu'].Created created
|
|
265
|
+
xml['wsu'].Expires expires
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
xml['wsse'].BinarySecurityToken(
|
|
269
|
+
cert_base64,
|
|
270
|
+
'ValueType' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3',
|
|
271
|
+
'EncodingType' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary',
|
|
272
|
+
'wsu:Id' => token_id
|
|
273
|
+
)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
xml['soap'].Body do
|
|
278
|
+
xml['tns'].AuthenticateUser do
|
|
279
|
+
xml['tns'].AuthenticateRequest do
|
|
280
|
+
xml.Email(email, 'xmlns' => 'urn:tyler:efm:services:schema:AuthenticateRequest')
|
|
281
|
+
xml.Password(password, 'xmlns' => 'urn:tyler:efm:services:schema:AuthenticateRequest')
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Parse the built XML to add digital signature
|
|
289
|
+
doc = Nokogiri::XML(builder.to_xml)
|
|
290
|
+
|
|
291
|
+
# Find the timestamp element for signing
|
|
292
|
+
timestamp_elem = doc.xpath('//wsu:Timestamp', 'wsu' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd').first
|
|
293
|
+
|
|
294
|
+
if timestamp_elem
|
|
295
|
+
add_digital_signature(doc, timestamp_elem, timestamp_id, token_id)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Return compact XML (no pretty printing)
|
|
299
|
+
doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def create_court_record_soap_envelope(email, password_hash, operation, soap_body)
|
|
303
|
+
timestamp_id = '_0'
|
|
304
|
+
token_id = "X509-#{SecureRandom.uuid}"
|
|
305
|
+
|
|
306
|
+
# Create timestamp
|
|
307
|
+
now = Time.now.utc
|
|
308
|
+
created = now.strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
|
|
309
|
+
expires = (now + 300).strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
|
|
310
|
+
|
|
311
|
+
cert_base64 = get_certificate_base64
|
|
312
|
+
|
|
313
|
+
# Build SOAP envelope XML using Nokogiri
|
|
314
|
+
builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
|
|
315
|
+
xml['soap'].Envelope(
|
|
316
|
+
'xmlns:soap' => 'http://schemas.xmlsoap.org/soap/envelope/',
|
|
317
|
+
'xmlns:tns' => 'https://docs.oasis-open.org/legalxml-courtfiling/ns/v5.0WSDL/CourtRecordMDE',
|
|
318
|
+
'xmlns:wrappers' => 'https://docs.oasis-open.org/legalxml-courtfiling/ns/v5.0/wrappers',
|
|
319
|
+
'xmlns' => 'urn:tyler:efm:services' # Default namespace for Tyler services
|
|
320
|
+
) do
|
|
321
|
+
xml['soap'].Header do
|
|
322
|
+
# CRITICAL: UserNameHeader MUST be first and WITHOUT namespace prefix
|
|
323
|
+
xml.UserNameHeader do
|
|
324
|
+
xml.UserName email
|
|
325
|
+
xml.Password password_hash # Use hashed password from authentication
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# WS-Security as second header element
|
|
329
|
+
xml['wsse'].Security(
|
|
330
|
+
'xmlns:wsse' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd',
|
|
331
|
+
'xmlns:wsu' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd',
|
|
332
|
+
'soap:mustUnderstand' => '1'
|
|
333
|
+
) do
|
|
334
|
+
xml['wsu'].Timestamp('wsu:Id' => timestamp_id) do
|
|
335
|
+
xml['wsu'].Created created
|
|
336
|
+
xml['wsu'].Expires expires
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
xml['wsse'].BinarySecurityToken(
|
|
340
|
+
cert_base64,
|
|
341
|
+
'ValueType' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3',
|
|
342
|
+
'EncodingType' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary',
|
|
343
|
+
'wsu:Id' => token_id
|
|
344
|
+
)
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
xml['soap'].Body('xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance',
|
|
349
|
+
'xmlns:xsd' => 'http://www.w3.org/2001/XMLSchema') do
|
|
350
|
+
# Parse and insert the provided SOAP body
|
|
351
|
+
begin
|
|
352
|
+
body_doc = Nokogiri::XML(soap_body)
|
|
353
|
+
xml << body_doc.root.to_xml
|
|
354
|
+
rescue
|
|
355
|
+
# If parsing fails, add as text (fallback)
|
|
356
|
+
xml << soap_body
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Parse the built XML to add digital signature
|
|
363
|
+
doc = Nokogiri::XML(builder.to_xml)
|
|
364
|
+
|
|
365
|
+
# Find the timestamp element for signing
|
|
366
|
+
timestamp_elem = doc.xpath('//wsu:Timestamp', 'wsu' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd').first
|
|
367
|
+
|
|
368
|
+
if timestamp_elem
|
|
369
|
+
add_digital_signature(doc, timestamp_elem, timestamp_id, token_id)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Return compact XML (no pretty printing)
|
|
373
|
+
doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def create_user_service_soap_envelope(operation, soap_body)
|
|
377
|
+
timestamp_id = '_0'
|
|
378
|
+
token_id = "X509-#{SecureRandom.uuid}"
|
|
379
|
+
|
|
380
|
+
# Create timestamp
|
|
381
|
+
now = Time.now.utc
|
|
382
|
+
created = now.strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
|
|
383
|
+
expires = (now + 300).strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
|
|
384
|
+
|
|
385
|
+
cert_base64 = get_certificate_base64
|
|
386
|
+
|
|
387
|
+
# Build SOAP envelope XML using Nokogiri
|
|
388
|
+
builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
|
|
389
|
+
xml['soap'].Envelope(
|
|
390
|
+
'xmlns:soap' => 'http://schemas.xmlsoap.org/soap/envelope/',
|
|
391
|
+
'xmlns:tns' => 'urn:tyler:efm:services'
|
|
392
|
+
) do
|
|
393
|
+
xml['soap'].Header do
|
|
394
|
+
xml['wsse'].Security(
|
|
395
|
+
'xmlns:wsse' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd',
|
|
396
|
+
'xmlns:wsu' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd',
|
|
397
|
+
'soap:mustUnderstand' => '1'
|
|
398
|
+
) do
|
|
399
|
+
xml['wsu'].Timestamp('wsu:Id' => timestamp_id) do
|
|
400
|
+
xml['wsu'].Created created
|
|
401
|
+
xml['wsu'].Expires expires
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
xml['wsse'].BinarySecurityToken(
|
|
405
|
+
cert_base64,
|
|
406
|
+
'ValueType' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3',
|
|
407
|
+
'EncodingType' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary',
|
|
408
|
+
'wsu:Id' => token_id
|
|
409
|
+
)
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
xml['soap'].Body do
|
|
414
|
+
# Parse and insert the provided SOAP body
|
|
415
|
+
begin
|
|
416
|
+
body_doc = Nokogiri::XML(soap_body)
|
|
417
|
+
xml << body_doc.root.to_xml
|
|
418
|
+
rescue
|
|
419
|
+
# If parsing fails, add as text (fallback)
|
|
420
|
+
xml << soap_body
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Parse the built XML to add digital signature
|
|
427
|
+
doc = Nokogiri::XML(builder.to_xml)
|
|
428
|
+
|
|
429
|
+
# Find the timestamp element for signing
|
|
430
|
+
timestamp_elem = doc.xpath('//wsu:Timestamp', 'wsu' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd').first
|
|
431
|
+
|
|
432
|
+
if timestamp_elem
|
|
433
|
+
add_digital_signature(doc, timestamp_elem, timestamp_id, token_id)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Return compact XML (no pretty printing)
|
|
437
|
+
doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def add_digital_signature(doc, timestamp_element, timestamp_id, token_id)
|
|
441
|
+
begin
|
|
442
|
+
# Canonicalize timestamp and compute digest
|
|
443
|
+
timestamp_canonical = canonicalize_xml(timestamp_element)
|
|
444
|
+
timestamp_digest = compute_sha1_digest(timestamp_canonical)
|
|
445
|
+
timestamp_digest_b64 = Base64.strict_encode64(timestamp_digest)
|
|
446
|
+
|
|
447
|
+
# Create SignedInfo XML
|
|
448
|
+
signed_info_xml = %Q{<ds:SignedInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
|
449
|
+
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
|
450
|
+
<ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
|
|
451
|
+
<ds:Reference URI="##{timestamp_id}">
|
|
452
|
+
<ds:Transforms>
|
|
453
|
+
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
|
|
454
|
+
</ds:Transforms>
|
|
455
|
+
<ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
|
|
456
|
+
<ds:DigestValue>#{timestamp_digest_b64}</ds:DigestValue>
|
|
457
|
+
</ds:Reference>
|
|
458
|
+
</ds:SignedInfo>}
|
|
459
|
+
|
|
460
|
+
# Parse SignedInfo for canonicalization
|
|
461
|
+
signed_info_doc = Nokogiri::XML(signed_info_xml)
|
|
462
|
+
signed_info_elem = signed_info_doc.root
|
|
463
|
+
|
|
464
|
+
# Canonicalize SignedInfo and sign
|
|
465
|
+
signed_info_canonical = canonicalize_xml(signed_info_elem)
|
|
466
|
+
signature_bytes = sign_data_rsa_sha1(signed_info_canonical)
|
|
467
|
+
signature_b64 = Base64.strict_encode64(signature_bytes)
|
|
468
|
+
|
|
469
|
+
# Create complete signature XML
|
|
470
|
+
signature_xml = %Q{<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
|
|
471
|
+
#{signed_info_xml.gsub('<ds:SignedInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">', '<ds:SignedInfo>').gsub('</ds:SignedInfo>', '</ds:SignedInfo>')}
|
|
472
|
+
<ds:SignatureValue>#{signature_b64}</ds:SignatureValue>
|
|
473
|
+
<ds:KeyInfo>
|
|
474
|
+
<wsse:SecurityTokenReference>
|
|
475
|
+
<wsse:Reference ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" URI="##{token_id}"/>
|
|
476
|
+
</wsse:SecurityTokenReference>
|
|
477
|
+
</ds:KeyInfo>
|
|
478
|
+
</ds:Signature>}
|
|
479
|
+
|
|
480
|
+
# Add signature to Security element
|
|
481
|
+
security_elem = doc.xpath('//wsse:Security', 'wsse' => 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd').first
|
|
482
|
+
signature_doc = Nokogiri::XML(signature_xml)
|
|
483
|
+
security_elem.add_child(signature_doc.root)
|
|
484
|
+
|
|
485
|
+
rescue => e
|
|
486
|
+
# Signature creation failed, but continue without signature
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def make_http_request(base_url, soap_envelope, headers)
|
|
491
|
+
uri = URI(base_url)
|
|
492
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
493
|
+
http.use_ssl = true
|
|
494
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
495
|
+
|
|
496
|
+
# Set client certificate for mutual TLS
|
|
497
|
+
http.cert = @certificate
|
|
498
|
+
http.key = @private_key
|
|
499
|
+
|
|
500
|
+
# Create POST request
|
|
501
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
502
|
+
headers.each { |key, value| request[key] = value }
|
|
503
|
+
request.body = soap_envelope
|
|
504
|
+
|
|
505
|
+
# Make the request
|
|
506
|
+
http.request(request)
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def parse_auth_response(response_text)
|
|
510
|
+
begin
|
|
511
|
+
# Handle multipart MIME response
|
|
512
|
+
xml_content = response_text
|
|
513
|
+
if response_text.include?('--uuid:')
|
|
514
|
+
xml_start = response_text.index('<s:Envelope') || response_text.index('<')
|
|
515
|
+
if xml_start
|
|
516
|
+
xml_end = response_text.rindex('</s:Envelope>')
|
|
517
|
+
if xml_end
|
|
518
|
+
xml_end += '</s:Envelope>'.length
|
|
519
|
+
xml_content = response_text[xml_start...xml_end]
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
doc = Nokogiri::XML(xml_content)
|
|
525
|
+
|
|
526
|
+
# Extract authentication response data
|
|
527
|
+
password_hash = extract_element_text(doc, 'PasswordHash')
|
|
528
|
+
user_id = extract_element_text(doc, 'UserID')
|
|
529
|
+
first_name = extract_element_text(doc, 'FirstName')
|
|
530
|
+
last_name = extract_element_text(doc, 'LastName')
|
|
531
|
+
email = extract_element_text(doc, 'Email')
|
|
532
|
+
expiration_date = extract_element_text(doc, 'ExpirationDateTime')
|
|
533
|
+
error_code = extract_element_text(doc, 'ErrorCode')
|
|
534
|
+
error_message = extract_element_text(doc, 'ErrorText')
|
|
535
|
+
|
|
536
|
+
# Determine success
|
|
537
|
+
success = !password_hash.nil? && (error_code.nil? || error_code == '0')
|
|
538
|
+
|
|
539
|
+
AuthenticationResult.new(
|
|
540
|
+
success: success,
|
|
541
|
+
password_hash: password_hash,
|
|
542
|
+
user_id: user_id,
|
|
543
|
+
first_name: first_name,
|
|
544
|
+
last_name: last_name,
|
|
545
|
+
email: email,
|
|
546
|
+
expiration_date: expiration_date,
|
|
547
|
+
error_code: error_code,
|
|
548
|
+
error_message: error_message
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
rescue => e
|
|
552
|
+
AuthenticationResult.new(
|
|
553
|
+
success: false,
|
|
554
|
+
error_code: 'PARSE_ERROR',
|
|
555
|
+
error_message: "Failed to parse authentication response: #{e.message}"
|
|
556
|
+
)
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def extract_element_text(doc, element_name)
|
|
561
|
+
elements = doc.xpath("//*[local-name()='#{element_name}']")
|
|
562
|
+
elements.first&.text&.strip
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def xml_to_json(xml_text)
|
|
566
|
+
begin
|
|
567
|
+
# Handle multipart MIME response
|
|
568
|
+
xml_content = xml_text
|
|
569
|
+
if xml_text.include?('--uuid:')
|
|
570
|
+
xml_start = xml_text.index('<s:Envelope') || xml_text.index('<')
|
|
571
|
+
if xml_start
|
|
572
|
+
xml_end = xml_text.rindex('</s:Envelope>')
|
|
573
|
+
if xml_end
|
|
574
|
+
xml_end += '</s:Envelope>'.length
|
|
575
|
+
xml_content = xml_text[xml_start...xml_end]
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
doc = Nokogiri::XML(xml_content)
|
|
581
|
+
element_to_hash(doc.root)
|
|
582
|
+
|
|
583
|
+
rescue => e
|
|
584
|
+
nil
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def element_to_hash(element)
|
|
589
|
+
result = {}
|
|
590
|
+
|
|
591
|
+
# Add attributes
|
|
592
|
+
element.attributes.each do |key, attr|
|
|
593
|
+
result["@#{key}"] = attr.value
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# Add text content
|
|
597
|
+
if element.text && !element.text.strip.empty?
|
|
598
|
+
if element.children.length == 1 && element.children.first.text?
|
|
599
|
+
return element.text.strip
|
|
600
|
+
else
|
|
601
|
+
result['#text'] = element.text.strip
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# Add child elements
|
|
606
|
+
element.children.each do |child|
|
|
607
|
+
next if child.text?
|
|
608
|
+
|
|
609
|
+
child_name = child.name
|
|
610
|
+
child_value = element_to_hash(child)
|
|
611
|
+
|
|
612
|
+
if result[child_name]
|
|
613
|
+
# Convert to array if multiple elements with same name
|
|
614
|
+
if result[child_name].is_a?(Array)
|
|
615
|
+
result[child_name] << child_value
|
|
616
|
+
else
|
|
617
|
+
result[child_name] = [result[child_name], child_value]
|
|
618
|
+
end
|
|
619
|
+
else
|
|
620
|
+
result[child_name] = child_value
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
result
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TylerEfmClient
|
|
4
|
+
##
|
|
5
|
+
# Base exception for Tyler EFM Client errors
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
##
|
|
9
|
+
# Exception raised for authentication failures
|
|
10
|
+
class AuthenticationError < Error; end
|
|
11
|
+
|
|
12
|
+
##
|
|
13
|
+
# Exception raised for certificate handling errors
|
|
14
|
+
class CertificateError < Error; end
|
|
15
|
+
|
|
16
|
+
##
|
|
17
|
+
# Exception raised for SOAP service call errors
|
|
18
|
+
class ServiceError < Error; end
|
|
19
|
+
end
|