usesmileid 12.1.0 → 13.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 896bc049c137997c9de0181582b1bb2564e96fbba5bbbac64b640363e7f472b2
4
- data.tar.gz: 7f8bda781a38427a40550e95af93dc0286523b2b41e30a0cc37ef482b6752b8b
3
+ metadata.gz: f64d6fd177105edbffe5bceac374c675140d49a0da20d8e56041ae156429dda3
4
+ data.tar.gz: 810132bb74281566a6957f2ae76dd647d0e685f1423a71db26780dbf9351076f
5
5
  SHA512:
6
- metadata.gz: e0e6772c8b7f6466b8807943dbda2cc72e8e52564ffe745faf9b9bc5cb2623dd43ecae662fab88974ecb98ae780fd713cacc11014bd23157e458b10c5709f17f
7
- data.tar.gz: 94a7ac44229e2d2e43c722a2113a72d589db9a8242ca5f4ec7b1febd7828f35841d15fe1183c713bd91468e45d569f1403a6f392d7f96b13d82e8f00a6ee7fc8
6
+ metadata.gz: 856a7d6b04e054b1084d21dd35822274c2f0fcced663944ad01b6c6446df160e1f58237a5686c9f0824bd64e6cdf9de32b9b131fd1db68d4f34287998a9da0f4
7
+ data.tar.gz: 306285d436a657d8bbafe633b101cebb9f5e6e955b2fe60029d6ff08e3cc0c5665fb58edfa78037323f7f23563897012e4fff02fd7b2d6810cb01c5b2f6bb427
data/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [13.0.0] - 2026-11-09
11
+
12
+ Version 12.2.0 was never published. Its changes are in this release, which is a major version because it breaks the 12.1.0 API.
13
+
14
+ ### Breaking changes
15
+
16
+ - `biometric.authenticate` no longer takes `use_enrolled_image:`. Use `biometric_kyc.verify(use_enrolled_image: true, user_id: ...)` instead.
17
+ - `biometric.authenticate` now requires `selfie_image:` and `liveness_images:`. Pass both on every call.
18
+ - `SmileID::TokenManager` has been removed. The client now mints a token for each request, so you do not need to manage tokens.
19
+
20
+ ### Changed
21
+
22
+ - Each request mints its own token, bound to that request's `user_id` and product, instead of reusing one cached token. A 401 is no longer retried with a refreshed token.
23
+ - A `user_id` that is empty or padded with whitespace raises `ValidationError` before any request is sent.
24
+ - `biometric.authenticate` takes `selfie_image:` and `liveness_images:` as required keywords.
25
+
26
+ ### Added
27
+
28
+ - `biometric_kyc.verify(use_enrolled_image: true, user_id: ...)` verifies against the user's enrolled selfie; the grant is bound into the job's token and the selfie and liveness images may be omitted.
29
+
30
+ ### Removed
31
+
32
+ - `use_enrolled_image` from `biometric.authenticate`; selfie and liveness images are now always required.
33
+
10
34
  ## [12.1.0] - 2026-10-01
11
35
 
12
36
  ### Added
@@ -41,6 +65,7 @@ First public release.
41
65
  - Typed errors under `SmileID::Errors`, keyed on HTTP status.
42
66
  - Bank codes, supported ID types and supported documents lookups.
43
67
 
44
- [Unreleased]: https://github.com/smileidentity/smileid-sdk-ruby/compare/v12.1.0...HEAD
68
+ [Unreleased]: https://github.com/smileidentity/smileid-sdk-ruby/compare/v13.0.0...HEAD
69
+ [13.0.0]: https://github.com/smileidentity/smileid-sdk-ruby/compare/v12.1.0...v13.0.0
45
70
  [12.1.0]: https://github.com/smileidentity/smileid-sdk-ruby/compare/v12.0.0...v12.1.0
46
71
  [12.0.0]: https://github.com/smileidentity/smileid-sdk-ruby/releases/tag/v12.0.0
data/README.md CHANGED
@@ -40,7 +40,7 @@ smile = SmileID::Client.new(
40
40
 
41
41
  Partner ids are displayed zero-padded (for example 002) but must be passed without leading zeros (2).
42
42
 
43
- Authentication is internal: the SDK fetches a short-lived token from the API, caches it until just before expiry, and refreshes it once automatically if a request returns 401. You never see or pass a token.
43
+ Authentication is internal: before each request the SDK fetches a short-lived token from the API. A job's token is bound to that job's product and, when the job names one, its user; other calls get a token with neither. A 401 raises `AuthenticationError` without a retry. You never see or pass a token.
44
44
 
45
45
  ### Environment selection
46
46
 
@@ -177,6 +177,20 @@ accepted = smile.biometric_kyc.verify(
177
177
  )
178
178
  ```
179
179
 
180
+ To verify against a user's enrolled selfie instead of a fresh capture, set `use_enrolled_image: true` and pass the enrolled `user_id`; the selfie and liveness images may then be omitted.
181
+
182
+ ```ruby
183
+ accepted = smile.biometric_kyc.verify(
184
+ use_enrolled_image: true,
185
+ user_id: "user-42",
186
+ country: "NG",
187
+ id_type: "NIN",
188
+ id_number: "12345678901",
189
+ user_details: user_details,
190
+ consent: consent
191
+ )
192
+ ```
193
+
180
194
  ### Biometric enrollment
181
195
 
182
196
  ```ruby
@@ -192,7 +206,7 @@ accepted = smile.biometric.enroll(
192
206
 
193
207
  ### Biometric authentication
194
208
 
195
- `user_id` is required and must match an enrolled user. Images are required unless you set `use_enrolled_image: true`.
209
+ `user_id` is required and must match an enrolled user. The selfie and liveness images are always required.
196
210
 
197
211
  ```ruby
198
212
  accepted = smile.biometric.authenticate(
@@ -296,7 +310,7 @@ Every API failure raises a typed error under `SmileID::Errors`, keyed on the HTT
296
310
  | Error | Raised on |
297
311
  |---|---|
298
312
  | `InvalidRequestError` | 400, 415, and failed client-side validation (`ValidationError` subclass) |
299
- | `AuthenticationError` | 401 after one automatic token refresh |
313
+ | `AuthenticationError` | 401 |
300
314
  | `PaymentRequiredError` | 402 — insufficient wallet balance |
301
315
  | `PermissionError` | 403 |
302
316
  | `NotFoundError` | 404 (except `verifications.retrieve`, which returns a `not_found` status) |
@@ -7,6 +7,19 @@ module SmileID
7
7
  # The Smile ID client (spec section 2.1, 4). Construct once, then call
8
8
  # resource verbs, e.g. client.enhanced_kyc.verify(...).
9
9
  class Client
10
+ # Mirrors the API authorizer: a token bound to a product is accepted only on
11
+ # that product's endpoint. Paths absent here mint a token with no claims.
12
+ PRODUCT_BY_PATH = {
13
+ '/v3/enhanced_kyc' => 'enhanced_kyc',
14
+ '/v3/document_verification' => 'document_verification',
15
+ '/v3/enhanced_document_verification' => 'enhanced_document_verification',
16
+ '/v3/residency_document_verification' => 'residency_document_verification',
17
+ '/v3/biometric_kyc' => 'biometric_kyc',
18
+ '/v3/registration' => 'smart_selfie_registration',
19
+ '/v3/authentication' => 'smart_selfie_authentication',
20
+ '/v3/compare' => 'smart_selfie_compare'
21
+ }.freeze
22
+
10
23
  attr_reader :config
11
24
 
12
25
  def initialize(partner_id:, api_key:, environment: :sandbox,
@@ -18,7 +31,6 @@ module SmileID
18
31
  base_url: base_url, timeout: timeout, max_retries: max_retries, http_client: http_client
19
32
  )
20
33
  @transport = Transport.new(@config)
21
- @token_manager = TokenManager.new(@config, @transport)
22
34
  end
23
35
 
24
36
  def enhanced_kyc
@@ -49,32 +61,25 @@ module SmileID
49
61
  @services ||= Resources::Services.new(self)
50
62
  end
51
63
 
52
- # Execute an operation end to end: build the request, attach auth and
53
- # telemetry, sign if configured, send it, refresh-on-401 once, and either
54
- # return the response or raise a typed error (spec section 2A, 7).
64
+ # Execute an operation end to end: mint its token, build the request, attach
65
+ # auth and telemetry, send it, and either return the response or raise a
66
+ # typed error (spec section 2A, 7). Every authenticated call mints its own
67
+ # token; retries of an idempotent call reuse it.
55
68
  #
56
69
  # @return [SmileID::Response] on a success status for the operation.
57
- def call(op_key, form: nil, path_params: {}, query: {}, user_id_header: nil, timeout: nil)
70
+ def call(op_key, form: nil, path_params: {}, query: {}, user_id_header: nil, timeout: nil,
71
+ use_enrolled_image: false)
58
72
  op = Generated::Operations.fetch(op_key)
59
73
  url = @config.base_url + interpolate(op.path, path_params)
60
74
  content_type, body = serialize(op, form)
61
- refreshed = false
62
-
63
- loop do
64
- headers = build_headers(op, content_type, user_id_header)
65
- response = @transport.send_request(
66
- method: op.http_method, url: url, headers: headers, query: query || {},
67
- body: body, retryable: op.idempotent, timeout: timeout
68
- )
75
+ token = mint_token(op, form, user_id_header, use_enrolled_image, timeout) if op.authenticated
69
76
 
70
- if response.status == 401 && op.authenticated && !refreshed
71
- @token_manager.force_refresh
72
- refreshed = true
73
- next
74
- end
75
-
76
- return handle(op, response)
77
- end
77
+ headers = build_headers(op, content_type, user_id_header, token)
78
+ response = @transport.send_request(
79
+ method: op.http_method, url: url, headers: headers, query: query || {},
80
+ body: body, retryable: op.idempotent, timeout: timeout
81
+ )
82
+ handle(op, response)
78
83
  end
79
84
 
80
85
  private
@@ -88,15 +93,86 @@ module SmileID
88
93
  Helpers::Multipart.build(form)
89
94
  end
90
95
 
91
- def build_headers(op, content_type, user_id_header)
96
+ def build_headers(op, content_type, user_id_header, token)
92
97
  headers = Telemetry.headers
93
98
  headers['Content-Type'] = content_type if content_type
94
- headers['SmileID-Token'] = @token_manager.token if op.authenticated
99
+ headers['SmileID-Token'] = token if token
95
100
  headers['SmileID-Partner-ID'] = @config.partner_id if op.partner_id_header
96
101
  headers['User-ID'] = user_id_header if user_id_header
97
102
  headers
98
103
  end
99
104
 
105
+ # The token is bound to this one request's user and product. A grant
106
+ # (use_enrolled_image) authorises a captureless job for one user, so it must
107
+ # never reach another call's token.
108
+ def mint_token(op, form, user_id_header, use_enrolled_image, timeout)
109
+ product = PRODUCT_BY_PATH[op.path]
110
+ user_id = job_user_id(form, user_id_header) if product
111
+ if use_enrolled_image && user_id.nil?
112
+ raise Errors::ValidationError.new('user_id is required when use_enrolled_image is true')
113
+ end
114
+
115
+ claims = { 'user_id' => user_id, 'product' => product,
116
+ 'use_enrolled_image' => (true if use_enrolled_image) }.compact
117
+ request_token(claims, timeout)
118
+ end
119
+
120
+ def request_token(claims, timeout)
121
+ content_type, body = Helpers::Multipart.build(claims) unless claims.empty?
122
+ response = @transport.send_request(
123
+ method: :post,
124
+ url: "#{@config.base_url}/v3/token",
125
+ headers: token_headers(content_type),
126
+ body: body,
127
+ retryable: true,
128
+ timeout: timeout
129
+ )
130
+
131
+ unless response.status == 200
132
+ raise Errors.from_response(
133
+ status_code: response.status,
134
+ body: response.json,
135
+ raw_body: response.raw_body,
136
+ headers: response.headers
137
+ )
138
+ end
139
+
140
+ jwt = response.json && response.json['token']
141
+ if jwt.nil? || jwt.to_s.empty?
142
+ raise Errors::UnexpectedResponseError.new(
143
+ 'token endpoint returned no token',
144
+ status_code: response.status,
145
+ request_id: Errors.request_id_from(response.headers),
146
+ raw_body: response.raw_body
147
+ )
148
+ end
149
+
150
+ jwt
151
+ end
152
+
153
+ # The API trims the User-ID header but not the token's user_id claim, so a
154
+ # padded id would mint a token for a different user than the job names.
155
+ def job_user_id(form, user_id_header)
156
+ user_id = user_id_header.nil? ? form && form['user_id'] : user_id_header
157
+ return nil if user_id.nil?
158
+
159
+ text = user_id.to_s
160
+ if text.empty? || text != text.strip
161
+ raise Errors::ValidationError.new('user_id must not be empty or padded with whitespace')
162
+ end
163
+
164
+ user_id
165
+ end
166
+
167
+ def token_headers(content_type)
168
+ headers = Telemetry.headers.merge(
169
+ 'smileid-partner-id' => @config.partner_id,
170
+ 'smileid-api-key' => @config.api_key
171
+ )
172
+ headers['Content-Type'] = content_type if content_type
173
+ headers
174
+ end
175
+
100
176
  def handle(op, response)
101
177
  if op.success_statuses.include?(response.status)
102
178
  return response if response.json.is_a?(Hash)
@@ -29,6 +29,13 @@ module SmileID
29
29
  callback_url || @client.config.default_callback_url
30
30
  end
31
31
 
32
+ def require_images!(selfie_image, liveness_images, condition = nil)
33
+ return unless selfie_image.nil? || liveness_images.nil? || Array(liveness_images).empty?
34
+
35
+ message = ['selfie_image and liveness_images are required', condition].compact.join(' ')
36
+ raise Errors::ValidationError.new(message)
37
+ end
38
+
32
39
  def accepted(response)
33
40
  Generated::Models::AcceptedResponse.from(response.json)
34
41
  end
@@ -133,9 +140,24 @@ module SmileID
133
140
 
134
141
  # POST /v3/biometric_kyc
135
142
  class BiometricKyc < Base
136
- def verify(selfie_image:, liveness_images:, consent:, country:, id_type:, id_number:,
137
- user_details:, callback_url: nil, sandbox_result: nil,
143
+ # With use_enrolled_image: true the job verifies against user_id's enrolled
144
+ # selfie, so the images may be omitted. The grant is bound into this job's
145
+ # token, never sent in the body.
146
+ def verify(consent:, country:, id_type:, id_number:, user_details:,
147
+ selfie_image: nil, liveness_images: nil, use_enrolled_image: false,
148
+ callback_url: nil, sandbox_result: nil,
138
149
  partner_params: nil, metadata: nil, user_id: nil, timeout: nil)
150
+ unless [true, false].include?(use_enrolled_image)
151
+ raise Errors::ValidationError.new('use_enrolled_image must be true or false')
152
+ end
153
+
154
+ if use_enrolled_image
155
+ if user_id.to_s.strip.empty?
156
+ raise Errors::ValidationError.new('user_id is required when use_enrolled_image is true')
157
+ end
158
+ else
159
+ require_images!(selfie_image, liveness_images, 'unless use_enrolled_image is true')
160
+ end
139
161
  form = {
140
162
  'country' => country,
141
163
  'id_type' => id_type,
@@ -149,7 +171,8 @@ module SmileID
149
171
  'partner_params' => partner_params,
150
172
  'metadata' => metadata
151
173
  }.compact
152
- accepted(@client.call(:biometric_kyc, form: form, user_id_header: user_id, timeout: timeout))
174
+ accepted(@client.call(:biometric_kyc, form: form, user_id_header: user_id, timeout: timeout,
175
+ use_enrolled_image: use_enrolled_image))
153
176
  end
154
177
  end
155
178
 
@@ -173,17 +196,16 @@ module SmileID
173
196
  end
174
197
 
175
198
  # user_id goes in the BODY here (required), not the User-ID header.
176
- def authenticate(user_id:, consent:, user_details:, selfie_image: nil, liveness_images: nil,
177
- use_enrolled_image: nil, callback_url: nil, sandbox_result: nil,
199
+ def authenticate(user_id:, consent:, user_details:, selfie_image:, liveness_images:,
200
+ callback_url: nil, sandbox_result: nil,
178
201
  partner_params: nil, metadata: nil, timeout: nil)
179
- require_images!(use_enrolled_image, selfie_image, liveness_images)
202
+ require_images!(selfie_image, liveness_images)
180
203
  form = {
181
204
  'user_id' => user_id,
182
205
  'selfie_image' => selfie_image,
183
206
  'liveness_images' => liveness_images,
184
207
  'user_details' => coerce_user_details(user_details),
185
208
  'consent' => coerce_consent(consent),
186
- 'use_enrolled_image' => use_enrolled_image,
187
209
  'callback_url' => resolve_callback(callback_url),
188
210
  'sandbox_result' => sandbox_result,
189
211
  'partner_params' => partner_params,
@@ -212,17 +234,6 @@ module SmileID
212
234
  }.compact
213
235
  accepted(@client.call(:compare, form: form, timeout: timeout))
214
236
  end
215
-
216
- private
217
-
218
- def require_images!(use_enrolled_image, selfie_image, liveness_images)
219
- return if use_enrolled_image == true
220
- return unless selfie_image.nil? || liveness_images.nil? || Array(liveness_images).empty?
221
-
222
- raise Errors::ValidationError.new(
223
- 'selfie_image and liveness_images are required unless use_enrolled_image is true'
224
- )
225
- end
226
237
  end
227
238
 
228
239
  # GET /v3/status, POST /v3/replay, plus the wait_until_complete poll helper.
@@ -36,7 +36,7 @@ module SmileID
36
36
  # The single HTTP transport (spec section 2.2, 2.6). Builds the Faraday
37
37
  # request, sends it, retries idempotent operations on transient failures
38
38
  # honouring Retry-After, and normalizes the response. It does not touch auth;
39
- # the client injects tokens and handles the refresh-on-401 dance.
39
+ # the client mints and injects tokens.
40
40
  class Transport
41
41
  RETRY_STATUSES = [408, 429, 500, 502, 503, 504].freeze
42
42
  BACKOFF_BASE = 0.5
@@ -39,7 +39,7 @@ module SmileID
39
39
  # Raised before send when a local validation rule fails (spec section 5.1, 6.11).
40
40
  class ValidationError < InvalidRequestError; end
41
41
 
42
- # 401 — token missing, invalid, or expired after a single refresh attempt.
42
+ # 401 — token missing, invalid or expired.
43
43
  class AuthenticationError < SmileIDError; end
44
44
 
45
45
  # 402 — insufficient wallet balance.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SmileID
4
- VERSION = '12.1.0'
4
+ VERSION = '13.0.0'
5
5
  end
data/lib/usesmileid.rb CHANGED
@@ -9,7 +9,6 @@ require 'usesmileid/helpers/user_details'
9
9
  require 'usesmileid/helpers/multipart'
10
10
  require 'usesmileid/client/config'
11
11
  require 'usesmileid/client/transport'
12
- require 'usesmileid/client/auth'
13
12
  require 'usesmileid/client/resources'
14
13
  require 'usesmileid/client/client'
15
14
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: usesmileid
3
3
  version: !ruby/object:Gem::Version
4
- version: 12.1.0
4
+ version: 13.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Smile Identity
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-01 00:00:00.000000000 Z
11
+ date: 2026-11-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -49,7 +49,6 @@ files:
49
49
  - LICENSE
50
50
  - README.md
51
51
  - lib/usesmileid.rb
52
- - lib/usesmileid/client/auth.rb
53
52
  - lib/usesmileid/client/client.rb
54
53
  - lib/usesmileid/client/config.rb
55
54
  - lib/usesmileid/client/resources.rb
@@ -1,98 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'base64'
4
- require 'json'
5
-
6
- module SmileID
7
- # Internal JWT lifecycle (spec section 2.3, 2A). Partners never see the token.
8
- #
9
- # The cache is thread-safe: concurrent calls do not stampede the token
10
- # endpoint. The token is cached until its JWT `exp` claim minus a 60s skew; a
11
- # token whose expiry cannot be decoded is treated as single-use (refreshed on
12
- # the next call, and always on a 401).
13
- class TokenManager
14
- EXPIRY_SKEW = 60
15
-
16
- def initialize(config, transport)
17
- @config = config
18
- @transport = transport
19
- @mutex = Mutex.new
20
- @cached = nil
21
- end
22
-
23
- # Return a valid token, fetching one if the cache is empty or expired.
24
- def token
25
- @mutex.synchronize do
26
- return @cached[:jwt] if valid?
27
-
28
- fetch
29
- end
30
- end
31
-
32
- # Force a refresh (used after a 401). Fetches a new token unconditionally.
33
- def force_refresh
34
- @mutex.synchronize do
35
- @cached = nil
36
- fetch
37
- end
38
- end
39
-
40
- private
41
-
42
- def valid?
43
- @cached && Time.now < @cached[:expires_at]
44
- end
45
-
46
- def fetch
47
- response = @transport.send_request(
48
- method: :post,
49
- url: "#{@config.base_url}/v3/token",
50
- headers: Telemetry.headers.merge(
51
- 'smileid-partner-id' => @config.partner_id,
52
- 'smileid-api-key' => @config.api_key
53
- ),
54
- query: {},
55
- body: nil,
56
- retryable: true
57
- )
58
-
59
- unless response.status == 200
60
- raise Errors.from_response(
61
- status_code: response.status,
62
- body: response.json,
63
- raw_body: response.raw_body,
64
- headers: response.headers
65
- )
66
- end
67
-
68
- jwt = response.json && response.json['token']
69
- raise Errors::AuthenticationError.new('token endpoint returned no token') if jwt.nil?
70
-
71
- @cached = { jwt: jwt, expires_at: expires_at(jwt) }
72
- jwt
73
- end
74
-
75
- def expires_at(jwt)
76
- exp = decode_exp(jwt)
77
- exp ? Time.at(exp) - EXPIRY_SKEW : Time.now
78
- end
79
-
80
- # Decode the `exp` claim from a JWT without verifying the signature. Returns
81
- # nil when the token cannot be decoded.
82
- def decode_exp(jwt)
83
- payload_segment = jwt.to_s.split('.')[1]
84
- return nil if payload_segment.nil?
85
-
86
- payload = JSON.parse(Base64.urlsafe_decode64(pad(payload_segment)))
87
- exp = payload['exp']
88
- exp.is_a?(Numeric) ? exp : nil
89
- rescue ArgumentError, JSON::ParserError
90
- nil
91
- end
92
-
93
- def pad(segment)
94
- remainder = segment.length % 4
95
- remainder.zero? ? segment : segment + ('=' * (4 - remainder))
96
- end
97
- end
98
- end