smart-id-ruby-client 0.1.0
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/.rspec +3 -0
- data/.rubocop.yml +14 -0
- data/CHANGELOG.md +13 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +436 -0
- data/Rakefile +12 -0
- data/lib/smart-id-ruby-client.rb +3 -0
- data/lib/smart_id_ruby/callback_url.rb +18 -0
- data/lib/smart_id_ruby/callback_url_util.rb +54 -0
- data/lib/smart_id_ruby/client.rb +124 -0
- data/lib/smart_id_ruby/configuration.rb +184 -0
- data/lib/smart_id_ruby/device_link_builder.rb +301 -0
- data/lib/smart_id_ruby/device_link_interaction.rb +67 -0
- data/lib/smart_id_ruby/errors/certificate_level_mismatch_error.rb +8 -0
- data/lib/smart_id_ruby/errors/document_unusable_error.rb +12 -0
- data/lib/smart_id_ruby/errors/error.rb +8 -0
- data/lib/smart_id_ruby/errors/expected_linked_session_error.rb +15 -0
- data/lib/smart_id_ruby/errors/no_suitable_account_of_requested_type_found_error.rb +10 -0
- data/lib/smart_id_ruby/errors/person_should_view_smart_id_portal_error.rb +8 -0
- data/lib/smart_id_ruby/errors/protocol_failure_error.rb +13 -0
- data/lib/smart_id_ruby/errors/relying_party_account_configuration_error.rb +10 -0
- data/lib/smart_id_ruby/errors/request_setup_error.rb +10 -0
- data/lib/smart_id_ruby/errors/request_validation_error.rb +8 -0
- data/lib/smart_id_ruby/errors/required_interaction_not_supported_by_app_error.rb +13 -0
- data/lib/smart_id_ruby/errors/response_error.rb +8 -0
- data/lib/smart_id_ruby/errors/server_maintenance_error.rb +8 -0
- data/lib/smart_id_ruby/errors/session_end_result_error.rb +15 -0
- data/lib/smart_id_ruby/errors/session_not_complete_error.rb +8 -0
- data/lib/smart_id_ruby/errors/session_not_found_error.rb +8 -0
- data/lib/smart_id_ruby/errors/session_secret_mismatch_error.rb +8 -0
- data/lib/smart_id_ruby/errors/session_timeout_error.rb +12 -0
- data/lib/smart_id_ruby/errors/smart_id_server_error.rb +12 -0
- data/lib/smart_id_ruby/errors/unprocessable_response_error.rb +9 -0
- data/lib/smart_id_ruby/errors/unsupported_client_api_version_error.rb +8 -0
- data/lib/smart_id_ruby/errors/user_account_not_found_error.rb +8 -0
- data/lib/smart_id_ruby/errors/user_account_unusable_error.rb +12 -0
- data/lib/smart_id_ruby/errors/user_refused_cert_choice_error.rb +14 -0
- data/lib/smart_id_ruby/errors/user_refused_confirmation_message_error.rb +13 -0
- data/lib/smart_id_ruby/errors/user_refused_confirmation_message_with_verification_choice_error.rb +13 -0
- data/lib/smart_id_ruby/errors/user_refused_display_text_and_pin_error.rb +13 -0
- data/lib/smart_id_ruby/errors/user_refused_error.rb +12 -0
- data/lib/smart_id_ruby/errors/user_selected_wrong_verification_code_error.rb +13 -0
- data/lib/smart_id_ruby/errors.rb +31 -0
- data/lib/smart_id_ruby/flows/base_builder.rb +90 -0
- data/lib/smart_id_ruby/flows/certificate_by_document_number_request_builder.rb +130 -0
- data/lib/smart_id_ruby/flows/device_link_authentication_session_request_builder.rb +208 -0
- data/lib/smart_id_ruby/flows/device_link_certificate_choice_session_request_builder.rb +112 -0
- data/lib/smart_id_ruby/flows/device_link_signature_session_request_builder.rb +286 -0
- data/lib/smart_id_ruby/flows/linked_notification_signature_session_request_builder.rb +235 -0
- data/lib/smart_id_ruby/flows/notification_authentication_session_request_builder.rb +184 -0
- data/lib/smart_id_ruby/flows/notification_certificate_choice_session_request_builder.rb +96 -0
- data/lib/smart_id_ruby/flows/notification_signature_session_request_builder.rb +272 -0
- data/lib/smart_id_ruby/models/authentication_identity.rb +19 -0
- data/lib/smart_id_ruby/models/authentication_response.rb +38 -0
- data/lib/smart_id_ruby/models/certificate_choice_response.rb +19 -0
- data/lib/smart_id_ruby/models/device_link_session_response.rb +34 -0
- data/lib/smart_id_ruby/models/notification_authentication_session_response.rb +25 -0
- data/lib/smart_id_ruby/models/notification_certificate_choice_session_response.rb +25 -0
- data/lib/smart_id_ruby/models/notification_signature_session_response.rb +29 -0
- data/lib/smart_id_ruby/models/session_status.rb +261 -0
- data/lib/smart_id_ruby/models/signature_response.rb +38 -0
- data/lib/smart_id_ruby/notification_interaction.rb +70 -0
- data/lib/smart_id_ruby/qr_code_generator.rb +65 -0
- data/lib/smart_id_ruby/rest/connector.rb +364 -0
- data/lib/smart_id_ruby/rest/session_status_poller.rb +125 -0
- data/lib/smart_id_ruby/rp_challenge.rb +37 -0
- data/lib/smart_id_ruby/rp_challenge_generator.rb +28 -0
- data/lib/smart_id_ruby/semantics_identifier.rb +35 -0
- data/lib/smart_id_ruby/validation/authentication_certificate_validator.rb +90 -0
- data/lib/smart_id_ruby/validation/authentication_identity_mapper.rb +227 -0
- data/lib/smart_id_ruby/validation/base_authentication_response_validator.rb +304 -0
- data/lib/smart_id_ruby/validation/certificate_choice_response_validator.rb +104 -0
- data/lib/smart_id_ruby/validation/certificate_validator.rb +170 -0
- data/lib/smart_id_ruby/validation/device_link_authentication_response_validator.rb +76 -0
- data/lib/smart_id_ruby/validation/error_result_handler.rb +88 -0
- data/lib/smart_id_ruby/validation/notification_authentication_response_validator.rb +16 -0
- data/lib/smart_id_ruby/validation/signature_payload_builder.rb +62 -0
- data/lib/smart_id_ruby/validation/signature_response_validator.rb +345 -0
- data/lib/smart_id_ruby/validation/signature_value_validator.rb +76 -0
- data/lib/smart_id_ruby/validation/trusted_ca_cert_store.rb +20 -0
- data/lib/smart_id_ruby/verification_code_calculator.rb +31 -0
- data/lib/smart_id_ruby/version.rb +5 -0
- data/lib/smart_id_ruby.rb +76 -0
- metadata +173 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "faraday"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module SmartIdRuby
|
|
8
|
+
module Rest
|
|
9
|
+
# Smart-ID REST connector implementation for RP API v3.1.
|
|
10
|
+
class Connector
|
|
11
|
+
SESSION_STATUS_PATH = "session/%<session_id>s"
|
|
12
|
+
|
|
13
|
+
DEVICE_LINK_CERTIFICATE_CHOICE_DEVICE_LINK_PATH = "signature/certificate-choice/device-link/anonymous"
|
|
14
|
+
LINKED_NOTIFICATION_SIGNATURE_WITH_DOCUMENT_NUMBER_PATH = "signature/notification/linked/%<document_number>s"
|
|
15
|
+
NOTIFICATION_CERTIFICATE_CHOICE_WITH_SEMANTIC_IDENTIFIER_PATH = "signature/certificate-choice/notification/etsi/%<semantics_identifier>s"
|
|
16
|
+
CERTIFICATE_BY_DOCUMENT_NUMBER_PATH = "signature/certificate/%<document_number>s"
|
|
17
|
+
DEVICE_LINK_SIGNATURE_WITH_SEMANTIC_IDENTIFIER_PATH = "signature/device-link/etsi/%<semantics_identifier>s"
|
|
18
|
+
DEVICE_LINK_SIGNATURE_WITH_DOCUMENT_NUMBER_PATH = "signature/device-link/document/%<document_number>s"
|
|
19
|
+
NOTIFICATION_SIGNATURE_WITH_SEMANTIC_IDENTIFIER_PATH = "signature/notification/etsi/%<semantics_identifier>s"
|
|
20
|
+
NOTIFICATION_SIGNATURE_WITH_DOCUMENT_NUMBER_PATH = "signature/notification/document/%<document_number>s"
|
|
21
|
+
ANONYMOUS_DEVICE_LINK_AUTHENTICATION_PATH = "authentication/device-link/anonymous"
|
|
22
|
+
DEVICE_LINK_AUTHENTICATION_WITH_SEMANTIC_IDENTIFIER_PATH = "authentication/device-link/etsi/%<semantics_identifier>s"
|
|
23
|
+
DEVICE_LINK_AUTHENTICATION_WITH_DOCUMENT_NUMBER_PATH = "authentication/device-link/document/%<document_number>s"
|
|
24
|
+
NOTIFICATION_AUTHENTICATION_WITH_SEMANTIC_IDENTIFIER_PATH = "authentication/notification/etsi/%<semantics_identifier>s"
|
|
25
|
+
NOTIFICATION_AUTHENTICATION_WITH_DOCUMENT_NUMBER_PATH = "authentication/notification/document/%<document_number>s"
|
|
26
|
+
|
|
27
|
+
attr_reader :host_url, :configured_connection, :network_connection_config, :ssl_context
|
|
28
|
+
|
|
29
|
+
def initialize(host_url:, configured_connection: nil, network_connection_config: nil, ssl_context: nil)
|
|
30
|
+
@host_url = host_url
|
|
31
|
+
@configured_connection = configured_connection
|
|
32
|
+
@network_connection_config = network_connection_config
|
|
33
|
+
@ssl_context = ssl_context
|
|
34
|
+
@session_status_response_socket_open_time = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def set_session_status_response_socket_open_time(unit, value)
|
|
38
|
+
@session_status_response_socket_open_time = { unit: unit, value: value }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def session_status_response_socket_open_time
|
|
42
|
+
@session_status_response_socket_open_time
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def set_ssl_context(ssl_context)
|
|
46
|
+
@ssl_context = ssl_context
|
|
47
|
+
@connection = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def get_session_status(session_id)
|
|
51
|
+
logger.debug("Getting session status for sessionId: #{session_id}")
|
|
52
|
+
query = {}
|
|
53
|
+
if @session_status_response_socket_open_time && @session_status_response_socket_open_time[:value].to_i.positive?
|
|
54
|
+
query["timeoutMs"] = to_milliseconds(
|
|
55
|
+
@session_status_response_socket_open_time[:unit],
|
|
56
|
+
@session_status_response_socket_open_time[:value]
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
path = format(SESSION_STATUS_PATH, session_id: encode_path_segment(session_id))
|
|
61
|
+
response = get(path, query: query, not_found_error: SmartIdRuby::Errors::SessionNotFoundError)
|
|
62
|
+
SmartIdRuby::Models::SessionStatus.from_h(response)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def init_device_link_authentication(authentication_request, semantics_identifier)
|
|
66
|
+
logger.debug("Starting device link authentication session with semantics identifier")
|
|
67
|
+
path = format(
|
|
68
|
+
DEVICE_LINK_AUTHENTICATION_WITH_SEMANTIC_IDENTIFIER_PATH,
|
|
69
|
+
semantics_identifier: encode_path_segment(extract_identifier(semantics_identifier))
|
|
70
|
+
)
|
|
71
|
+
post(path, body: authentication_request)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def init_device_link_authentication_with_document(authentication_request, document_number)
|
|
75
|
+
logger.debug("Starting device link authentication session with document number")
|
|
76
|
+
path = format(
|
|
77
|
+
DEVICE_LINK_AUTHENTICATION_WITH_DOCUMENT_NUMBER_PATH,
|
|
78
|
+
document_number: encode_path_segment(document_number)
|
|
79
|
+
)
|
|
80
|
+
post(path, body: authentication_request)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def init_anonymous_device_link_authentication(authentication_request)
|
|
84
|
+
logger.debug("Starting anonymous device link authentication session")
|
|
85
|
+
post(ANONYMOUS_DEVICE_LINK_AUTHENTICATION_PATH, body: authentication_request)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def init_notification_authentication(authentication_request, semantics_identifier)
|
|
89
|
+
logger.debug("Starting notification authentication session with semantics identifier")
|
|
90
|
+
path = format(
|
|
91
|
+
NOTIFICATION_AUTHENTICATION_WITH_SEMANTIC_IDENTIFIER_PATH,
|
|
92
|
+
semantics_identifier: encode_path_segment(extract_identifier(semantics_identifier))
|
|
93
|
+
)
|
|
94
|
+
post(path, body: authentication_request)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def init_notification_authentication_with_document(authentication_request, document_number)
|
|
98
|
+
logger.debug("Starting notification authentication session with document number")
|
|
99
|
+
path = format(
|
|
100
|
+
NOTIFICATION_AUTHENTICATION_WITH_DOCUMENT_NUMBER_PATH,
|
|
101
|
+
document_number: encode_path_segment(document_number)
|
|
102
|
+
)
|
|
103
|
+
post(path, body: authentication_request)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def init_device_link_certificate_choice(request)
|
|
107
|
+
logger.debug("Initiating device link based certificate choice request")
|
|
108
|
+
post(DEVICE_LINK_CERTIFICATE_CHOICE_DEVICE_LINK_PATH, body: request)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def init_linked_notification_signature(request, document_number)
|
|
112
|
+
logger.debug("Starting linked notification-based signature session")
|
|
113
|
+
path = format(
|
|
114
|
+
LINKED_NOTIFICATION_SIGNATURE_WITH_DOCUMENT_NUMBER_PATH,
|
|
115
|
+
document_number: encode_path_segment(document_number)
|
|
116
|
+
)
|
|
117
|
+
post(path, body: request)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def init_notification_certificate_choice(request, semantics_identifier)
|
|
121
|
+
logger.debug("Starting notification-based certificate choice session")
|
|
122
|
+
path = format(
|
|
123
|
+
NOTIFICATION_CERTIFICATE_CHOICE_WITH_SEMANTIC_IDENTIFIER_PATH,
|
|
124
|
+
semantics_identifier: encode_path_segment(extract_identifier(semantics_identifier))
|
|
125
|
+
)
|
|
126
|
+
post(path, body: request)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def get_certificate_by_document_number(document_number, request)
|
|
130
|
+
logger.debug("Querying certificate by document number")
|
|
131
|
+
path = format(CERTIFICATE_BY_DOCUMENT_NUMBER_PATH, document_number: encode_path_segment(document_number))
|
|
132
|
+
post(path, body: request)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def init_device_link_signature(request, semantics_identifier)
|
|
136
|
+
logger.debug("Starting device link signature session with semantics identifier")
|
|
137
|
+
path = format(
|
|
138
|
+
DEVICE_LINK_SIGNATURE_WITH_SEMANTIC_IDENTIFIER_PATH,
|
|
139
|
+
semantics_identifier: encode_path_segment(extract_identifier(semantics_identifier))
|
|
140
|
+
)
|
|
141
|
+
post(path, body: request)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def init_device_link_signature_with_document(request, document_number)
|
|
145
|
+
logger.debug("Starting device link signature session with document number")
|
|
146
|
+
path = format(
|
|
147
|
+
DEVICE_LINK_SIGNATURE_WITH_DOCUMENT_NUMBER_PATH,
|
|
148
|
+
document_number: encode_path_segment(document_number)
|
|
149
|
+
)
|
|
150
|
+
post(path, body: request)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def init_notification_signature(request, semantics_identifier)
|
|
154
|
+
logger.debug("Starting notification signature session with semantics identifier")
|
|
155
|
+
path = format(
|
|
156
|
+
NOTIFICATION_SIGNATURE_WITH_SEMANTIC_IDENTIFIER_PATH,
|
|
157
|
+
semantics_identifier: encode_path_segment(extract_identifier(semantics_identifier))
|
|
158
|
+
)
|
|
159
|
+
post(path, body: request)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def init_notification_signature_with_document(request, document_number)
|
|
163
|
+
logger.debug("Starting notification signature session with document number")
|
|
164
|
+
path = format(
|
|
165
|
+
NOTIFICATION_SIGNATURE_WITH_DOCUMENT_NUMBER_PATH,
|
|
166
|
+
document_number: encode_path_segment(document_number)
|
|
167
|
+
)
|
|
168
|
+
post(path, body: request)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def connection
|
|
174
|
+
@connection ||= configured_connection ||
|
|
175
|
+
Faraday.new(url: host_url, ssl: ssl_options) do |faraday|
|
|
176
|
+
apply_connection_config(faraday)
|
|
177
|
+
faraday.response :raise_error
|
|
178
|
+
faraday.adapter Faraday.default_adapter
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def ssl_options
|
|
183
|
+
options = { verify: true }
|
|
184
|
+
return options unless ssl_context.respond_to?(:cert_store)
|
|
185
|
+
|
|
186
|
+
cert_store = ssl_context.cert_store
|
|
187
|
+
options[:cert_store] = cert_store if cert_store
|
|
188
|
+
options
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def apply_connection_config(faraday)
|
|
192
|
+
return unless network_connection_config.is_a?(Hash)
|
|
193
|
+
|
|
194
|
+
headers = network_connection_config[:headers]
|
|
195
|
+
faraday.headers.update(headers) if headers.is_a?(Hash)
|
|
196
|
+
|
|
197
|
+
request_opts = network_connection_config[:request]
|
|
198
|
+
configure_request_options(faraday.options, request_opts) if request_opts.is_a?(Hash)
|
|
199
|
+
|
|
200
|
+
configure_request_options(faraday.options, network_connection_config)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def configure_request_options(options, config)
|
|
204
|
+
timeout = config[:timeout]
|
|
205
|
+
open_timeout = config[:open_timeout]
|
|
206
|
+
write_timeout = config[:write_timeout]
|
|
207
|
+
|
|
208
|
+
options.timeout = timeout if timeout
|
|
209
|
+
options.open_timeout = open_timeout if open_timeout
|
|
210
|
+
options.write_timeout = write_timeout if write_timeout && options.respond_to?(:write_timeout=)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def get(path, query:, not_found_error: nil)
|
|
214
|
+
response = perform_request(:get, path, query: query)
|
|
215
|
+
parse_response(response)
|
|
216
|
+
rescue Faraday::ResourceNotFound
|
|
217
|
+
logger.warn("Session or resource not found for path #{path}")
|
|
218
|
+
raise(not_found_error || SmartIdRuby::Errors::UserAccountNotFoundError)
|
|
219
|
+
rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
|
|
220
|
+
logger.warn("Request is unauthorized for path #{path}: #{e.message}")
|
|
221
|
+
raise SmartIdRuby::Errors::RelyingPartyAccountConfigurationError, e.message
|
|
222
|
+
rescue Faraday::ClientError => e
|
|
223
|
+
handle_client_error(e)
|
|
224
|
+
rescue Faraday::ServerError => e
|
|
225
|
+
handle_server_error(e)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def post(path, body:)
|
|
229
|
+
response = perform_request(:post, path, body: body)
|
|
230
|
+
parse_response(response)
|
|
231
|
+
rescue Faraday::ResourceNotFound
|
|
232
|
+
logger.warn("User account not found for path #{path}")
|
|
233
|
+
raise SmartIdRuby::Errors::UserAccountNotFoundError
|
|
234
|
+
rescue Faraday::UnauthorizedError, Faraday::ForbiddenError => e
|
|
235
|
+
logger.warn("No permission to issue request for path #{path}: #{e.message}")
|
|
236
|
+
raise SmartIdRuby::Errors::RelyingPartyAccountConfigurationError, e.message
|
|
237
|
+
rescue Faraday::BadRequestError => e
|
|
238
|
+
logger.warn("Request is invalid for path #{path}: #{e.message}")
|
|
239
|
+
raise SmartIdRuby::Errors::RequestValidationError, e.message
|
|
240
|
+
rescue Faraday::ClientError => e
|
|
241
|
+
handle_client_error(e)
|
|
242
|
+
rescue Faraday::ServerError => e
|
|
243
|
+
handle_server_error(e)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def perform_request(method, path, query: nil, body: nil)
|
|
247
|
+
url = build_url(path)
|
|
248
|
+
headers = default_headers
|
|
249
|
+
logger.debug("#{method.to_s.upcase} #{url}")
|
|
250
|
+
if method == :post && logger.respond_to?(:debug?) && logger.debug?
|
|
251
|
+
logger.debug("Request body: #{safe_json_for_log(body)}")
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
response = if method == :get
|
|
255
|
+
connection.get(url, query, headers)
|
|
256
|
+
else
|
|
257
|
+
connection.post(url, JSON.generate(body), headers)
|
|
258
|
+
end
|
|
259
|
+
logger.debug("Response status: #{response.status}")
|
|
260
|
+
logger.debug("Response body: #{truncate_for_log(response.body)}")
|
|
261
|
+
response
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def build_url(path)
|
|
265
|
+
normalized_path = path.to_s.sub(%r{\A/+}, "")
|
|
266
|
+
URI.join(host_url, normalized_path).to_s
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def default_headers
|
|
270
|
+
{
|
|
271
|
+
"Accept" => "application/json",
|
|
272
|
+
"Content-Type" => "application/json",
|
|
273
|
+
"User-Agent" => "smart_id/#{SmartIdRuby::VERSION} (Ruby/#{RUBY_VERSION})"
|
|
274
|
+
}
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def parse_response(response)
|
|
278
|
+
return {} if response.body.nil? || response.body.to_s.strip.empty?
|
|
279
|
+
return response.body if response.body.is_a?(Hash)
|
|
280
|
+
|
|
281
|
+
JSON.parse(response.body)
|
|
282
|
+
rescue JSON::ParserError => e
|
|
283
|
+
raise SmartIdRuby::Errors::ResponseError, "Failed to parse Smart-ID response body: #{e.message}"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def handle_client_error(error)
|
|
287
|
+
status = error.response[:status].to_i
|
|
288
|
+
logger.debug("Client error response status=#{status}, body=#{truncate_for_log(error.response[:body])}")
|
|
289
|
+
case status
|
|
290
|
+
when 471
|
|
291
|
+
logger.warn("No suitable account of requested type found, but user has some other accounts")
|
|
292
|
+
raise SmartIdRuby::Errors::NoSuitableAccountOfRequestedTypeFoundError
|
|
293
|
+
when 472
|
|
294
|
+
logger.warn("Person should view Smart-ID app or Smart-ID self-service portal now")
|
|
295
|
+
raise SmartIdRuby::Errors::PersonShouldViewSmartIdPortalError
|
|
296
|
+
when 480
|
|
297
|
+
logger.warn("Client-side API is too old and not supported anymore")
|
|
298
|
+
raise SmartIdRuby::Errors::UnsupportedClientApiVersionError
|
|
299
|
+
else
|
|
300
|
+
logger.warn("Server refused the request: #{error.message}")
|
|
301
|
+
raise SmartIdRuby::Errors::RequestValidationError, error.message
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def handle_server_error(error)
|
|
306
|
+
status = error.response[:status].to_i
|
|
307
|
+
logger.debug("Server error response status=#{status}, body=#{truncate_for_log(error.response[:body])}")
|
|
308
|
+
if status == 580
|
|
309
|
+
logger.warn("Server is under maintenance, retry later")
|
|
310
|
+
raise SmartIdRuby::Errors::ServerMaintenanceError
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
logger.warn("Unexpected server error: #{error.message}")
|
|
314
|
+
raise SmartIdRuby::Errors::ResponseError, error.message
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def extract_identifier(semantics_identifier)
|
|
318
|
+
return semantics_identifier.identifier if semantics_identifier.respond_to?(:identifier)
|
|
319
|
+
|
|
320
|
+
semantics_identifier
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def encode_path_segment(value)
|
|
324
|
+
CGI.escape(value.to_s).gsub("+", "%20")
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def to_milliseconds(unit, value)
|
|
328
|
+
numeric = value.to_i
|
|
329
|
+
return numeric if numeric <= 0
|
|
330
|
+
|
|
331
|
+
case unit.to_sym
|
|
332
|
+
when :milliseconds
|
|
333
|
+
numeric
|
|
334
|
+
when :seconds
|
|
335
|
+
numeric * 1000
|
|
336
|
+
when :minutes
|
|
337
|
+
numeric * 60_000
|
|
338
|
+
when :hours
|
|
339
|
+
numeric * 3_600_000
|
|
340
|
+
else
|
|
341
|
+
numeric
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def logger
|
|
346
|
+
SmartIdRuby.logger
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def safe_json_for_log(value)
|
|
350
|
+
json = JSON.generate(value)
|
|
351
|
+
truncate_for_log(json)
|
|
352
|
+
rescue StandardError
|
|
353
|
+
"<unserializable request body>"
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def truncate_for_log(value, max = 2_000)
|
|
357
|
+
text = value.is_a?(String) ? value : value.inspect
|
|
358
|
+
return text if text.length <= max
|
|
359
|
+
|
|
360
|
+
"#{text[0, max]}...(truncated #{text.length - max} chars)"
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartIdRuby
|
|
4
|
+
module Rest
|
|
5
|
+
# Provides methods for querying session status and polling session status.
|
|
6
|
+
class SessionStatusPoller
|
|
7
|
+
DEFAULT_MAX_POLL_DURATION_SECONDS = 300
|
|
8
|
+
|
|
9
|
+
def initialize(connector)
|
|
10
|
+
@connector = connector
|
|
11
|
+
@polling_sleep_time_unit = :seconds
|
|
12
|
+
@polling_sleep_timeout = 1
|
|
13
|
+
@max_poll_duration_seconds = DEFAULT_MAX_POLL_DURATION_SECONDS
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def set_polling_sleep_time(unit, timeout)
|
|
17
|
+
logger.debug("Setting polling sleep time to #{timeout} #{unit}")
|
|
18
|
+
@polling_sleep_time_unit = unit
|
|
19
|
+
@polling_sleep_timeout = timeout
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def set_max_poll_duration(unit, timeout)
|
|
23
|
+
seconds = convert_to_seconds(unit, timeout.to_f)
|
|
24
|
+
@max_poll_duration_seconds = seconds.positive? ? seconds : 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def polling_sleep_time
|
|
28
|
+
{ unit: @polling_sleep_time_unit, timeout: @polling_sleep_timeout }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Loops session status query until state is COMPLETE.
|
|
32
|
+
def fetch_final_session_status(session_id)
|
|
33
|
+
logger.debug("Starting to poll session status for session #{session_id}")
|
|
34
|
+
poll_for_final_session_status(session_id)
|
|
35
|
+
rescue Interrupt => e
|
|
36
|
+
logger.error("Failed to poll session status: #{e.message}")
|
|
37
|
+
raise SmartIdRuby::Error, "Failed to poll session status"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Query session status once.
|
|
41
|
+
def get_session_status(session_id)
|
|
42
|
+
logger.debug("Querying session status")
|
|
43
|
+
@connector.get_session_status(session_id)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def poll_for_final_session_status(session_id)
|
|
49
|
+
started_at = monotonic_now
|
|
50
|
+
session_status = nil
|
|
51
|
+
while session_status.nil? || running?(session_status)
|
|
52
|
+
enforce_max_poll_duration!(started_at, session_id)
|
|
53
|
+
session_status = get_session_status(session_id)
|
|
54
|
+
break if complete?(session_status)
|
|
55
|
+
|
|
56
|
+
logger.debug("Sleeping for #{@polling_sleep_timeout} #{@polling_sleep_time_unit}")
|
|
57
|
+
sleep_for_poll_interval
|
|
58
|
+
end
|
|
59
|
+
logger.debug("Got final session status response")
|
|
60
|
+
session_status
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def running?(session_status)
|
|
64
|
+
session_state(session_status)&.casecmp("RUNNING")&.zero?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def complete?(session_status)
|
|
68
|
+
session_state(session_status)&.casecmp("COMPLETE")&.zero?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def session_state(session_status)
|
|
72
|
+
if session_status.respond_to?(:[])
|
|
73
|
+
session_status[:state] || session_status["state"]
|
|
74
|
+
elsif session_status.respond_to?(:state)
|
|
75
|
+
session_status.state
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def sleep_for_poll_interval
|
|
80
|
+
seconds = poll_interval_in_seconds
|
|
81
|
+
return if seconds <= 0
|
|
82
|
+
|
|
83
|
+
sleep(seconds)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def enforce_max_poll_duration!(started_at, session_id)
|
|
87
|
+
return if @max_poll_duration_seconds.nil? || @max_poll_duration_seconds <= 0
|
|
88
|
+
return if (monotonic_now - started_at) <= @max_poll_duration_seconds
|
|
89
|
+
|
|
90
|
+
raise SmartIdRuby::Errors::SessionNotCompleteError,
|
|
91
|
+
"Session #{session_id} did not complete within #{@max_poll_duration_seconds} seconds"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def monotonic_now
|
|
95
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def poll_interval_in_seconds
|
|
99
|
+
timeout = @polling_sleep_timeout.to_f
|
|
100
|
+
return 0 if timeout <= 0
|
|
101
|
+
|
|
102
|
+
convert_to_seconds(@polling_sleep_time_unit, timeout)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def convert_to_seconds(unit, timeout)
|
|
106
|
+
case unit&.to_sym
|
|
107
|
+
when :milliseconds
|
|
108
|
+
timeout / 1000.0
|
|
109
|
+
when :seconds
|
|
110
|
+
timeout
|
|
111
|
+
when :minutes
|
|
112
|
+
timeout * 60
|
|
113
|
+
when :hours
|
|
114
|
+
timeout * 3600
|
|
115
|
+
else
|
|
116
|
+
timeout
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def logger
|
|
121
|
+
SmartIdRuby.logger
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module SmartIdRuby
|
|
6
|
+
# Represents RP challenge bytes and helper encodings.
|
|
7
|
+
class RpChallenge
|
|
8
|
+
def initialize(value)
|
|
9
|
+
@value = normalize_value(value).freeze
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Returns a copy of the challenge bytes.
|
|
13
|
+
def value
|
|
14
|
+
@value.dup
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns challenge as a Base64-encoded string.
|
|
18
|
+
def to_base64_encoded_value
|
|
19
|
+
Base64.strict_encode64(@value)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# Accepts Ruby-friendly inputs and normalizes them into binary bytes.
|
|
25
|
+
def normalize_value(input)
|
|
26
|
+
case input
|
|
27
|
+
when String
|
|
28
|
+
input.dup.force_encoding(Encoding::BINARY)
|
|
29
|
+
when Array
|
|
30
|
+
input.pack("C*")
|
|
31
|
+
else
|
|
32
|
+
raise SmartIdRuby::Errors::RequestValidationError,
|
|
33
|
+
"Value for 'value' must be a binary String or an Array of bytes"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module SmartIdRuby
|
|
6
|
+
# Utility class for generating RP challenges.
|
|
7
|
+
class RpChallengeGenerator
|
|
8
|
+
MAX_LENGTH = 64
|
|
9
|
+
MIN_LENGTH = 32
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
# Generates an RP challenge with default length (64 bytes).
|
|
13
|
+
def generate(length = MAX_LENGTH)
|
|
14
|
+
validate_length!(length)
|
|
15
|
+
RpChallenge.new(SecureRandom.random_bytes(length))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def validate_length!(length)
|
|
21
|
+
return if length.is_a?(Integer) && length.between?(MIN_LENGTH, MAX_LENGTH)
|
|
22
|
+
|
|
23
|
+
raise SmartIdRuby::Errors::RequestValidationError,
|
|
24
|
+
"Length must be between #{MIN_LENGTH} and #{MAX_LENGTH}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartIdRuby
|
|
4
|
+
# Representation of Semantic Identifier.
|
|
5
|
+
class SemanticsIdentifier
|
|
6
|
+
module IdentityType
|
|
7
|
+
PAS = "PAS"
|
|
8
|
+
IDC = "IDC"
|
|
9
|
+
PNO = "PNO"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module CountryCode
|
|
13
|
+
EE = "EE"
|
|
14
|
+
LT = "LT"
|
|
15
|
+
LV = "LV"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :identifier
|
|
19
|
+
|
|
20
|
+
# Supports:
|
|
21
|
+
# - SemanticsIdentifier.new("PNO", "EE", "30303039914")
|
|
22
|
+
# - SemanticsIdentifier.new("PNOEE-30303039914")
|
|
23
|
+
def initialize(identity_type_or_identifier, country_code = nil, identity_number = nil)
|
|
24
|
+
@identifier =
|
|
25
|
+
if country_code.nil? && identity_number.nil?
|
|
26
|
+
identity_type_or_identifier
|
|
27
|
+
elsif !country_code.nil? && !identity_number.nil?
|
|
28
|
+
"#{identity_type_or_identifier}#{country_code}-#{identity_number}"
|
|
29
|
+
else
|
|
30
|
+
raise SmartIdRuby::Errors::RequestValidationError,
|
|
31
|
+
"Provide either full identifier or identityType + countryCode + identityNumber"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module SmartIdRuby
|
|
7
|
+
module Validation
|
|
8
|
+
# Validates authentication certificate structure, level and purpose.
|
|
9
|
+
class AuthenticationCertificateValidator
|
|
10
|
+
QUALIFIED_CERTIFICATE_POLICY_OIDS = ["1.3.6.1.4.1.10015.17.2", "0.4.0.2042.1.2"].freeze
|
|
11
|
+
CERTIFICATE_LEVEL_ORDER = {
|
|
12
|
+
"ADVANCED" => 1,
|
|
13
|
+
"QUALIFIED" => 2,
|
|
14
|
+
"QSCD" => 3
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def validate(cert:, requested_level:)
|
|
18
|
+
if cert.nil?
|
|
19
|
+
raise SmartIdRuby::Errors::UnprocessableResponseError, "Authentication session status field 'cert' is missing"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
validate_non_empty(cert.value, "cert.value")
|
|
23
|
+
validate_non_empty(cert.certificate_level, "cert.certificateLevel")
|
|
24
|
+
|
|
25
|
+
actual_level = cert.certificate_level.to_s.upcase
|
|
26
|
+
required_level = requested_level.to_s.upcase
|
|
27
|
+
unless CERTIFICATE_LEVEL_ORDER.key?(actual_level)
|
|
28
|
+
raise SmartIdRuby::Errors::UnprocessableResponseError,
|
|
29
|
+
"Authentication session status field 'cert.certificateLevel' has unsupported value"
|
|
30
|
+
end
|
|
31
|
+
unless CERTIFICATE_LEVEL_ORDER[actual_level] >= CERTIFICATE_LEVEL_ORDER.fetch(required_level, 2)
|
|
32
|
+
raise SmartIdRuby::Errors::UnprocessableResponseError, "Signer's certificate is below requested certificate level"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
certificate = parse_certificate(cert.value)
|
|
36
|
+
validate_certificate_is_currently_valid(certificate)
|
|
37
|
+
validate_certificate_purpose(certificate, actual_level)
|
|
38
|
+
certificate
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def parse_certificate(value)
|
|
44
|
+
der = Base64.decode64(value)
|
|
45
|
+
OpenSSL::X509::Certificate.new(der)
|
|
46
|
+
rescue OpenSSL::X509::CertificateError, ArgumentError
|
|
47
|
+
raise SmartIdRuby::Errors::UnprocessableResponseError, "Certificate is invalid"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def validate_certificate_is_currently_valid(certificate)
|
|
51
|
+
now = Time.now
|
|
52
|
+
return if certificate.not_before <= now && now <= certificate.not_after
|
|
53
|
+
|
|
54
|
+
raise SmartIdRuby::Errors::UnprocessableResponseError, "Certificate is invalid"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_certificate_purpose(certificate, actual_level)
|
|
58
|
+
return unless %w[QUALIFIED QSCD].include?(actual_level)
|
|
59
|
+
|
|
60
|
+
certificate_policy_oids = extract_certificate_policy_oids(certificate)
|
|
61
|
+
if certificate_policy_oids.empty?
|
|
62
|
+
raise SmartIdRuby::Errors::UnprocessableResponseError,
|
|
63
|
+
"Certificate does not have certificate policy OIDs and is not a qualified Smart-ID authentication certificate"
|
|
64
|
+
end
|
|
65
|
+
return if (QUALIFIED_CERTIFICATE_POLICY_OIDS - certificate_policy_oids).empty?
|
|
66
|
+
|
|
67
|
+
raise SmartIdRuby::Errors::UnprocessableResponseError,
|
|
68
|
+
"Certificate is not a qualified Smart-ID authentication certificate"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def extract_certificate_policy_oids(certificate)
|
|
72
|
+
extension = certificate.extensions.find { |ext| ext.oid == "certificatePolicies" }
|
|
73
|
+
return [] unless extension
|
|
74
|
+
|
|
75
|
+
extension.value.scan(/\b\d+(?:\.\d+)+\b/)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate_non_empty(value, field_name)
|
|
79
|
+
return unless blank?(value)
|
|
80
|
+
|
|
81
|
+
raise SmartIdRuby::Errors::UnprocessableResponseError,
|
|
82
|
+
"Authentication session status field '#{field_name}' is empty"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def blank?(value)
|
|
86
|
+
value.nil? || value.to_s.strip.empty?
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|