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,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module SmartIdRuby
|
|
9
|
+
# Utility helpers for creating and validating Smart-ID callback URLs and their tokens.
|
|
10
|
+
class CallbackUrlUtil
|
|
11
|
+
class << self
|
|
12
|
+
def create_callback_url(base_url)
|
|
13
|
+
raise SmartIdRuby::Errors::RequestSetupError, "Parameter for 'baseUrl' cannot be empty" if blank?(base_url)
|
|
14
|
+
|
|
15
|
+
url_token = SecureRandom.urlsafe_base64(32, false)
|
|
16
|
+
uri = URI.parse(base_url.to_s)
|
|
17
|
+
query = URI.decode_www_form(uri.query.to_s)
|
|
18
|
+
query << ["value", url_token]
|
|
19
|
+
uri.query = URI.encode_www_form(query)
|
|
20
|
+
SmartIdRuby::CallbackUrl.new(url: uri.to_s, token: url_token)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def validate_session_secret_digest(session_secret_digest, session_secret)
|
|
24
|
+
if blank?(session_secret_digest)
|
|
25
|
+
raise SmartIdRuby::Errors::RequestSetupError, "Parameter for 'sessionSecretDigest' cannot be empty"
|
|
26
|
+
end
|
|
27
|
+
if blank?(session_secret)
|
|
28
|
+
raise SmartIdRuby::Errors::RequestSetupError, "Parameter for 'sessionSecret' cannot be empty"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
calculated_session_secret_digest = calculate_digest(session_secret)
|
|
32
|
+
return if session_secret_digest.to_s == calculated_session_secret_digest
|
|
33
|
+
|
|
34
|
+
raise SmartIdRuby::Errors::SessionSecretMismatchError,
|
|
35
|
+
"Session secret digest from callback does not match calculated session secret digest"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def calculate_digest(session_secret)
|
|
41
|
+
decoded_session_secret = Base64.strict_decode64(session_secret)
|
|
42
|
+
digest = OpenSSL::Digest::SHA256.digest(decoded_session_secret)
|
|
43
|
+
Base64.urlsafe_encode64(digest, padding: false)
|
|
44
|
+
rescue ArgumentError => e
|
|
45
|
+
raise SmartIdRuby::Errors::RequestSetupError,
|
|
46
|
+
"Parameter 'sessionSecret' is not Base64-encoded value: #{e.message}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def blank?(value)
|
|
50
|
+
value.nil? || value.to_s.strip.empty?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartIdRuby
|
|
4
|
+
# Main entry point for using Smart-ID services.
|
|
5
|
+
class Client
|
|
6
|
+
attr_accessor :relying_party_uuid, :relying_party_name, :host_url,
|
|
7
|
+
:network_connection_config, :configured_connection
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@polling_sleep_timeout = 1
|
|
11
|
+
@polling_sleep_time_unit = :seconds
|
|
12
|
+
@session_status_response_socket_open_time = nil
|
|
13
|
+
@smart_id_connector = nil
|
|
14
|
+
@session_status_poller = nil
|
|
15
|
+
@ssl_context = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create_device_link_certificate_request
|
|
19
|
+
Flows::DeviceLinkCertificateChoiceSessionRequestBuilder.new(smart_id_connector)
|
|
20
|
+
.with_relying_party_uuid(relying_party_uuid)
|
|
21
|
+
.with_relying_party_name(relying_party_name)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def create_linked_notification_signature
|
|
25
|
+
Flows::LinkedNotificationSignatureSessionRequestBuilder.new(smart_id_connector)
|
|
26
|
+
.with_relying_party_uuid(relying_party_uuid)
|
|
27
|
+
.with_relying_party_name(relying_party_name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def create_notification_certificate_choice
|
|
31
|
+
Flows::NotificationCertificateChoiceSessionRequestBuilder.new(smart_id_connector)
|
|
32
|
+
.with_relying_party_uuid(relying_party_uuid)
|
|
33
|
+
.with_relying_party_name(relying_party_name)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def create_device_link_authentication
|
|
37
|
+
Flows::DeviceLinkAuthenticationSessionRequestBuilder.new(smart_id_connector)
|
|
38
|
+
.with_relying_party_uuid(relying_party_uuid)
|
|
39
|
+
.with_relying_party_name(relying_party_name)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def create_notification_authentication
|
|
43
|
+
Flows::NotificationAuthenticationSessionRequestBuilder.new(smart_id_connector)
|
|
44
|
+
.with_relying_party_uuid(relying_party_uuid)
|
|
45
|
+
.with_relying_party_name(relying_party_name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def create_device_link_signature
|
|
49
|
+
Flows::DeviceLinkSignatureSessionRequestBuilder.new(smart_id_connector)
|
|
50
|
+
.with_relying_party_uuid(relying_party_uuid)
|
|
51
|
+
.with_relying_party_name(relying_party_name)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def create_certificate_by_document_number
|
|
55
|
+
Flows::CertificateByDocumentNumberRequestBuilder.new(smart_id_connector)
|
|
56
|
+
.with_relying_party_uuid(relying_party_uuid)
|
|
57
|
+
.with_relying_party_name(relying_party_name)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def create_notification_signature
|
|
61
|
+
Flows::NotificationSignatureSessionRequestBuilder.new(smart_id_connector)
|
|
62
|
+
.with_relying_party_uuid(relying_party_uuid)
|
|
63
|
+
.with_relying_party_name(relying_party_name)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def session_status_poller
|
|
67
|
+
@session_status_poller ||= begin
|
|
68
|
+
poller = Rest::SessionStatusPoller.new(smart_id_connector)
|
|
69
|
+
poller.set_polling_sleep_time(@polling_sleep_time_unit, @polling_sleep_timeout)
|
|
70
|
+
poller
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def create_dynamic_content
|
|
75
|
+
DeviceLinkBuilder.new.with_relying_party_name(relying_party_name)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def set_polling_sleep_timeout(unit, timeout)
|
|
79
|
+
@polling_sleep_time_unit = unit
|
|
80
|
+
@polling_sleep_timeout = timeout
|
|
81
|
+
return unless @session_status_poller
|
|
82
|
+
|
|
83
|
+
@session_status_poller.set_polling_sleep_time(unit, timeout)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def set_session_status_response_socket_open_time(unit, value)
|
|
87
|
+
@session_status_response_socket_open_time = { unit: unit, value: value }
|
|
88
|
+
return unless @smart_id_connector
|
|
89
|
+
|
|
90
|
+
@smart_id_connector.set_session_status_response_socket_open_time(unit, value)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def trust_ssl_context=(ssl_context)
|
|
94
|
+
@ssl_context = ssl_context
|
|
95
|
+
@smart_id_connector = nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# rubocop:disable Metrics/MethodLength
|
|
99
|
+
def smart_id_connector
|
|
100
|
+
if host_url.nil? || host_url.to_s.strip.empty?
|
|
101
|
+
raise SmartIdRuby::Errors::RequestSetupError, "Value for 'host_url' cannot be empty"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
@smart_id_connector ||= begin
|
|
105
|
+
connector = Rest::Connector.new(
|
|
106
|
+
host_url: host_url,
|
|
107
|
+
configured_connection: configured_connection,
|
|
108
|
+
network_connection_config: network_connection_config,
|
|
109
|
+
ssl_context: @ssl_context
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if @session_status_response_socket_open_time
|
|
113
|
+
connector.set_session_status_response_socket_open_time(
|
|
114
|
+
@session_status_response_socket_open_time[:unit],
|
|
115
|
+
@session_status_response_socket_open_time[:value]
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
connector
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
# rubocop:enable Metrics/MethodLength
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ostruct"
|
|
4
|
+
|
|
5
|
+
# rubocop:disable Style/RescueModifier
|
|
6
|
+
module SmartIdRuby
|
|
7
|
+
# Mixin that provides global configuration for the Smart-ID Ruby client
|
|
8
|
+
# (e.g. relying party UUID/name, host URL, and network options).
|
|
9
|
+
module Configuration
|
|
10
|
+
DEFAULT_CONFIG = {
|
|
11
|
+
relying_party_uuid: nil,
|
|
12
|
+
relying_party_name: nil,
|
|
13
|
+
host_url: nil,
|
|
14
|
+
default_certificate_level: "ADVANCED",
|
|
15
|
+
poller_timeout_seconds: 10,
|
|
16
|
+
truststore_path: nil,
|
|
17
|
+
truststore_type: nil,
|
|
18
|
+
truststore_password: nil,
|
|
19
|
+
tls_config: nil,
|
|
20
|
+
network_connection_config: nil,
|
|
21
|
+
configured_connection: nil
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def configuration
|
|
25
|
+
@configuration ||= OpenStruct.new(DEFAULT_CONFIG)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def configure
|
|
29
|
+
yield(configuration)
|
|
30
|
+
reload_config
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def client
|
|
34
|
+
@client ||= build_client(configuration)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def reset_client!
|
|
38
|
+
@client = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def reload_config
|
|
42
|
+
reset_client!
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def build_client(config)
|
|
49
|
+
client = SmartIdRuby::Client.new
|
|
50
|
+
client.relying_party_uuid = config.relying_party_uuid
|
|
51
|
+
client.relying_party_name = config.relying_party_name
|
|
52
|
+
client.host_url = config.host_url
|
|
53
|
+
client.network_connection_config = config.network_connection_config
|
|
54
|
+
client.configured_connection = config.configured_connection
|
|
55
|
+
|
|
56
|
+
if config.poller_timeout_seconds
|
|
57
|
+
client.set_session_status_response_socket_open_time(:seconds, config.poller_timeout_seconds.to_i)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
ssl_context = build_ssl_context(config)
|
|
61
|
+
client.trust_ssl_context = ssl_context if ssl_context
|
|
62
|
+
|
|
63
|
+
client
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_ssl_context(config)
|
|
67
|
+
path = config.truststore_path
|
|
68
|
+
return nil if path.nil? || path.to_s.strip.empty?
|
|
69
|
+
|
|
70
|
+
truststore_type = normalize_truststore_type(config.truststore_type, path)
|
|
71
|
+
validate_truststore_configuration!(truststore_type, path, config.truststore_password)
|
|
72
|
+
|
|
73
|
+
cert_store = OpenSSL::X509::Store.new
|
|
74
|
+
cert_store.set_default_paths
|
|
75
|
+
|
|
76
|
+
if truststore_type == :pkcs12
|
|
77
|
+
add_pkcs12_certificates(cert_store, path, config.truststore_password)
|
|
78
|
+
else
|
|
79
|
+
validate_truststore_file!(path)
|
|
80
|
+
cert_store.add_file(path)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
ssl_context = OpenSSL::SSL::SSLContext.new
|
|
84
|
+
ssl_context.cert_store = cert_store
|
|
85
|
+
ssl_context
|
|
86
|
+
rescue Errno::ENOENT, OpenSSL::X509::StoreError, OpenSSL::PKCS12::PKCS12Error => e
|
|
87
|
+
log_debug("[SmartIdRuby] Truststore diagnostics for path=#{path}: #{truststore_diagnostics(path)}") rescue nil
|
|
88
|
+
raise SmartIdRuby::Error,
|
|
89
|
+
"Failed to load #{truststore_type || "truststore"} from '#{path}': #{e.message}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def normalize_truststore_type(value, path)
|
|
93
|
+
normalized = value.to_s.strip.upcase
|
|
94
|
+
if normalized.empty?
|
|
95
|
+
return path.to_s.downcase.end_with?(".p12", ".pfx") ? :pkcs12 : :pem
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
return :pem if normalized == "PEM"
|
|
99
|
+
return :pkcs12 if %w[PKCS12 P12].include?(normalized)
|
|
100
|
+
|
|
101
|
+
raise SmartIdRuby::Error, "Unsupported truststore_type '#{value}'. Supported types: PEM, PKCS12, P12"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def add_pkcs12_certificates(cert_store, path, password)
|
|
105
|
+
validate_truststore_configuration!(:pkcs12, path, password)
|
|
106
|
+
validate_truststore_file!(path)
|
|
107
|
+
|
|
108
|
+
pkcs12 = OpenSSL::PKCS12.new(File.binread(path), password)
|
|
109
|
+
certificates = [pkcs12.certificate, *Array(pkcs12.ca_certs)].compact
|
|
110
|
+
raise SmartIdRuby::Error, "PKCS12 truststore does not contain any certificates" if certificates.empty?
|
|
111
|
+
|
|
112
|
+
certificates.each { |certificate| cert_store.add_cert(certificate) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validate_truststore_configuration!(truststore_type, path, truststore_password)
|
|
116
|
+
return if path.nil? || path.to_s.strip.empty?
|
|
117
|
+
|
|
118
|
+
if truststore_type == :pkcs12 && truststore_password.to_s.empty?
|
|
119
|
+
log_debug("[SmartIdRuby] PKCS12 truststore password missing/empty. path=#{path}") rescue nil
|
|
120
|
+
raise SmartIdRuby::Error,
|
|
121
|
+
"PKCS12 truststore password is missing/empty for '#{path}'"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def validate_truststore_file!(path)
|
|
126
|
+
return if path.nil? || path.to_s.strip.empty?
|
|
127
|
+
|
|
128
|
+
unless File.exist?(path)
|
|
129
|
+
log_debug("[SmartIdRuby] Truststore file missing. path=#{path} diag=#{truststore_diagnostics(path)}") rescue nil
|
|
130
|
+
raise SmartIdRuby::Error,
|
|
131
|
+
"Truststore file does not exist at '#{path}'"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
return if File.file?(path)
|
|
135
|
+
|
|
136
|
+
log_debug("[SmartIdRuby] Truststore path is not a regular file. path=#{path} diag=#{truststore_diagnostics(path)}") rescue nil
|
|
137
|
+
raise SmartIdRuby::Error,
|
|
138
|
+
"Truststore path is not a regular file at '#{path}'"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def truststore_diagnostics(path)
|
|
142
|
+
return "path=nil" if path.nil?
|
|
143
|
+
|
|
144
|
+
dir = File.dirname(path) rescue nil
|
|
145
|
+
basename = File.basename(path) rescue nil
|
|
146
|
+
|
|
147
|
+
exists = File.exist?(path)
|
|
148
|
+
readable = exists ? File.readable?(path) : false
|
|
149
|
+
stat = File.stat(path) rescue nil
|
|
150
|
+
size = stat&.size
|
|
151
|
+
mode = stat&.mode
|
|
152
|
+
|
|
153
|
+
entries =
|
|
154
|
+
if dir && Dir.exist?(dir)
|
|
155
|
+
begin
|
|
156
|
+
Dir.entries(dir).sort.first(30)
|
|
157
|
+
rescue StandardError
|
|
158
|
+
[]
|
|
159
|
+
end
|
|
160
|
+
else
|
|
161
|
+
[]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
{
|
|
165
|
+
exists: exists,
|
|
166
|
+
readable: readable,
|
|
167
|
+
size_bytes: size,
|
|
168
|
+
mode: mode,
|
|
169
|
+
dir: dir,
|
|
170
|
+
basename: basename,
|
|
171
|
+
dir_entries_sample: entries
|
|
172
|
+
}.to_s
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def log_debug(message)
|
|
176
|
+
# Use the gem logger (defined in `smart_id_ruby.rb`) so logging behavior is consistent.
|
|
177
|
+
# Note: the logger level may be WARN by default when not running under Rails.
|
|
178
|
+
SmartIdRuby.logger.debug(message)
|
|
179
|
+
rescue StandardError
|
|
180
|
+
warn(message)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
# rubocop:enable Style/RescueModifier
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module SmartIdRuby
|
|
9
|
+
module DeviceLinkType
|
|
10
|
+
QR_CODE = "QR"
|
|
11
|
+
WEB_2_APP = "Web2App"
|
|
12
|
+
APP_2_APP = "App2App"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
module SessionType
|
|
16
|
+
AUTHENTICATION = "auth"
|
|
17
|
+
SIGNATURE = "sign"
|
|
18
|
+
CERTIFICATE_CHOICE = "cert"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Builder for creating Smart-ID device-link URIs and auth codes.
|
|
22
|
+
class DeviceLinkBuilder
|
|
23
|
+
ALLOWED_VERSION = "1.0"
|
|
24
|
+
DEFAULT_SCHEME_NAME = "smart-id"
|
|
25
|
+
DEFAULT_LANGUAGE = "eng"
|
|
26
|
+
SUPPORTED_DEVICE_LINK_TYPES = [
|
|
27
|
+
DeviceLinkType::QR_CODE,
|
|
28
|
+
DeviceLinkType::WEB_2_APP,
|
|
29
|
+
DeviceLinkType::APP_2_APP
|
|
30
|
+
].freeze
|
|
31
|
+
SUPPORTED_SESSION_TYPES = [
|
|
32
|
+
SessionType::AUTHENTICATION,
|
|
33
|
+
SessionType::SIGNATURE,
|
|
34
|
+
SessionType::CERTIFICATE_CHOICE
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
def initialize
|
|
38
|
+
@scheme_name = DEFAULT_SCHEME_NAME
|
|
39
|
+
@version = ALLOWED_VERSION
|
|
40
|
+
@lang = DEFAULT_LANGUAGE
|
|
41
|
+
@device_link_base = nil
|
|
42
|
+
@device_link_type = nil
|
|
43
|
+
@session_type = nil
|
|
44
|
+
@session_token = nil
|
|
45
|
+
@elapsed_seconds = nil
|
|
46
|
+
@digest = nil
|
|
47
|
+
@relying_party_name_base64 = nil
|
|
48
|
+
@brokered_rp_name_base64 = nil
|
|
49
|
+
@interactions = nil
|
|
50
|
+
@initial_callback_url = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def with_scheme_name(value)
|
|
54
|
+
@scheme_name = value
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def with_device_link_base(value)
|
|
59
|
+
@device_link_base = value
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def with_version(value)
|
|
64
|
+
@version = value
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def with_device_link_type(value)
|
|
69
|
+
@device_link_type = normalize_device_link_type(value)
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def with_session_type(value)
|
|
74
|
+
@session_type = normalize_session_type(value)
|
|
75
|
+
self
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def with_session_token(value)
|
|
79
|
+
@session_token = value
|
|
80
|
+
self
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def with_elapsed_seconds(value)
|
|
84
|
+
@elapsed_seconds = value
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def with_lang(value)
|
|
89
|
+
@lang = value
|
|
90
|
+
self
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def with_digest(value)
|
|
94
|
+
@digest = value
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def with_relying_party_name(value)
|
|
99
|
+
@relying_party_name_base64 = Base64.strict_encode64(value.to_s.dup.force_encoding("UTF-8"))
|
|
100
|
+
self
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def with_brokered_rp_name(value)
|
|
104
|
+
@brokered_rp_name_base64 = Base64.strict_encode64(value.to_s.dup.force_encoding("UTF-8"))
|
|
105
|
+
self
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def with_interactions(value)
|
|
109
|
+
@interactions = encode_interactions(value)
|
|
110
|
+
self
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def with_initial_callback_url(value)
|
|
114
|
+
@initial_callback_url = value
|
|
115
|
+
self
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def create_unprotected_uri
|
|
119
|
+
validate_input_parameters!
|
|
120
|
+
|
|
121
|
+
query_params = [
|
|
122
|
+
["deviceLinkType", @device_link_type]
|
|
123
|
+
]
|
|
124
|
+
query_params << ["elapsedSeconds", @elapsed_seconds.to_s] unless @elapsed_seconds.nil?
|
|
125
|
+
query_params.concat(
|
|
126
|
+
[
|
|
127
|
+
["sessionToken", @session_token],
|
|
128
|
+
["sessionType", @session_type],
|
|
129
|
+
["version", @version],
|
|
130
|
+
["lang", @lang]
|
|
131
|
+
]
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
uri = append_query_params(@device_link_base, query_params)
|
|
135
|
+
logger.debug("Created unprotected device link URI=#{sanitize_uri(uri)}")
|
|
136
|
+
uri
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def build_device_link(session_secret)
|
|
140
|
+
unprotected_uri = create_unprotected_uri
|
|
141
|
+
logger.debug("Building protected device link with scheme=#{@scheme_name}, session_type=#{@session_type}, device_link_type=#{@device_link_type}")
|
|
142
|
+
auth_code = generate_auth_code(unprotected_uri.to_s, session_secret)
|
|
143
|
+
uri = append_query_params(unprotected_uri.to_s, [["authCode", auth_code]])
|
|
144
|
+
logger.debug("Built protected device link URI=#{sanitize_uri(uri)}")
|
|
145
|
+
uri
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def validate_input_parameters!
|
|
151
|
+
raise_request_setup_error("Parameter 'deviceLinkBase' cannot be empty") if blank?(@device_link_base)
|
|
152
|
+
raise_request_setup_error("Parameter 'version' cannot be empty") if blank?(@version)
|
|
153
|
+
raise_request_setup_error("Only version 1.0 is allowed") unless @version == ALLOWED_VERSION
|
|
154
|
+
raise_request_setup_error("Parameter 'deviceLinkType' must be set") if blank?(@device_link_type)
|
|
155
|
+
raise_request_setup_error("Parameter 'sessionType' must be set") if blank?(@session_type)
|
|
156
|
+
raise_request_setup_error("Parameter 'sessionToken' cannot be empty") if blank?(@session_token)
|
|
157
|
+
raise_request_setup_error("Parameter 'lang' must be set") if blank?(@lang)
|
|
158
|
+
|
|
159
|
+
if @device_link_type == DeviceLinkType::QR_CODE && @elapsed_seconds.nil?
|
|
160
|
+
raise_request_setup_error("Parameter 'elapsedSeconds' must be set when 'deviceLinkType' is QR_CODE")
|
|
161
|
+
end
|
|
162
|
+
if @device_link_type != DeviceLinkType::QR_CODE && !@elapsed_seconds.nil?
|
|
163
|
+
raise_request_setup_error("Parameter 'elapsedSeconds' should only be used when 'deviceLinkType' is QR_CODE")
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def validate_auth_code_params!
|
|
168
|
+
raise_request_setup_error("Parameter 'schemeName' cannot be empty") if blank?(@scheme_name)
|
|
169
|
+
raise_request_setup_error("Parameter 'relyingPartyName' cannot be empty") if blank?(@relying_party_name_base64)
|
|
170
|
+
|
|
171
|
+
has_callback = !blank?(@initial_callback_url)
|
|
172
|
+
if @device_link_type == DeviceLinkType::QR_CODE && has_callback
|
|
173
|
+
raise_request_setup_error("Parameter 'initialCallbackUrl' must be empty when 'deviceLinkType' is QR_CODE")
|
|
174
|
+
end
|
|
175
|
+
if [DeviceLinkType::APP_2_APP, DeviceLinkType::WEB_2_APP].include?(@device_link_type) && !has_callback
|
|
176
|
+
raise_request_setup_error("Parameter 'initialCallbackUrl' must be provided when 'deviceLinkType' is APP_2_APP or WEB_2_APP")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
if [SessionType::AUTHENTICATION, SessionType::SIGNATURE].include?(@session_type)
|
|
180
|
+
raise_request_setup_error("Parameter 'digest' must be set when 'sessionType' is AUTHENTICATION or SIGNATURE") if blank?(@digest)
|
|
181
|
+
raise_request_setup_error("Parameter 'interactions' must be set when 'sessionType' is AUTHENTICATION or SIGNATURE") if blank?(@interactions)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
if @session_type == SessionType::CERTIFICATE_CHOICE
|
|
185
|
+
raise_request_setup_error("Parameter 'digest' must be empty when 'sessionType' is CERTIFICATE_CHOICE") unless blank?(@digest)
|
|
186
|
+
raise_request_setup_error("Parameter 'interactions' must be empty when 'sessionType' is CERTIFICATE_CHOICE") unless blank?(@interactions)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def generate_auth_code(unprotected_link, session_secret_base64)
|
|
191
|
+
raise_request_setup_error("Parameter 'sessionSecret' cannot be empty") if blank?(session_secret_base64)
|
|
192
|
+
|
|
193
|
+
validate_auth_code_params!
|
|
194
|
+
|
|
195
|
+
payload = [
|
|
196
|
+
@scheme_name,
|
|
197
|
+
signature_protocol_for_session,
|
|
198
|
+
or_empty(@digest),
|
|
199
|
+
@relying_party_name_base64,
|
|
200
|
+
or_empty(@brokered_rp_name_base64),
|
|
201
|
+
or_empty(@interactions),
|
|
202
|
+
or_empty(@initial_callback_url),
|
|
203
|
+
unprotected_link
|
|
204
|
+
].join("|")
|
|
205
|
+
logger.debug("Generating authCode payload metadata scheme=#{@scheme_name},
|
|
206
|
+
protocol=#{signature_protocol_for_session}, has_digest=#{!blank?(@digest)},
|
|
207
|
+
has_interactions=#{!blank?(@interactions)}, has_callback=#{!blank?(@initial_callback_url)}")
|
|
208
|
+
|
|
209
|
+
session_secret = Base64.decode64(session_secret_base64)
|
|
210
|
+
hmac = OpenSSL::HMAC.digest(
|
|
211
|
+
"SHA256", session_secret, payload
|
|
212
|
+
)
|
|
213
|
+
Base64.urlsafe_encode64(hmac).sub(/=*$/, '')
|
|
214
|
+
rescue OpenSSL::HMACError, ArgumentError => e
|
|
215
|
+
raise SmartIdRuby::Errors::RequestSetupError, "Failed to calculate authCode: #{e.message}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def signature_protocol_for_session
|
|
219
|
+
case @session_type
|
|
220
|
+
when SessionType::AUTHENTICATION then "ACSP_V2"
|
|
221
|
+
when SessionType::SIGNATURE then "RAW_DIGEST_SIGNATURE"
|
|
222
|
+
when SessionType::CERTIFICATE_CHOICE then ""
|
|
223
|
+
else ""
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def append_query_params(base_url, new_params)
|
|
228
|
+
uri = URI.parse(base_url.to_s)
|
|
229
|
+
params = URI.decode_www_form(uri.query.to_s)
|
|
230
|
+
params.concat(new_params)
|
|
231
|
+
uri.query = URI.encode_www_form(params)
|
|
232
|
+
uri
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def encode_interactions(value)
|
|
236
|
+
return nil if value.nil?
|
|
237
|
+
return value if value.is_a?(String)
|
|
238
|
+
|
|
239
|
+
interactions = Array(value).compact.map do |interaction|
|
|
240
|
+
if interaction.respond_to?(:to_h)
|
|
241
|
+
interaction.to_h
|
|
242
|
+
elsif interaction.is_a?(Hash)
|
|
243
|
+
interaction
|
|
244
|
+
else
|
|
245
|
+
raise_request_setup_error("Unsupported interaction object type: #{interaction.class}")
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
Base64.strict_encode64(JSON.generate(interactions))
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def normalize_device_link_type(value)
|
|
253
|
+
return value if value.nil?
|
|
254
|
+
|
|
255
|
+
normalized = value.to_s
|
|
256
|
+
return normalized if SUPPORTED_DEVICE_LINK_TYPES.include?(normalized)
|
|
257
|
+
|
|
258
|
+
raise_request_setup_error("Unsupported device link type: #{value}")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def normalize_session_type(value)
|
|
262
|
+
return value if value.nil?
|
|
263
|
+
|
|
264
|
+
normalized = value.to_s
|
|
265
|
+
return normalized if SUPPORTED_SESSION_TYPES.include?(normalized)
|
|
266
|
+
|
|
267
|
+
raise_request_setup_error("Unsupported session type: #{value}")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def or_empty(value)
|
|
271
|
+
value.nil? ? "" : value
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def blank?(value)
|
|
275
|
+
value.nil? || value.to_s.strip.empty?
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def raise_request_setup_error(message)
|
|
279
|
+
raise SmartIdRuby::Errors::RequestSetupError, message
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def logger
|
|
283
|
+
SmartIdRuby.logger
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def sanitize_uri(uri)
|
|
287
|
+
parsed = URI.parse(uri.to_s)
|
|
288
|
+
query = URI.decode_www_form(parsed.query.to_s).map do |key, value|
|
|
289
|
+
[key, sensitive_param?(key) ? "[FILTERED]" : value]
|
|
290
|
+
end
|
|
291
|
+
parsed.query = URI.encode_www_form(query)
|
|
292
|
+
parsed.to_s
|
|
293
|
+
rescue StandardError
|
|
294
|
+
uri.to_s
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def sensitive_param?(name)
|
|
298
|
+
%w[sessionToken authCode].include?(name.to_s)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|