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,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