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 +4 -4
- data/CHANGELOG.md +26 -1
- data/README.md +17 -3
- data/lib/usesmileid/client/client.rb +99 -23
- data/lib/usesmileid/client/resources.rb +29 -18
- data/lib/usesmileid/client/transport.rb +1 -1
- data/lib/usesmileid/errors.rb +1 -1
- 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,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/
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
@@ -133,9 +140,24 @@ module SmileID
|
|
|
133
140
|
|
|
134
141
|
# POST /v3/biometric_kyc
|
|
135
142
|
class BiometricKyc < Base
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
177
|
-
|
|
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!(
|
|
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
|
|
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.
|
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-10
|
|
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
|