usesmileid 12.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 +7 -0
- data/CHANGELOG.md +47 -0
- data/LICENSE +21 -0
- data/README.md +320 -0
- data/lib/usesmileid/client/auth.rb +98 -0
- data/lib/usesmileid/client/client.rb +130 -0
- data/lib/usesmileid/client/config.rb +106 -0
- data/lib/usesmileid/client/resources.rb +320 -0
- data/lib/usesmileid/client/transport.rb +114 -0
- data/lib/usesmileid/errors.rb +125 -0
- data/lib/usesmileid/generated/models.rb +170 -0
- data/lib/usesmileid/generated/operations.rb +98 -0
- data/lib/usesmileid/helpers/consent.rb +85 -0
- data/lib/usesmileid/helpers/multipart.rb +250 -0
- data/lib/usesmileid/helpers/user_details.rb +63 -0
- data/lib/usesmileid/version.rb +5 -0
- data/lib/usesmileid.rb +24 -0
- metadata +90 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0b3aa92d6b398bde9e998a5e5b128c688a94ac72331b7f5be584df7008286458
|
|
4
|
+
data.tar.gz: f44668a9c23b92c59708176ba20153ef729b3f840431a3fd2fd32fab8121efa4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2f3017edcda29325e2b49d5a6824d20f6c649538044e840f2a6b08aea08d44d65fa9e8e07c821544519181726d9a3fa3853ee796c6216808c18169f094d65a5e
|
|
7
|
+
data.tar.gz: 146f765cb0b59e9aa473718c614ae3287fd94e064e51bca8f546755c09e1b3f4f60c22816c538041aeb01750c1728da2b9b3b3c65d0238edbe74a3a88106b289
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Renamed the gem from `smile-identity-core` to `smileid`. The require path
|
|
13
|
+
(`require "smileid"`) and the `SmileID` module are unchanged.
|
|
14
|
+
- Set the version to 12.0.0, aligning the server SDKs with the V12 mobile
|
|
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.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- `SmileID::Errors::UnexpectedResponseError`, raised when a 2xx success
|
|
31
|
+
response body is not the expected JSON object.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- Full V3 API coverage: enhanced KYC, document verification, enhanced document
|
|
36
|
+
verification, biometric KYC, biometric enrollment, authentication and compare,
|
|
37
|
+
verification status with a `wait_until_complete` polling helper, callback
|
|
38
|
+
replay, fraud reporting (with `flag_fraud` / `clear_fraud` wrappers), and the
|
|
39
|
+
four services endpoints.
|
|
40
|
+
- Internal JWT authentication with a thread-safe cache and a single automatic
|
|
41
|
+
refresh on 401.
|
|
42
|
+
- Typed error hierarchy under `SmileID::Errors`, covering both API error body
|
|
43
|
+
shapes.
|
|
44
|
+
- Automatic retries with exponential backoff and `Retry-After` support for
|
|
45
|
+
idempotent calls only.
|
|
46
|
+
- Consent builder and client-side validation for user details and fraud
|
|
47
|
+
reports.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Smile Identity Limited
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# usesmileid
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/usesmileid)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
Official Smile ID server-side SDK for Ruby, covering the V3 APIs.
|
|
7
|
+
|
|
8
|
+
The SDK handles authentication, request serialization, retries and typed errors so you can call Smile ID from Ruby with plain method calls. You never handle tokens yourself.
|
|
9
|
+
|
|
10
|
+
Requires Ruby 3.0 or later.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
Add the gem to your Gemfile:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
gem "usesmileid"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or install it directly:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
gem install usesmileid
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Getting started
|
|
27
|
+
|
|
28
|
+
Construct one client with your partner id and API key. The client is thread-safe and can be shared across your application.
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
require "usesmileid"
|
|
32
|
+
|
|
33
|
+
smile = SmileID::Client.new(
|
|
34
|
+
partner_id: "1234",
|
|
35
|
+
api_key: ENV.fetch("SMILE_API_KEY"),
|
|
36
|
+
environment: :sandbox, # default
|
|
37
|
+
default_callback_url: "https://app.example.com/cb" # optional
|
|
38
|
+
)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Partner ids are displayed zero-padded (for example 002) but must be passed without leading zeros (2).
|
|
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.
|
|
44
|
+
|
|
45
|
+
### Environment selection
|
|
46
|
+
|
|
47
|
+
The client uses the sandbox by default. Set `environment: :production` to go live. Only `:sandbox` and `:production` are named.
|
|
48
|
+
|
|
49
|
+
| Environment | Base URL |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `:sandbox` (default) | `https://testapi.smileidentity.com` |
|
|
52
|
+
| `:production` | `https://api.smileidentity.com` |
|
|
53
|
+
|
|
54
|
+
Any other host needs an explicit `base_url:`, which wins over `environment`:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
smile = SmileID::Client.new(
|
|
58
|
+
partner_id: "2",
|
|
59
|
+
api_key: ENV.fetch("SMILE_API_KEY"),
|
|
60
|
+
base_url: "https://your-environment.example.com"
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
A custom `base_url` must be an absolute https URL with no query string or fragment. There is deliberately no way to use plain http. Callback URLs (`default_callback_url` and any per-request `callback_url`) must also be https; an insecure callback raises a validation error before any request is sent.
|
|
65
|
+
|
|
66
|
+
Non-production environments match test identities on given names + last name + email; an unrecognised identity resolves to `block`.
|
|
67
|
+
|
|
68
|
+
### Other client options
|
|
69
|
+
|
|
70
|
+
| Option | Default | Purpose |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `timeout` | 30 | Per-request timeout in seconds. Every method also accepts a per-call `timeout:` override. |
|
|
73
|
+
| `max_retries` | 2 | Retries for idempotent calls only (status and services reads, and the internal token fetch). Job submissions are never retried automatically. |
|
|
74
|
+
| `http_client` | SDK default | Inject your own `Faraday::Connection` for testing or proxies. |
|
|
75
|
+
|
|
76
|
+
## Shared inputs
|
|
77
|
+
|
|
78
|
+
All verification submissions need consent and user details.
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
consent = SmileID::Consent.granted(
|
|
82
|
+
granted_at: Time.now.utc,
|
|
83
|
+
notice_language: "EN",
|
|
84
|
+
notice_privacy_policy_url: "https://example.com/privacy"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
user_details = {
|
|
88
|
+
given_names: "John",
|
|
89
|
+
last_name: "Doe",
|
|
90
|
+
email: "john@example.com" # at least one of email / phone_number is required
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Image inputs accept a file path (`"selfie.jpg"`), raw bytes, an IO object, or a hash such as `{ path: "front.png", content_type: "image/png" }`.
|
|
95
|
+
|
|
96
|
+
## Methods
|
|
97
|
+
|
|
98
|
+
### Enhanced KYC
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
accepted = smile.enhanced_kyc.verify(
|
|
102
|
+
country: "NG",
|
|
103
|
+
id_type: "NIN",
|
|
104
|
+
id_number: "12345678901",
|
|
105
|
+
user_details: user_details,
|
|
106
|
+
consent: consent,
|
|
107
|
+
user_id: "user_01h8x9y2z3a4b5c6d7e8f9g0h1" # optional
|
|
108
|
+
)
|
|
109
|
+
accepted.job_id # => "job_..."
|
|
110
|
+
accepted.accepted? # => true
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Document verification
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
accepted = smile.documents.verify(
|
|
117
|
+
selfie_image: "selfie.jpg",
|
|
118
|
+
liveness_images: ["live1.jpg", "live2.jpg", "live3.jpg",
|
|
119
|
+
"live4.jpg", "live5.jpg", "live6.jpg"],
|
|
120
|
+
document: "doc_front.jpg",
|
|
121
|
+
document_back: "doc_back.jpg", # optional
|
|
122
|
+
country: "NG",
|
|
123
|
+
user_details: user_details,
|
|
124
|
+
consent: consent
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Enhanced document verification
|
|
129
|
+
|
|
130
|
+
Same shape as document verification, but `id_type` is required.
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
accepted = smile.documents.verify_enhanced(
|
|
134
|
+
id_type: "PASSPORT",
|
|
135
|
+
selfie_image: "selfie.jpg",
|
|
136
|
+
liveness_images: ["live1.jpg", "live2.jpg", "live3.jpg",
|
|
137
|
+
"live4.jpg", "live5.jpg", "live6.jpg"],
|
|
138
|
+
document: "doc_front.jpg",
|
|
139
|
+
country: "NG",
|
|
140
|
+
user_details: user_details,
|
|
141
|
+
consent: consent
|
|
142
|
+
)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Biometric KYC
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
accepted = smile.biometric_kyc.verify(
|
|
149
|
+
selfie_image: "selfie.jpg",
|
|
150
|
+
liveness_images: ["live1.jpg", "live2.jpg", "live3.jpg",
|
|
151
|
+
"live4.jpg", "live5.jpg", "live6.jpg"],
|
|
152
|
+
country: "NG",
|
|
153
|
+
id_type: "NIN",
|
|
154
|
+
id_number: "12345678901",
|
|
155
|
+
user_details: user_details,
|
|
156
|
+
consent: consent
|
|
157
|
+
)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Biometric enrollment
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
accepted = smile.biometric.enroll(
|
|
164
|
+
selfie_image: "selfie.jpg",
|
|
165
|
+
liveness_images: ["live1.jpg", "live2.jpg", "live3.jpg",
|
|
166
|
+
"live4.jpg", "live5.jpg", "live6.jpg"],
|
|
167
|
+
user_details: user_details,
|
|
168
|
+
consent: consent,
|
|
169
|
+
user_id: "user-42" # optional partner-provided id
|
|
170
|
+
)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Biometric authentication
|
|
174
|
+
|
|
175
|
+
`user_id` is required and must match an enrolled user. Images are required unless you set `use_enrolled_image: true`.
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
accepted = smile.biometric.authenticate(
|
|
179
|
+
user_id: "user-42",
|
|
180
|
+
selfie_image: "selfie.jpg",
|
|
181
|
+
liveness_images: ["live1.jpg", "live2.jpg", "live3.jpg",
|
|
182
|
+
"live4.jpg", "live5.jpg", "live6.jpg"],
|
|
183
|
+
user_details: user_details,
|
|
184
|
+
consent: consent
|
|
185
|
+
)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Biometric compare
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
accepted = smile.biometric.compare(
|
|
192
|
+
selfie_image: "selfie.jpg",
|
|
193
|
+
comparison_image: "id_photo.jpg",
|
|
194
|
+
comparison_image_type: "ID_PHOTO", # DOCUMENT | ID_PHOTO | PORTRAIT
|
|
195
|
+
user_details: user_details,
|
|
196
|
+
consent: consent
|
|
197
|
+
)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Check a verification's status
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
status = smile.verifications.retrieve("job_01h8x9y2z3a4b5c6d7e8f9g0h1")
|
|
204
|
+
status.status # "processing", "not_found", or the decision: "clear", "block", "attention", "error"
|
|
205
|
+
status.complete? # true when terminal, i.e. neither processing nor not_found
|
|
206
|
+
status.message # e.g. "Job completed"
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
A job the API does not know yet returns a status of `not_found` rather than raising an error, so you can poll safely right after submission.
|
|
210
|
+
|
|
211
|
+
### Wait for a verification to complete
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
status = smile.verifications.wait_until_complete(
|
|
215
|
+
"job_01h8x9y2z3a4b5c6d7e8f9g0h1",
|
|
216
|
+
interval: 2, # seconds between polls (default 2)
|
|
217
|
+
timeout: 60 # give up after this many seconds (default 60)
|
|
218
|
+
)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Polling stops as soon as the job reaches a terminal decision (`clear`, `block`, `attention` or `error`). Raises `SmileID::Errors::TimeoutError` if the job does not complete in time. Pass `treat_not_found_as_pending: false` to return immediately when the job is unknown instead of polling on.
|
|
222
|
+
|
|
223
|
+
### Replay a callback
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
smile.verifications.replay(
|
|
227
|
+
"job_01h8x9y2z3a4b5c6d7e8f9g0h1",
|
|
228
|
+
callback_url: "https://app.example.com/cb" # optional override
|
|
229
|
+
)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Replaying a job that is still processing raises `SmileID::Errors::ConflictError`.
|
|
233
|
+
|
|
234
|
+
### Report user fraud
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
smile.users.report_fraud(
|
|
238
|
+
"user-42",
|
|
239
|
+
is_fraud: true,
|
|
240
|
+
reason: "ACCOUNT_TAKEOVER",
|
|
241
|
+
reported_by: "risk@example.com"
|
|
242
|
+
)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Or use the convenience wrappers:
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
smile.users.flag_fraud("user-42", reason: "ACCOUNT_TAKEOVER", reported_by: "risk@example.com")
|
|
249
|
+
smile.users.clear_fraud("user-42", notes: "Cleared after review", reported_by: "risk@example.com")
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
When flagging, `reason` is required (and `notes` too if the reason is `OTHER`). When clearing, `notes` is required.
|
|
253
|
+
|
|
254
|
+
### Services
|
|
255
|
+
|
|
256
|
+
Bank codes, supported ID types and supported documents need no authentication.
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
smile.services.bank_codes(country: "NG").bank_codes
|
|
260
|
+
# => [{ "code" => "044", "country" => "NG", "name" => "Access Bank" }, ...]
|
|
261
|
+
|
|
262
|
+
smile.services.supported_id_types(country: "NG").id_types
|
|
263
|
+
# => [{ "country" => "NG", "type" => "BVN", "label" => ..., "regex" => ..., ... }, ...]
|
|
264
|
+
|
|
265
|
+
smile.services.supported_documents(country_code: "NG").valid_documents
|
|
266
|
+
# => [{ "country" => { "code" => "NG", ... }, "id_types" => [...] }, ...]
|
|
267
|
+
|
|
268
|
+
smile.services.id_status(country: "NG", id_type: "BVN")
|
|
269
|
+
# => #<IdStatusResponse last_known_status="online" last_hour_success_rate="95%" ...>
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Error handling
|
|
273
|
+
|
|
274
|
+
Every API failure raises a typed error under `SmileID::Errors`, keyed on the HTTP status:
|
|
275
|
+
|
|
276
|
+
| Error | Raised on |
|
|
277
|
+
|---|---|
|
|
278
|
+
| `InvalidRequestError` | 400, 415, and failed client-side validation (`ValidationError` subclass) |
|
|
279
|
+
| `AuthenticationError` | 401 after one automatic token refresh |
|
|
280
|
+
| `PaymentRequiredError` | 402 — insufficient wallet balance |
|
|
281
|
+
| `PermissionError` | 403 |
|
|
282
|
+
| `NotFoundError` | 404 (except `verifications.retrieve`, which returns a `not_found` status) |
|
|
283
|
+
| `ConflictError` | 409 — for example replaying a job that is still processing |
|
|
284
|
+
| `PayloadTooLargeError` | 413 |
|
|
285
|
+
| `RateLimitError` | 429 |
|
|
286
|
+
| `APIError` | any 5xx |
|
|
287
|
+
| `ConnectionError` | network failure or timeout with no HTTP response |
|
|
288
|
+
| `TimeoutError` | `wait_until_complete` deadline passed (no HTTP response) |
|
|
289
|
+
| `UnexpectedResponseError` | a 2xx response whose body is not the expected JSON object, for example proxy interference |
|
|
290
|
+
|
|
291
|
+
Each error exposes `status_code`, `status`, `message`, `code`, `request_id` and `raw_body`.
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
begin
|
|
295
|
+
smile.enhanced_kyc.verify(...)
|
|
296
|
+
rescue SmileID::Errors::PaymentRequiredError => e
|
|
297
|
+
e.status_code # 402
|
|
298
|
+
e.message # "Insufficient wallet balance."
|
|
299
|
+
rescue SmileID::Errors::SmileIDError => e
|
|
300
|
+
# catch-all for anything the API raised
|
|
301
|
+
end
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## Telemetry
|
|
305
|
+
|
|
306
|
+
The SDK sends three telemetry headers on every request: `SmileID-Source-SDK` (`ruby`), `SmileID-Source-SDK-Version` and a `User-Agent` identifying the SDK and Ruby version. These identify the SDK for observability. They are never used for authentication and carry no personal data.
|
|
307
|
+
|
|
308
|
+
## Development
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
bundle install
|
|
312
|
+
bundle exec rspec # unit tests, fully offline
|
|
313
|
+
bundle exec rubocop
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
The end-to-end sandbox test runs only when `SMILE_PARTNER_ID` and `SMILE_API_KEY` are set in the environment; otherwise it skips. Set `SMILE_BASE_URL` to run it against a host other than the sandbox.
|
|
317
|
+
|
|
318
|
+
## Contributing
|
|
319
|
+
|
|
320
|
+
See [SECURITY.md](SECURITY.md) for how to report a security issue.
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module SmileID
|
|
7
|
+
# The Smile ID client (spec section 2.1, 4). Construct once, then call
|
|
8
|
+
# resource verbs, e.g. client.enhanced_kyc.verify(...).
|
|
9
|
+
class Client
|
|
10
|
+
attr_reader :config
|
|
11
|
+
|
|
12
|
+
def initialize(partner_id:, api_key:, environment: :sandbox,
|
|
13
|
+
default_callback_url: nil, base_url: nil, timeout: Config::DEFAULT_TIMEOUT,
|
|
14
|
+
max_retries: Config::DEFAULT_MAX_RETRIES, http_client: nil)
|
|
15
|
+
@config = Config.new(
|
|
16
|
+
partner_id: partner_id, api_key: api_key, environment: environment,
|
|
17
|
+
default_callback_url: default_callback_url,
|
|
18
|
+
base_url: base_url, timeout: timeout, max_retries: max_retries, http_client: http_client
|
|
19
|
+
)
|
|
20
|
+
@transport = Transport.new(@config)
|
|
21
|
+
@token_manager = TokenManager.new(@config, @transport)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def enhanced_kyc
|
|
25
|
+
@enhanced_kyc ||= Resources::EnhancedKyc.new(self)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def documents
|
|
29
|
+
@documents ||= Resources::Documents.new(self)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def biometric_kyc
|
|
33
|
+
@biometric_kyc ||= Resources::BiometricKyc.new(self)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def biometric
|
|
37
|
+
@biometric ||= Resources::Biometric.new(self)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def verifications
|
|
41
|
+
@verifications ||= Resources::Verifications.new(self)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def users
|
|
45
|
+
@users ||= Resources::Users.new(self)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def services
|
|
49
|
+
@services ||= Resources::Services.new(self)
|
|
50
|
+
end
|
|
51
|
+
|
|
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).
|
|
55
|
+
#
|
|
56
|
+
# @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)
|
|
58
|
+
op = Generated::Operations.fetch(op_key)
|
|
59
|
+
url = @config.base_url + interpolate(op.path, path_params)
|
|
60
|
+
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
|
+
)
|
|
69
|
+
|
|
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
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Multipart bodies only; an op with an optional body (replay) sends no
|
|
83
|
+
# body at all when there are no fields.
|
|
84
|
+
def serialize(op, form)
|
|
85
|
+
return [nil, nil] unless op.body_kind == :multipart
|
|
86
|
+
return [nil, nil] if form.nil? || form.empty?
|
|
87
|
+
|
|
88
|
+
Helpers::Multipart.build(form)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_headers(op, content_type, user_id_header)
|
|
92
|
+
headers = Telemetry.headers
|
|
93
|
+
headers['Content-Type'] = content_type if content_type
|
|
94
|
+
headers['SmileID-Token'] = @token_manager.token if op.authenticated
|
|
95
|
+
headers['SmileID-Partner-ID'] = @config.partner_id if op.partner_id_header
|
|
96
|
+
headers['User-ID'] = user_id_header if user_id_header
|
|
97
|
+
headers
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def handle(op, response)
|
|
101
|
+
if op.success_statuses.include?(response.status)
|
|
102
|
+
return response if response.json.is_a?(Hash)
|
|
103
|
+
|
|
104
|
+
raise Errors::UnexpectedResponseError.new(
|
|
105
|
+
'expected a JSON object response body',
|
|
106
|
+
status_code: response.status,
|
|
107
|
+
request_id: Errors.request_id_from(response.headers),
|
|
108
|
+
raw_body: response.raw_body
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
raise Errors.from_response(
|
|
113
|
+
status_code: response.status,
|
|
114
|
+
body: response.json,
|
|
115
|
+
raw_body: response.raw_body,
|
|
116
|
+
headers: response.headers
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def interpolate(path, path_params)
|
|
121
|
+
path.gsub(/\{(\w+)\}/) do
|
|
122
|
+
key = Regexp.last_match(1)
|
|
123
|
+
value = path_params[key] || path_params[key.to_sym]
|
|
124
|
+
raise ArgumentError, "missing path parameter: #{key}" if value.nil?
|
|
125
|
+
|
|
126
|
+
ERB::Util.url_encode(value.to_s)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|