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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +14 -0
  4. data/CHANGELOG.md +13 -0
  5. data/CODE_OF_CONDUCT.md +132 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +436 -0
  8. data/Rakefile +12 -0
  9. data/lib/smart-id-ruby-client.rb +3 -0
  10. data/lib/smart_id_ruby/callback_url.rb +18 -0
  11. data/lib/smart_id_ruby/callback_url_util.rb +54 -0
  12. data/lib/smart_id_ruby/client.rb +124 -0
  13. data/lib/smart_id_ruby/configuration.rb +184 -0
  14. data/lib/smart_id_ruby/device_link_builder.rb +301 -0
  15. data/lib/smart_id_ruby/device_link_interaction.rb +67 -0
  16. data/lib/smart_id_ruby/errors/certificate_level_mismatch_error.rb +8 -0
  17. data/lib/smart_id_ruby/errors/document_unusable_error.rb +12 -0
  18. data/lib/smart_id_ruby/errors/error.rb +8 -0
  19. data/lib/smart_id_ruby/errors/expected_linked_session_error.rb +15 -0
  20. data/lib/smart_id_ruby/errors/no_suitable_account_of_requested_type_found_error.rb +10 -0
  21. data/lib/smart_id_ruby/errors/person_should_view_smart_id_portal_error.rb +8 -0
  22. data/lib/smart_id_ruby/errors/protocol_failure_error.rb +13 -0
  23. data/lib/smart_id_ruby/errors/relying_party_account_configuration_error.rb +10 -0
  24. data/lib/smart_id_ruby/errors/request_setup_error.rb +10 -0
  25. data/lib/smart_id_ruby/errors/request_validation_error.rb +8 -0
  26. data/lib/smart_id_ruby/errors/required_interaction_not_supported_by_app_error.rb +13 -0
  27. data/lib/smart_id_ruby/errors/response_error.rb +8 -0
  28. data/lib/smart_id_ruby/errors/server_maintenance_error.rb +8 -0
  29. data/lib/smart_id_ruby/errors/session_end_result_error.rb +15 -0
  30. data/lib/smart_id_ruby/errors/session_not_complete_error.rb +8 -0
  31. data/lib/smart_id_ruby/errors/session_not_found_error.rb +8 -0
  32. data/lib/smart_id_ruby/errors/session_secret_mismatch_error.rb +8 -0
  33. data/lib/smart_id_ruby/errors/session_timeout_error.rb +12 -0
  34. data/lib/smart_id_ruby/errors/smart_id_server_error.rb +12 -0
  35. data/lib/smart_id_ruby/errors/unprocessable_response_error.rb +9 -0
  36. data/lib/smart_id_ruby/errors/unsupported_client_api_version_error.rb +8 -0
  37. data/lib/smart_id_ruby/errors/user_account_not_found_error.rb +8 -0
  38. data/lib/smart_id_ruby/errors/user_account_unusable_error.rb +12 -0
  39. data/lib/smart_id_ruby/errors/user_refused_cert_choice_error.rb +14 -0
  40. data/lib/smart_id_ruby/errors/user_refused_confirmation_message_error.rb +13 -0
  41. data/lib/smart_id_ruby/errors/user_refused_confirmation_message_with_verification_choice_error.rb +13 -0
  42. data/lib/smart_id_ruby/errors/user_refused_display_text_and_pin_error.rb +13 -0
  43. data/lib/smart_id_ruby/errors/user_refused_error.rb +12 -0
  44. data/lib/smart_id_ruby/errors/user_selected_wrong_verification_code_error.rb +13 -0
  45. data/lib/smart_id_ruby/errors.rb +31 -0
  46. data/lib/smart_id_ruby/flows/base_builder.rb +90 -0
  47. data/lib/smart_id_ruby/flows/certificate_by_document_number_request_builder.rb +130 -0
  48. data/lib/smart_id_ruby/flows/device_link_authentication_session_request_builder.rb +208 -0
  49. data/lib/smart_id_ruby/flows/device_link_certificate_choice_session_request_builder.rb +112 -0
  50. data/lib/smart_id_ruby/flows/device_link_signature_session_request_builder.rb +286 -0
  51. data/lib/smart_id_ruby/flows/linked_notification_signature_session_request_builder.rb +235 -0
  52. data/lib/smart_id_ruby/flows/notification_authentication_session_request_builder.rb +184 -0
  53. data/lib/smart_id_ruby/flows/notification_certificate_choice_session_request_builder.rb +96 -0
  54. data/lib/smart_id_ruby/flows/notification_signature_session_request_builder.rb +272 -0
  55. data/lib/smart_id_ruby/models/authentication_identity.rb +19 -0
  56. data/lib/smart_id_ruby/models/authentication_response.rb +38 -0
  57. data/lib/smart_id_ruby/models/certificate_choice_response.rb +19 -0
  58. data/lib/smart_id_ruby/models/device_link_session_response.rb +34 -0
  59. data/lib/smart_id_ruby/models/notification_authentication_session_response.rb +25 -0
  60. data/lib/smart_id_ruby/models/notification_certificate_choice_session_response.rb +25 -0
  61. data/lib/smart_id_ruby/models/notification_signature_session_response.rb +29 -0
  62. data/lib/smart_id_ruby/models/session_status.rb +261 -0
  63. data/lib/smart_id_ruby/models/signature_response.rb +38 -0
  64. data/lib/smart_id_ruby/notification_interaction.rb +70 -0
  65. data/lib/smart_id_ruby/qr_code_generator.rb +65 -0
  66. data/lib/smart_id_ruby/rest/connector.rb +364 -0
  67. data/lib/smart_id_ruby/rest/session_status_poller.rb +125 -0
  68. data/lib/smart_id_ruby/rp_challenge.rb +37 -0
  69. data/lib/smart_id_ruby/rp_challenge_generator.rb +28 -0
  70. data/lib/smart_id_ruby/semantics_identifier.rb +35 -0
  71. data/lib/smart_id_ruby/validation/authentication_certificate_validator.rb +90 -0
  72. data/lib/smart_id_ruby/validation/authentication_identity_mapper.rb +227 -0
  73. data/lib/smart_id_ruby/validation/base_authentication_response_validator.rb +304 -0
  74. data/lib/smart_id_ruby/validation/certificate_choice_response_validator.rb +104 -0
  75. data/lib/smart_id_ruby/validation/certificate_validator.rb +170 -0
  76. data/lib/smart_id_ruby/validation/device_link_authentication_response_validator.rb +76 -0
  77. data/lib/smart_id_ruby/validation/error_result_handler.rb +88 -0
  78. data/lib/smart_id_ruby/validation/notification_authentication_response_validator.rb +16 -0
  79. data/lib/smart_id_ruby/validation/signature_payload_builder.rb +62 -0
  80. data/lib/smart_id_ruby/validation/signature_response_validator.rb +345 -0
  81. data/lib/smart_id_ruby/validation/signature_value_validator.rb +76 -0
  82. data/lib/smart_id_ruby/validation/trusted_ca_cert_store.rb +20 -0
  83. data/lib/smart_id_ruby/verification_code_calculator.rb +31 -0
  84. data/lib/smart_id_ruby/version.rb +5 -0
  85. data/lib/smart_id_ruby.rb +76 -0
  86. 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