usesmileid 12.0.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 +4 -4
- data/CHANGELOG.md +54 -30
- data/README.md +37 -3
- data/lib/usesmileid/client/client.rb +99 -23
- data/lib/usesmileid/client/resources.rb +54 -19
- data/lib/usesmileid/client/transport.rb +1 -1
- data/lib/usesmileid/errors.rb +1 -1
- data/lib/usesmileid/generated/operations.rb +5 -0
- data/lib/usesmileid/helpers/multipart.rb +5 -3
- data/lib/usesmileid/version.rb +1 -1
- data/lib/usesmileid.rb +0 -1
- metadata +2 -3
- data/lib/usesmileid/client/auth.rb +0 -98
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f64d6fd177105edbffe5bceac374c675140d49a0da20d8e56041ae156429dda3
|
|
4
|
+
data.tar.gz: 810132bb74281566a6957f2ae76dd647d0e685f1423a71db26780dbf9351076f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 856a7d6b04e054b1084d21dd35822274c2f0fcced663944ad01b6c6446df160e1f58237a5686c9f0824bd64e6cdf9de32b9b131fd1db68d4f34287998a9da0f4
|
|
7
|
+
data.tar.gz: 306285d436a657d8bbafe633b101cebb9f5e6e955b2fe60029d6ff08e3cc0c5665fb58edfa78037323f7f23563897012e4fff02fd7b2d6810cb01c5b2f6bb427
|
data/CHANGELOG.md
CHANGED
|
@@ -7,41 +7,65 @@ 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
|
+
|
|
10
20
|
### Changed
|
|
11
21
|
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
SDKs.
|
|
16
|
-
- `base_url` must now be an absolute https URL with no query or fragment,
|
|
17
|
-
validated at construction. There is no allow-insecure option.
|
|
18
|
-
- `default_callback_url` (at construction) and per-request `callback_url`
|
|
19
|
-
values (entry operations and replay) must be https; insecure values raise
|
|
20
|
-
a validation error before any request is sent.
|
|
21
|
-
- `job_id` and `user_id` path parameters are URL-encoded as single path
|
|
22
|
-
segments before interpolation.
|
|
23
|
-
- Multipart filenames and content types are sanitized against header
|
|
24
|
-
injection on every input path, including caller-supplied content types.
|
|
25
|
-
Filenames have control characters stripped and quotes encoded; content
|
|
26
|
-
types must be a valid media type.
|
|
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.
|
|
27
25
|
|
|
28
26
|
### Added
|
|
29
27
|
|
|
30
|
-
- `
|
|
31
|
-
|
|
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
|
+
|
|
34
|
+
## [12.1.0] - 2026-10-01
|
|
32
35
|
|
|
33
36
|
### Added
|
|
34
37
|
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
38
|
+
- Residency Document Verification: `documents.verify_residency`.
|
|
39
|
+
|
|
40
|
+
## [12.0.0] - 2026-08-20
|
|
41
|
+
|
|
42
|
+
First public release.
|
|
43
|
+
|
|
44
|
+
### Products
|
|
45
|
+
|
|
46
|
+
- Enhanced KYC, Biometric KYC, Document Verification and Enhanced Document
|
|
47
|
+
Verification.
|
|
48
|
+
- SmartSelfie enrollment, authentication and compare.
|
|
49
|
+
|
|
50
|
+
### Verifications
|
|
51
|
+
|
|
52
|
+
- Job status retrieval, plus a `wait_until_complete` helper that polls until
|
|
53
|
+
a job reaches a terminal decision.
|
|
54
|
+
- Callback replay.
|
|
55
|
+
- User fraud reporting, with `flag_fraud` / `clear_fraud` convenience
|
|
56
|
+
wrappers.
|
|
57
|
+
|
|
58
|
+
### Client
|
|
59
|
+
|
|
60
|
+
- Sandbox and production environments, with a `base_url` override for other
|
|
61
|
+
hosts. `base_url` and callback URLs must be https, checked before any
|
|
62
|
+
request is sent.
|
|
63
|
+
- Internal JWT authentication, cached and refreshed automatically on 401.
|
|
64
|
+
- Automatic retries with backoff for idempotent calls.
|
|
65
|
+
- Typed errors under `SmileID::Errors`, keyed on HTTP status.
|
|
66
|
+
- Bank codes, supported ID types and supported documents lookups.
|
|
67
|
+
|
|
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
|
|
70
|
+
[12.1.0]: https://github.com/smileidentity/smileid-sdk-ruby/compare/v12.0.0...v12.1.0
|
|
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
|
|
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
|
|
|
@@ -142,6 +142,26 @@ accepted = smile.documents.verify_enhanced(
|
|
|
142
142
|
)
|
|
143
143
|
```
|
|
144
144
|
|
|
145
|
+
### Residency document verification
|
|
146
|
+
|
|
147
|
+
Same shape as enhanced document verification, plus the `visa` endorsed in the
|
|
148
|
+
passport (JPEG or PNG). `id_type` is optional and defaults to `PASSPORT`, the
|
|
149
|
+
only value accepted. The visa details arrive in
|
|
150
|
+
`additional_documents` on the callback.
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
accepted = smile.documents.verify_residency(
|
|
154
|
+
selfie_image: "selfie.jpg",
|
|
155
|
+
liveness_images: ["live1.jpg", "live2.jpg", "live3.jpg",
|
|
156
|
+
"live4.jpg", "live5.jpg", "live6.jpg"],
|
|
157
|
+
document: "passport.jpg",
|
|
158
|
+
visa: "visa.jpg",
|
|
159
|
+
country: "ZA",
|
|
160
|
+
user_details: user_details,
|
|
161
|
+
consent: consent
|
|
162
|
+
)
|
|
163
|
+
```
|
|
164
|
+
|
|
145
165
|
### Biometric KYC
|
|
146
166
|
|
|
147
167
|
```ruby
|
|
@@ -157,6 +177,20 @@ accepted = smile.biometric_kyc.verify(
|
|
|
157
177
|
)
|
|
158
178
|
```
|
|
159
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
|
+
|
|
160
194
|
### Biometric enrollment
|
|
161
195
|
|
|
162
196
|
```ruby
|
|
@@ -172,7 +206,7 @@ accepted = smile.biometric.enroll(
|
|
|
172
206
|
|
|
173
207
|
### Biometric authentication
|
|
174
208
|
|
|
175
|
-
`user_id` is required and must match an enrolled user.
|
|
209
|
+
`user_id` is required and must match an enrolled user. The selfie and liveness images are always required.
|
|
176
210
|
|
|
177
211
|
```ruby
|
|
178
212
|
accepted = smile.biometric.authenticate(
|
|
@@ -276,7 +310,7 @@ Every API failure raises a typed error under `SmileID::Errors`, keyed on the HTT
|
|
|
276
310
|
| Error | Raised on |
|
|
277
311
|
|---|---|
|
|
278
312
|
| `InvalidRequestError` | 400, 415, and failed client-side validation (`ValidationError` subclass) |
|
|
279
|
-
| `AuthenticationError` | 401
|
|
313
|
+
| `AuthenticationError` | 401 |
|
|
280
314
|
| `PaymentRequiredError` | 402 — insufficient wallet balance |
|
|
281
315
|
| `PermissionError` | 403 |
|
|
282
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
|
|
53
|
-
#
|
|
54
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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'] =
|
|
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
|
|
@@ -55,7 +62,8 @@ module SmileID
|
|
|
55
62
|
end
|
|
56
63
|
end
|
|
57
64
|
|
|
58
|
-
# POST /v3/document_verification
|
|
65
|
+
# POST /v3/document_verification, /v3/enhanced_document_verification and
|
|
66
|
+
# /v3/residency_document_verification
|
|
59
67
|
class Documents < Base
|
|
60
68
|
def verify(selfie_image:, liveness_images:, document:, consent:, country:, user_details:,
|
|
61
69
|
document_back: nil, id_type: nil, callback_url: nil,
|
|
@@ -87,6 +95,29 @@ module SmileID
|
|
|
87
95
|
user_id_header: user_id, timeout: timeout))
|
|
88
96
|
end
|
|
89
97
|
|
|
98
|
+
# POST /v3/residency_document_verification. The document is a passport
|
|
99
|
+
# and the visa endorsed in it is read as well, so id_type defaults to, and must be, PASSPORT.
|
|
100
|
+
def verify_residency(selfie_image:, liveness_images:, document:, visa:, consent:,
|
|
101
|
+
country:, user_details:, id_type: 'PASSPORT', document_back: nil,
|
|
102
|
+
callback_url: nil, partner_params: nil, metadata: nil, user_id: nil, timeout: nil)
|
|
103
|
+
id_type = 'PASSPORT' if id_type.to_s.empty?
|
|
104
|
+
unless id_type.to_s == 'PASSPORT'
|
|
105
|
+
raise Errors::ValidationError.new('id_type must be PASSPORT for residency document verification')
|
|
106
|
+
end
|
|
107
|
+
if visa.nil? || (visa.respond_to?(:empty?) && visa.empty?)
|
|
108
|
+
raise Errors::ValidationError.new('visa is required for residency document verification')
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
form = document_form(
|
|
112
|
+
selfie_image: selfie_image, liveness_images: liveness_images, document: document,
|
|
113
|
+
document_back: document_back, consent: consent, country: country, id_type: id_type,
|
|
114
|
+
user_details: user_details, callback_url: callback_url,
|
|
115
|
+
partner_params: partner_params, metadata: metadata
|
|
116
|
+
).merge('visa' => visa)
|
|
117
|
+
accepted(@client.call(:residency_document_verification, form: form,
|
|
118
|
+
user_id_header: user_id, timeout: timeout))
|
|
119
|
+
end
|
|
120
|
+
|
|
90
121
|
private
|
|
91
122
|
|
|
92
123
|
def document_form(selfie_image:, liveness_images:, document:, document_back:, consent:,
|
|
@@ -109,9 +140,24 @@ module SmileID
|
|
|
109
140
|
|
|
110
141
|
# POST /v3/biometric_kyc
|
|
111
142
|
class BiometricKyc < Base
|
|
112
|
-
|
|
113
|
-
|
|
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,
|
|
114
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
|
|
115
161
|
form = {
|
|
116
162
|
'country' => country,
|
|
117
163
|
'id_type' => id_type,
|
|
@@ -125,7 +171,8 @@ module SmileID
|
|
|
125
171
|
'partner_params' => partner_params,
|
|
126
172
|
'metadata' => metadata
|
|
127
173
|
}.compact
|
|
128
|
-
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))
|
|
129
176
|
end
|
|
130
177
|
end
|
|
131
178
|
|
|
@@ -149,17 +196,16 @@ module SmileID
|
|
|
149
196
|
end
|
|
150
197
|
|
|
151
198
|
# user_id goes in the BODY here (required), not the User-ID header.
|
|
152
|
-
def authenticate(user_id:, consent:, user_details:, selfie_image
|
|
153
|
-
|
|
199
|
+
def authenticate(user_id:, consent:, user_details:, selfie_image:, liveness_images:,
|
|
200
|
+
callback_url: nil, sandbox_result: nil,
|
|
154
201
|
partner_params: nil, metadata: nil, timeout: nil)
|
|
155
|
-
require_images!(
|
|
202
|
+
require_images!(selfie_image, liveness_images)
|
|
156
203
|
form = {
|
|
157
204
|
'user_id' => user_id,
|
|
158
205
|
'selfie_image' => selfie_image,
|
|
159
206
|
'liveness_images' => liveness_images,
|
|
160
207
|
'user_details' => coerce_user_details(user_details),
|
|
161
208
|
'consent' => coerce_consent(consent),
|
|
162
|
-
'use_enrolled_image' => use_enrolled_image,
|
|
163
209
|
'callback_url' => resolve_callback(callback_url),
|
|
164
210
|
'sandbox_result' => sandbox_result,
|
|
165
211
|
'partner_params' => partner_params,
|
|
@@ -188,17 +234,6 @@ module SmileID
|
|
|
188
234
|
}.compact
|
|
189
235
|
accepted(@client.call(:compare, form: form, timeout: timeout))
|
|
190
236
|
end
|
|
191
|
-
|
|
192
|
-
private
|
|
193
|
-
|
|
194
|
-
def require_images!(use_enrolled_image, selfie_image, liveness_images)
|
|
195
|
-
return if use_enrolled_image == true
|
|
196
|
-
return unless selfie_image.nil? || liveness_images.nil? || Array(liveness_images).empty?
|
|
197
|
-
|
|
198
|
-
raise Errors::ValidationError.new(
|
|
199
|
-
'selfie_image and liveness_images are required unless use_enrolled_image is true'
|
|
200
|
-
)
|
|
201
|
-
end
|
|
202
237
|
end
|
|
203
238
|
|
|
204
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
|
|
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
|
data/lib/usesmileid/errors.rb
CHANGED
|
@@ -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
|
|
42
|
+
# 401 — token missing, invalid or expired.
|
|
43
43
|
class AuthenticationError < SmileIDError; end
|
|
44
44
|
|
|
45
45
|
# 402 — insufficient wallet balance.
|
|
@@ -33,6 +33,11 @@ module SmileID
|
|
|
33
33
|
partner_id_header: true, body_kind: :multipart, idempotent: false,
|
|
34
34
|
success_statuses: [202]
|
|
35
35
|
),
|
|
36
|
+
residency_document_verification: Operation.new(
|
|
37
|
+
http_method: :post, path: '/v3/residency_document_verification', authenticated: true,
|
|
38
|
+
partner_id_header: true, body_kind: :multipart, idempotent: false,
|
|
39
|
+
success_statuses: [202]
|
|
40
|
+
),
|
|
36
41
|
biometric_kyc: Operation.new(
|
|
37
42
|
http_method: :post, path: '/v3/biometric_kyc', authenticated: true,
|
|
38
43
|
partner_id_header: true, body_kind: :multipart, idempotent: false,
|
|
@@ -21,7 +21,7 @@ module SmileID
|
|
|
21
21
|
# Fields serialized as JSON parts.
|
|
22
22
|
JSON_PART_FIELDS = %w[consent user_details partner_params metadata].freeze
|
|
23
23
|
# Single binary fields.
|
|
24
|
-
BINARY_FIELDS = %w[selfie_image document document_back comparison_image].freeze
|
|
24
|
+
BINARY_FIELDS = %w[selfie_image document document_back visa comparison_image].freeze
|
|
25
25
|
# Repeated binary fields.
|
|
26
26
|
BINARY_ARRAY_FIELDS = %w[liveness_images].freeze
|
|
27
27
|
|
|
@@ -29,13 +29,14 @@ module SmileID
|
|
|
29
29
|
'selfie_image' => 'image/jpeg',
|
|
30
30
|
'document' => 'image/jpeg',
|
|
31
31
|
'document_back' => 'image/jpeg',
|
|
32
|
+
'visa' => 'image/jpeg',
|
|
32
33
|
'comparison_image' => 'image/jpeg',
|
|
33
34
|
'liveness_images' => 'image/jpeg'
|
|
34
35
|
}.freeze
|
|
35
36
|
|
|
36
|
-
# Only document and
|
|
37
|
+
# Only document, document_back and visa may be PNG (spec section 5.3 rule 3);
|
|
37
38
|
# selfie, liveness and comparison images are always image/jpeg.
|
|
38
|
-
PNG_CAPABLE_FIELDS = %w[document document_back].freeze
|
|
39
|
+
PNG_CAPABLE_FIELDS = %w[document document_back visa].freeze
|
|
39
40
|
PNG_MAGIC = "\x89PNG".b.freeze
|
|
40
41
|
|
|
41
42
|
# RFC 6838 type/subtype tokens — anything else is rejected before send.
|
|
@@ -45,6 +46,7 @@ module SmileID
|
|
|
45
46
|
'selfie_image' => 'selfie.jpg',
|
|
46
47
|
'document' => 'document.jpg',
|
|
47
48
|
'document_back' => 'document_back.jpg',
|
|
49
|
+
'visa' => 'visa.jpg',
|
|
48
50
|
'comparison_image' => 'comparison.jpg'
|
|
49
51
|
}.freeze
|
|
50
52
|
|
data/lib/usesmileid/version.rb
CHANGED
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:
|
|
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-
|
|
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
|