didww-otp_verification 1.0.0 → 1.2.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 +87 -0
- data/README.md +120 -15
- data/lib/didww/otp_verification/callback_verifier.rb +12 -5
- data/lib/didww/otp_verification/client.rb +46 -6
- data/lib/didww/otp_verification/errors.rb +16 -0
- data/lib/didww/otp_verification/rails.rb +2 -1
- data/lib/didww/otp_verification/verification.rb +64 -3
- data/lib/didww/otp_verification/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9ff9c9fc68b3da5a2d330d5ddc3ef599f65feb79674fe93d302c393dabe6d5b3
|
|
4
|
+
data.tar.gz: 639dc28bfb09c2af89e98ec9845c0a5fb422d09b21e9e717b30cd6f28039b387
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7de05b4e0d44499d58f885db44190a09c199a014091efbce7060c2127fccd23c3ef2981832a298ed5ab52720a5de135e6f32b7eb4ffd1f859da77854c0bddb99
|
|
7
|
+
data.tar.gz: dca7699a26fc3dee4e717470be7d778cd0591acffb282ec70be84e8b880c963023cec1a8ae54f80f199238bac3913f97eb7a6d5223dfe15b79115ed08bb7f337
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,93 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
|
|
|
6
6
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html): from 1.0.0 onwards a breaking change
|
|
7
7
|
to the public surface requires a major version.
|
|
8
8
|
|
|
9
|
+
## [1.2.0] — 2026-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`custom:` on `start_verification`**, up to 4096 characters of free text sent
|
|
14
|
+
as top-level `data.custom` and delivered unchanged to your callback server in
|
|
15
|
+
the `verification_request` body (the key is absent when unset). It is not
|
|
16
|
+
returned in API responses. A longer value is refused with the new
|
|
17
|
+
`custom_too_long` error code.
|
|
18
|
+
- **`sms: {autofill: ...}`** with type `app_hash`, `domain` or `none`.
|
|
19
|
+
`Verification#sms_autofill` reads back the stored marker, `app_hash` or
|
|
20
|
+
`domain`, whose `value` is the hash or the domain. New error codes
|
|
21
|
+
`autofill_invalid` and `sms_domain_not_configured`.
|
|
22
|
+
- **`template_group` in the `sms:` and `callout:` options**, to pick a template
|
|
23
|
+
group instead of the application's default. New readers
|
|
24
|
+
`Verification#sms_template_group`/`#callout_template_group`,
|
|
25
|
+
`#sms_template_group_used`/`#callout_template_group_used` and
|
|
26
|
+
`#sms_template_fallback_reason`/`#callout_template_fallback_reason`; the last
|
|
27
|
+
is an open set (`language_missing`, `version_pending`, `version_rejected`,
|
|
28
|
+
`audio_not_ready`, `group_missing`). An unknown group is refused with the new
|
|
29
|
+
`template_group_not_found` error code.
|
|
30
|
+
- **Secret rotation support in callback verification.** During a secret
|
|
31
|
+
rotation the service also sends an `x-signature-previous` header, signed with
|
|
32
|
+
the previous secret. `CallbackVerifier#valid?` takes an optional
|
|
33
|
+
`previous_signature:` and accepts the callback when either signature matches
|
|
34
|
+
the configured secret. `RailsCallbackVerifier` reads the header itself.
|
|
35
|
+
Upgrade the SDK before rotating a secret.
|
|
36
|
+
- **New 429 error codes:** `rate_limit_per_ip` (too many requests from this IP
|
|
37
|
+
address), `rate_limit_failed_auth` (too many failed authentication attempts
|
|
38
|
+
from this IP address), `rate_limit_per_account` (too many requests for this
|
|
39
|
+
account), `rate_limit_public_per_account` (too many public requests for this
|
|
40
|
+
account) and `volume_limit_reached` (the application reached its daily or
|
|
41
|
+
monthly verification limit; `Retry-After` can be days, so surface it instead
|
|
42
|
+
of retrying). All raise `RateLimitedError`.
|
|
43
|
+
- **New outcome codes:** `application_paused` and `destination_country_disabled`
|
|
44
|
+
(status `denied`).
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- **`destination_not_supported_for_channel` is now an outcome, not a 422.** A
|
|
49
|
+
start for a number with no supported country, no delivery for the requested
|
|
50
|
+
method, or in a blocked range returns `201` with status `failed` and this
|
|
51
|
+
`error_code`. Code that rescued the 422 now gets a failed verification
|
|
52
|
+
instead. The error code stays in the list for compatibility.
|
|
53
|
+
|
|
54
|
+
- **Supported Ruby is now 3.3+** (`required_ruby_version >= 3.3.0`). Ruby 3.1
|
|
55
|
+
went EOL on 2025-03-26 and 3.2 on 2026-03-31. CI tests 3.3, 3.4 and 4.0.
|
|
56
|
+
|
|
57
|
+
### Deprecated
|
|
58
|
+
|
|
59
|
+
- **The `sms: {app_hash: ...}` option and `Verification#sms_app_hash`.** Use
|
|
60
|
+
`autofill`. The option is sent as `autofill: {type: "app_hash", value: ...}`
|
|
61
|
+
with a `:deprecated` warning; passing it together with a non-nil `autofill`
|
|
62
|
+
raises `ArgumentError`.
|
|
63
|
+
- **The `stale_dispatch` outcome code.** The service no longer sends it and
|
|
64
|
+
reports these verifications as `dispatch_failed`. It stays documented so
|
|
65
|
+
existing matches keep working, and will be dropped from the docs in the next
|
|
66
|
+
major version.
|
|
67
|
+
|
|
68
|
+
### Docs
|
|
69
|
+
|
|
70
|
+
- A Testing section on the sandbox environment, which sends nothing and gives
|
|
71
|
+
a predefined result only for the reserved test numbers.
|
|
72
|
+
|
|
73
|
+
## [1.1.0] — 2026-10
|
|
74
|
+
|
|
75
|
+
### Added
|
|
76
|
+
|
|
77
|
+
- **A User-Agent header.** Every request now sends
|
|
78
|
+
`User-Agent: didww-verification-ruby/<version>`.
|
|
79
|
+
- **`code_length` on the `sms` and `callout` blocks**, readable via
|
|
80
|
+
`Verification#sms_code_length`/`#callout_code_length` — the length of the
|
|
81
|
+
generated code, 4–8 digits, set per application.
|
|
82
|
+
- **`RateLimitedError` (429, `destination_in_cooldown`)**, raised when
|
|
83
|
+
`start_verification` repeats for the same app and destination inside the
|
|
84
|
+
API's cooldown window. `#retry_after` reads the `Retry-After` header in
|
|
85
|
+
seconds (`nil` if missing or unparseable). The SDK never auto-retries it.
|
|
86
|
+
|
|
87
|
+
### Changed
|
|
88
|
+
|
|
89
|
+
- Corrected README claims that the verification lifetime is a fixed 120 s /
|
|
90
|
+
2-minute window: it is the per-app `code_expiry` setting (60–600 s, default
|
|
91
|
+
300), which `sms_interception_timeout` also equals.
|
|
92
|
+
- A 429 now raises `RateLimitedError`, an `APIError` subclass, instead of the
|
|
93
|
+
`APIError` base class. `rescue APIError` still catches it, but a strict
|
|
94
|
+
`e.instance_of?(APIError)` check no longer matches a 429.
|
|
95
|
+
|
|
9
96
|
## [1.0.0] — 2026-09
|
|
10
97
|
|
|
11
98
|
First public release.
|
data/README.md
CHANGED
|
@@ -33,7 +33,8 @@ verification.status # => "pending"
|
|
|
33
33
|
verification.pending? # => true
|
|
34
34
|
verification.to_h # => raw response data Hash with string keys
|
|
35
35
|
|
|
36
|
-
# Report the code the user entered (counts as an attempt; max 3, expires
|
|
36
|
+
# Report the code the user entered (counts as an attempt; max 3, expires per
|
|
37
|
+
# the app's code lifetime)
|
|
37
38
|
result = client.report_verification(
|
|
38
39
|
verification.id, delivery_method: "sms", code: "1234"
|
|
39
40
|
)
|
|
@@ -64,15 +65,23 @@ reads only the block matching `delivery_method` and ignores the others.
|
|
|
64
65
|
| Field | Description |
|
|
65
66
|
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
66
67
|
| `languages` | Preferred template languages as BCP 47 tags, most preferred first. Matched exactly, so the region subtag is required — `"pl"` does not match `pl-PL`. Unmatched tags fall back to `en-US`. |
|
|
67
|
-
| `
|
|
68
|
+
| `autofill` | Auto-fill marker for the delivered message. `{type: "app_hash", value: "..."}` is the Android SMS Retriever hash: exactly 11 characters of `[A-Za-z0-9+/]`. The message is then prefixed with `<#> ` and the hash appended as its last token, so the handset can auto-fill the code. `{type: "domain"}` adds a domain-bound line for the application's configured SMS domain. `{type: "none"}` explicitly asks for no marker. Omit it to use the application default. |
|
|
69
|
+
| `template_group` | Name of one of your template groups, used instead of the application's default. |
|
|
68
70
|
|
|
69
|
-
`
|
|
71
|
+
`autofill` is here because a Ruby backend often starts the verification on behalf
|
|
70
72
|
of an Android app — the hash identifies that app, so only the app can compute it.
|
|
73
|
+
`{type: "domain"}` takes no `value`; on an application with no SMS domain it is
|
|
74
|
+
refused with `sms_domain_not_configured`. A malformed `autofill` is refused with
|
|
75
|
+
`autofill_invalid`.
|
|
76
|
+
The former `app_hash:` key still works as a deprecated alias for
|
|
77
|
+
`autofill: {type: "app_hash", value: ...}` (it prints a deprecation warning
|
|
78
|
+
when Ruby's `:deprecated` warnings are enabled). Passing both with non-nil values
|
|
79
|
+
raises `ArgumentError`; `autofill: nil` counts as absent.
|
|
71
80
|
|
|
72
81
|
```ruby
|
|
73
82
|
client.start_verification(
|
|
74
83
|
destination: "+4915112345678", delivery_method: "sms",
|
|
75
|
-
sms: {languages: ["en-US"], app_hash: "A1b2C3d4E5f"}
|
|
84
|
+
sms: {languages: ["en-US"], autofill: {type: "app_hash", value: "A1b2C3d4E5f"}}
|
|
76
85
|
)
|
|
77
86
|
```
|
|
78
87
|
|
|
@@ -81,6 +90,10 @@ client.start_verification(
|
|
|
81
90
|
| Field | Description |
|
|
82
91
|
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
83
92
|
| `languages` | Preferred announcement languages — the same tags, order and matching rules as the `sms:` ones, so the same list is valid for either method. It is not shared between them, though: each method reads only its own block, so the list goes under `callout:` here and under `sms:` there. The catalogues are separate too, so a tag that has a message template but no recording is accepted and falls back to `en-US`. |
|
|
93
|
+
| `template_group` | Name of one of your template groups, as for `sms:`. |
|
|
94
|
+
|
|
95
|
+
An unknown `template_group` on either method is refused with
|
|
96
|
+
`template_group_not_found`.
|
|
84
97
|
|
|
85
98
|
```ruby
|
|
86
99
|
client.start_verification(
|
|
@@ -95,6 +108,20 @@ API ships it. The cost of that is no typo protection: an unrecognized option is
|
|
|
95
108
|
ignored rather than rejected, and a misspelled key returns `201` with the
|
|
96
109
|
defaults applied, not an error.
|
|
97
110
|
|
|
111
|
+
### `custom:`
|
|
112
|
+
|
|
113
|
+
Optional free text, up to 4096 characters, sent as top-level `data.custom` (an
|
|
114
|
+
empty string counts as absent). It is not returned by the API; it reaches your
|
|
115
|
+
callback server as `data.custom` in the `verification_request` body; the key is
|
|
116
|
+
absent when unset. A longer value is refused with `custom_too_long`.
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
client.start_verification(
|
|
120
|
+
destination: "+4915112345678", delivery_method: "sms",
|
|
121
|
+
custom: "order-42"
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
98
125
|
### Reading a method block back
|
|
99
126
|
|
|
100
127
|
The response carries the block for the method that was used, readable field by
|
|
@@ -103,11 +130,19 @@ field or as a whole:
|
|
|
103
130
|
```ruby
|
|
104
131
|
v.sms_template # => "Your code is {{CODE}}"
|
|
105
132
|
v.sms_language # => "en-US"
|
|
106
|
-
v.sms_interception_timeout # =>
|
|
107
|
-
v.
|
|
133
|
+
v.sms_interception_timeout # => 300
|
|
134
|
+
v.sms_code_length # => 6
|
|
135
|
+
v.sms_autofill # => {"type" => "app_hash", "value" => "A1b2C3d4E5f"}, or nil
|
|
136
|
+
v.sms_template_group # => "checkout", or nil if none applied
|
|
137
|
+
v.sms_template_group_used # => true
|
|
138
|
+
v.sms_template_fallback_reason # => nil, or e.g. "language_missing"
|
|
108
139
|
v.sms # => the raw block, or nil on a callout verification
|
|
109
140
|
|
|
110
141
|
v.callout_language # => "de-DE"
|
|
142
|
+
v.callout_code_length # => 6
|
|
143
|
+
v.callout_template_group # => "checkout", or nil if none applied
|
|
144
|
+
v.callout_template_group_used # => true
|
|
145
|
+
v.callout_template_fallback_reason # => nil, or e.g. "audio_not_ready"
|
|
111
146
|
v.callout # => the raw block, or nil on an sms verification
|
|
112
147
|
```
|
|
113
148
|
|
|
@@ -119,10 +154,21 @@ because the two catalogues are separate, a tag honoured on `sms` can still fall
|
|
|
119
154
|
back on `callout`.
|
|
120
155
|
|
|
121
156
|
`sms_interception_timeout` is how many seconds a client should keep an on-device
|
|
122
|
-
SMS listener armed. It
|
|
123
|
-
for the verification — manual
|
|
124
|
-
|
|
125
|
-
|
|
157
|
+
SMS listener armed. It equals the app's configured code lifetime (60–600 s,
|
|
158
|
+
default 300) and is **not** a deadline for the verification itself — manual
|
|
159
|
+
entry keeps working until `expires_at`.
|
|
160
|
+
`sms_code_length`/`callout_code_length` is the length of this verification's
|
|
161
|
+
code, 4–8 digits, set per application (default 6).
|
|
162
|
+
`sms_autofill` is echoed back only when a hash or domain marker was stored, so it
|
|
163
|
+
reflects what was persisted rather than what was requested; for `domain`, `value`
|
|
164
|
+
is the domain. `sms_app_hash` is a deprecated reader derived from it: the `value`
|
|
165
|
+
when the type is `app_hash`, otherwise `nil`.
|
|
166
|
+
`*_template_group_used` is true when the group's template was used. When it is
|
|
167
|
+
false, `*_template_fallback_reason` says why: `language_missing`,
|
|
168
|
+
`version_pending`, `version_rejected`, `audio_not_ready` or `group_missing` (the
|
|
169
|
+
group no longer exists; `*_template_group` is then `nil`). Both are false/`nil`
|
|
170
|
+
when no template group applies at all. Like error codes, the reasons are an open
|
|
171
|
+
set of plain strings.
|
|
126
172
|
|
|
127
173
|
### Address by phone number
|
|
128
174
|
|
|
@@ -161,6 +207,15 @@ DIDWW::OTPVerification.configure do |c|
|
|
|
161
207
|
end
|
|
162
208
|
```
|
|
163
209
|
|
|
210
|
+
## Testing
|
|
211
|
+
|
|
212
|
+
The sandbox sends no real SMS and places no real calls. Only the reserved test
|
|
213
|
+
numbers give a predefined result, such as delivered, failed or expired; a
|
|
214
|
+
delivered one has a fixed code. Any other number is never delivered to, and its
|
|
215
|
+
verification ends as failed. The test numbers, their outcomes and the code to
|
|
216
|
+
submit are listed in
|
|
217
|
+
[Testing on sandbox](https://doc.didww.com/otp-verification/testing.html).
|
|
218
|
+
|
|
164
219
|
## Configuration
|
|
165
220
|
|
|
166
221
|
Set global defaults; every field is overridable per `Client`.
|
|
@@ -197,6 +252,8 @@ DIDWW::OTPVerification::Client.new(
|
|
|
197
252
|
) { |conn| conn.proxy = "http://proxy:3128" }
|
|
198
253
|
```
|
|
199
254
|
|
|
255
|
+
Every request carries `User-Agent: didww-verification-ruby/<version>`.
|
|
256
|
+
|
|
200
257
|
## Auth modes
|
|
201
258
|
|
|
202
259
|
| Mode | Header | Secret | Notes |
|
|
@@ -265,15 +322,35 @@ ok = verifier.valid?(
|
|
|
265
322
|
content_type: request.content_type,
|
|
266
323
|
body: request.raw_post, # RAW body bytes — do not re-serialize
|
|
267
324
|
timestamp: request.headers["x-timestamp"],
|
|
268
|
-
signature: signature
|
|
325
|
+
signature: signature,
|
|
326
|
+
previous_signature: request.headers["x-signature-previous"] # optional
|
|
269
327
|
)
|
|
270
328
|
|
|
271
329
|
render json: { action: ok ? "allow" : "deny" }
|
|
272
330
|
```
|
|
273
331
|
|
|
332
|
+
The `verification_request` body carries the `custom` value you passed to
|
|
333
|
+
`start_verification` as `data.custom` (absent when unset). When the verification
|
|
334
|
+
is started from a mobile app, that value is client-controlled: validate it
|
|
335
|
+
against your own records rather than trusting it.
|
|
336
|
+
|
|
274
337
|
The verifier also enforces a 5-minute timestamp window (configurable via
|
|
275
338
|
`tolerance:`) to reject replays.
|
|
276
339
|
|
|
340
|
+
### Rotating the application secret
|
|
341
|
+
|
|
342
|
+
While a rotated-out secret is still valid, DIDWW also sends an
|
|
343
|
+
`x-signature-previous` header: the same signature computed with the previous
|
|
344
|
+
secret. The verifier accepts a callback when either the `Authorization`
|
|
345
|
+
signature or `previous_signature` matches the secret you configured. A receiver
|
|
346
|
+
can therefore switch from the old secret to the new one at any point during the
|
|
347
|
+
grace period, with no failed callbacks. Without the header the verifier behaves
|
|
348
|
+
as before.
|
|
349
|
+
|
|
350
|
+
Upgrade the SDK before you rotate a secret, because an older version ignores
|
|
351
|
+
`x-signature-previous` and rejects callbacks signed with the new secret while
|
|
352
|
+
the old one is configured.
|
|
353
|
+
|
|
277
354
|
### Rails
|
|
278
355
|
|
|
279
356
|
`RailsCallbackVerifier` pulls every signed field off an `ActionDispatch`
|
|
@@ -327,13 +404,23 @@ carries `error_code` (switch on it) and `error_detail` (display it). These are
|
|
|
327
404
|
| ---------- | -------- | -------------------------------------------------------------------------------------------- |
|
|
328
405
|
| `pending` | no | `nil` — on its way, or awaiting a report |
|
|
329
406
|
| `verified` | yes | `nil` |
|
|
330
|
-
| `failed` | yes | `too_many_attempts`, `dispatch_failed`, `
|
|
331
|
-
| `expired` | yes | `expired` — past the
|
|
332
|
-
| `denied` | yes | `denied_by_callback`, `denied_missing_callback_url`, `denied_invalid_callback_response`
|
|
407
|
+
| `failed` | yes | `too_many_attempts`, `dispatch_failed`, `superseded`, `application_deleted`, `destination_not_supported_for_channel`, `stale_dispatch` (deprecated) |
|
|
408
|
+
| `expired` | yes | `expired` — past the application's code lifetime (60–600 s, default 300) |
|
|
409
|
+
| `denied` | yes | `denied_by_callback`, `denied_missing_callback_url`, `denied_invalid_callback_response`, `application_paused`, `destination_country_disabled` |
|
|
333
410
|
|
|
334
411
|
`superseded` means a newer `start_verification` for the same number retired this
|
|
335
412
|
one — only the newest verification per number stays active.
|
|
336
413
|
|
|
414
|
+
Some starts are refused as an outcome rather than an error: a `201` whose status
|
|
415
|
+
is `denied` (`application_paused`, `destination_country_disabled`) or `failed`
|
|
416
|
+
(`destination_not_supported_for_channel`: the number has no supported country,
|
|
417
|
+
can't be reached by this delivery method, or is in a blocked range). Earlier
|
|
418
|
+
versions returned the last one as a `422`; that code stays in the error list for
|
|
419
|
+
compatibility only.
|
|
420
|
+
|
|
421
|
+
`stale_dispatch` is deprecated: the service no longer sends it and reports these
|
|
422
|
+
verifications as `dispatch_failed`.
|
|
423
|
+
|
|
337
424
|
```ruby
|
|
338
425
|
v = client.get_verification(id)
|
|
339
426
|
|
|
@@ -362,6 +449,7 @@ Non-2xx responses raise a typed error under `DIDWW::OTPVerification::Error`:
|
|
|
362
449
|
| `BalanceInsufficientError` | 402 | `balance_insufficient` |
|
|
363
450
|
| `NotFoundError` | 404 | `not_found` |
|
|
364
451
|
| `ValidationError` | 400 / 422 | `parameter_missing` / per-field codes |
|
|
452
|
+
| `RateLimitedError` | 429 | `destination_in_cooldown`, `rate_limit_*`, `volume_limit_reached` |
|
|
365
453
|
| `ServerError` | 5xx | `internal_error` |
|
|
366
454
|
|
|
367
455
|
Every error carries the API's coded envelope: `#errors` is an array of
|
|
@@ -390,10 +478,27 @@ end
|
|
|
390
478
|
The SDK does **not** auto-retry. If you add `faraday-retry`, exclude `POST`
|
|
391
479
|
(double-charges) and `PATCH` (each report counts against the 3-attempt limit).
|
|
392
480
|
|
|
481
|
+
A 429 raises `RateLimitedError`, and its code says which limit was hit:
|
|
482
|
+
|
|
483
|
+
- `destination_in_cooldown`: a verification for this destination was requested
|
|
484
|
+
too recently.
|
|
485
|
+
- `rate_limit_per_ip`: too many requests from this IP address.
|
|
486
|
+
- `rate_limit_failed_auth`: too many failed authentication attempts from this
|
|
487
|
+
IP address.
|
|
488
|
+
- `rate_limit_per_account`: too many requests for this account.
|
|
489
|
+
- `rate_limit_public_per_account`: too many public requests for this account.
|
|
490
|
+
- `volume_limit_reached`: the application reached its daily or monthly
|
|
491
|
+
verification limit. `Retry-After` can be long (days) — surface it to the
|
|
492
|
+
user instead of retrying. Contact support to raise the limit.
|
|
493
|
+
|
|
494
|
+
`#retry_after` gives the wait in seconds (`nil` if the `Retry-After` header is
|
|
495
|
+
missing or unparseable) — wait that long before trying again yourself; never
|
|
496
|
+
auto-retry it.
|
|
497
|
+
|
|
393
498
|
## Development
|
|
394
499
|
|
|
395
500
|
Development is pinned to the Ruby in `.ruby-version`; the gem itself supports
|
|
396
|
-
3.
|
|
501
|
+
3.3+.
|
|
397
502
|
|
|
398
503
|
```sh
|
|
399
504
|
bundle install
|
|
@@ -19,7 +19,8 @@ module DIDWW
|
|
|
19
19
|
# verifier.valid?(
|
|
20
20
|
# method: "POST", path: "/callbacks/didww",
|
|
21
21
|
# content_type: "application/json", body: raw_body,
|
|
22
|
-
# timestamp: request.headers["x-timestamp"], signature: signature
|
|
22
|
+
# timestamp: request.headers["x-timestamp"], signature: signature,
|
|
23
|
+
# previous_signature: request.headers["x-signature-previous"]
|
|
23
24
|
# )
|
|
24
25
|
class CallbackVerifier
|
|
25
26
|
# @param tolerance [Integer] max allowed clock skew, in seconds (server uses 5 minutes).
|
|
@@ -41,16 +42,21 @@ module DIDWW
|
|
|
41
42
|
[key, signature]
|
|
42
43
|
end
|
|
43
44
|
|
|
44
|
-
# @
|
|
45
|
-
#
|
|
46
|
-
|
|
45
|
+
# @param previous_signature [String, nil] the `x-signature-previous` header, sent
|
|
46
|
+
# during a secret rotation and signed with the previous secret. Ignored when absent.
|
|
47
|
+
# @return [Boolean] true when the timestamp is fresh and the signature, or the
|
|
48
|
+
# previous signature, matches (constant-time comparison).
|
|
49
|
+
def valid?(method:, path:, content_type:, body:, timestamp:, signature:, previous_signature: nil)
|
|
47
50
|
return false if Util.blank?(signature)
|
|
48
51
|
return false unless fresh?(timestamp)
|
|
49
52
|
|
|
50
53
|
expected = @signer.sign(
|
|
51
54
|
method:, path:, content_type:, body:, timestamp: timestamp
|
|
52
55
|
)
|
|
53
|
-
|
|
56
|
+
# Both comparisons always run, so timing does not reveal which header matched.
|
|
57
|
+
current = secure_compare(expected, signature)
|
|
58
|
+
previous = secure_compare(expected, previous_signature)
|
|
59
|
+
current || previous
|
|
54
60
|
end
|
|
55
61
|
|
|
56
62
|
private
|
|
@@ -63,6 +69,7 @@ module DIDWW
|
|
|
63
69
|
|
|
64
70
|
# Constant-time string comparison via the audited stdlib implementation.
|
|
65
71
|
def secure_compare(a, b)
|
|
72
|
+
return false if b.nil?
|
|
66
73
|
return false unless a.bytesize == b.bytesize
|
|
67
74
|
|
|
68
75
|
OpenSSL.fixed_length_secure_compare(a, b)
|
|
@@ -21,6 +21,7 @@ module DIDWW
|
|
|
21
21
|
# Unspecified arguments fall back to DIDWW::OTPVerification.configuration.
|
|
22
22
|
class Client
|
|
23
23
|
API_PREFIX = "/api/v1".freeze
|
|
24
|
+
USER_AGENT = "didww-verification-ruby/#{VERSION}".freeze
|
|
24
25
|
|
|
25
26
|
attr_reader :key, :auth_mode, :base_url
|
|
26
27
|
|
|
@@ -49,10 +50,21 @@ module DIDWW
|
|
|
49
50
|
# reads only the block matching +delivery_method+ and ignores the others.
|
|
50
51
|
# An unrecognized option is ignored rather than rejected, so a typo there
|
|
51
52
|
# fails silently. Methods with no options of their own take no keyword.
|
|
52
|
-
|
|
53
|
+
#
|
|
54
|
+
# +custom+ is free text (up to 4096 characters) delivered to your
|
|
55
|
+
# callback server as +data.custom+; it is not part of the response.
|
|
56
|
+
#
|
|
57
|
+
# The +sms+ key +app_hash+ is deprecated: it is sent as
|
|
58
|
+
# <tt>autofill: {type: "app_hash", value: ...}</tt> with a deprecation
|
|
59
|
+
# warning. Passing it together with +autofill+ raises ArgumentError,
|
|
60
|
+
# because the API rejects that combination. Both apply only when
|
|
61
|
+
# +delivery_method+ is +sms+, the only method that reads +autofill+.
|
|
62
|
+
def start_verification(destination:, delivery_method:, sms: nil, callout: nil, custom: nil)
|
|
53
63
|
data = {destination:, delivery_method:}
|
|
64
|
+
data[:custom] = custom unless custom.nil?
|
|
54
65
|
data[:sms] = sms unless sms.nil?
|
|
55
66
|
data[:callout] = callout unless callout.nil?
|
|
67
|
+
data[:sms] = migrate_app_hash(data[:sms]) if delivery_method.to_s == "sms" && data[:sms].is_a?(Hash)
|
|
56
68
|
request(:post, "#{API_PREFIX}/verifications", data)
|
|
57
69
|
end
|
|
58
70
|
|
|
@@ -84,6 +96,26 @@ module DIDWW
|
|
|
84
96
|
|
|
85
97
|
private
|
|
86
98
|
|
|
99
|
+
# Maps the deprecated sms +app_hash+ onto +autofill+ on a copy, so the
|
|
100
|
+
# caller's hash is left alone.
|
|
101
|
+
def migrate_app_hash(sms)
|
|
102
|
+
keys = [:app_hash, "app_hash"].select { |k| sms.key?(k) }
|
|
103
|
+
return sms if keys.empty?
|
|
104
|
+
|
|
105
|
+
# A nil hash counts as absent, as the API always treated it.
|
|
106
|
+
key = keys.find { |k| !sms[k].nil? }
|
|
107
|
+
return sms.except(*keys) unless key
|
|
108
|
+
# A nil autofill counts as absent too, as the API treats it.
|
|
109
|
+
autofill_keys = [:autofill, "autofill"].select { |k| sms.key?(k) }
|
|
110
|
+
if autofill_keys.any? { |k| !sms[k].nil? }
|
|
111
|
+
raise ArgumentError, "sms: pass either autofill or the deprecated app_hash, not both"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
warn "DIDWW::OTPVerification: sms app_hash is deprecated; " \
|
|
115
|
+
"use sms: {autofill: {type: \"app_hash\", value: ...}}", category: :deprecated, uplevel: 2
|
|
116
|
+
sms.except(*keys, *autofill_keys).merge(autofill: {type: "app_hash", value: sms[key]})
|
|
117
|
+
end
|
|
118
|
+
|
|
87
119
|
# Percent-encode a value for use as a single URL path segment. Notably
|
|
88
120
|
# turns a leading "+" into "%2B" so it survives proxies that would
|
|
89
121
|
# otherwise decode "+" to a space in the path.
|
|
@@ -110,11 +142,9 @@ module DIDWW
|
|
|
110
142
|
end
|
|
111
143
|
|
|
112
144
|
errors = parse_errors(body)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
response: response
|
|
117
|
-
)
|
|
145
|
+
klass = error_class(response.status)
|
|
146
|
+
extra = (klass == RateLimitedError) ? {retry_after: parse_retry_after(response)} : {}
|
|
147
|
+
raise klass.new(status: response.status, errors: errors, response: response, **extra)
|
|
118
148
|
end
|
|
119
149
|
|
|
120
150
|
# Map the {"errors": [{"code", "detail"}]} envelope to ErrorItem objects.
|
|
@@ -131,12 +161,20 @@ module DIDWW
|
|
|
131
161
|
end
|
|
132
162
|
end
|
|
133
163
|
|
|
164
|
+
# @return [Integer, nil] the Retry-After header in seconds, nil if
|
|
165
|
+
# missing or not a plain non-negative integer (e.g. an HTTP-date).
|
|
166
|
+
def parse_retry_after(response)
|
|
167
|
+
value = response.headers["Retry-After"].to_s
|
|
168
|
+
Integer(value, 10) if value.match?(/\A\d+\z/)
|
|
169
|
+
end
|
|
170
|
+
|
|
134
171
|
def error_class(status)
|
|
135
172
|
case status
|
|
136
173
|
when 401 then UnauthorizedError
|
|
137
174
|
when 402 then BalanceInsufficientError
|
|
138
175
|
when 404 then NotFoundError
|
|
139
176
|
when 400, 422 then ValidationError
|
|
177
|
+
when 429 then RateLimitedError
|
|
140
178
|
when 500..599 then ServerError
|
|
141
179
|
else APIError
|
|
142
180
|
end
|
|
@@ -147,6 +185,8 @@ module DIDWW
|
|
|
147
185
|
conn.request :json
|
|
148
186
|
conn.response :json, content_type: /\bjson$/
|
|
149
187
|
@faraday_block&.call(conn)
|
|
188
|
+
# After the caller's block, so the block cannot replace it.
|
|
189
|
+
conn.headers["User-Agent"] = USER_AGENT
|
|
150
190
|
# Auth (signing) must be installed last so it sees the final request
|
|
151
191
|
# bytes/headers/path, after any user middleware from @faraday_block.
|
|
152
192
|
apply_auth(conn)
|
|
@@ -48,6 +48,22 @@ module DIDWW
|
|
|
48
48
|
# 400 Bad Request / 422 Unprocessable Content (validation errors in +errors+).
|
|
49
49
|
class ValidationError < APIError; end
|
|
50
50
|
|
|
51
|
+
# 429 Too Many Requests. +code+ says which limit was hit:
|
|
52
|
+
# +destination_in_cooldown+ (this destination was requested too recently),
|
|
53
|
+
# a +rate_limit_*+ request limit, or +volume_limit_reached+ (a daily or
|
|
54
|
+
# monthly limit; +retry_after+ can be days).
|
|
55
|
+
# Never auto-retry this.
|
|
56
|
+
class RateLimitedError < APIError
|
|
57
|
+
# @return [Integer, nil] seconds to wait, from the Retry-After header.
|
|
58
|
+
# Nil if the header is missing or not a plain integer.
|
|
59
|
+
attr_reader :retry_after
|
|
60
|
+
|
|
61
|
+
def initialize(message = nil, status:, errors: [], response: nil, retry_after: nil)
|
|
62
|
+
@retry_after = retry_after
|
|
63
|
+
super(message, status: status, errors: errors, response: response)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
51
67
|
# 5xx Server Error (+internal_error+ code).
|
|
52
68
|
class ServerError < APIError; end
|
|
53
69
|
end
|
|
@@ -55,10 +55,46 @@ module DIDWW
|
|
|
55
55
|
@sms && @sms["interception_timeout"]
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
# @return [
|
|
59
|
-
#
|
|
58
|
+
# @return [Integer, nil] the length of this verification's code, 4..8
|
|
59
|
+
# (set per application). Only present for the +sms+ method.
|
|
60
|
+
def sms_code_length
|
|
61
|
+
@sms && @sms["code_length"]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Hash, nil] the stored autofill marker, +{"type", "value"}+ with
|
|
65
|
+
# type +app_hash+ (value: the hash) or +domain+ (value: the domain);
|
|
66
|
+
# +nil+ unless one was stored on this verification.
|
|
67
|
+
def sms_autofill
|
|
68
|
+
@sms && @sms["autofill"]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @deprecated Use {#sms_autofill}.
|
|
72
|
+
# @return [String, nil] the SMS Retriever hash, when the stored autofill
|
|
73
|
+
# is of type +app_hash+.
|
|
60
74
|
def sms_app_hash
|
|
61
|
-
|
|
75
|
+
autofill = sms_autofill
|
|
76
|
+
return @sms && @sms["app_hash"] unless autofill
|
|
77
|
+
|
|
78
|
+
autofill["value"] if autofill["type"] == "app_hash"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [String, nil] the template group applied; nil when none applies
|
|
82
|
+
# or it no longer exists. Only present for the +sms+ method.
|
|
83
|
+
def sms_template_group
|
|
84
|
+
@sms && @sms["template_group"]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @return [Boolean, nil] true when the group's template was used. Only
|
|
88
|
+
# present for the +sms+ method.
|
|
89
|
+
def sms_template_group_used
|
|
90
|
+
@sms && @sms["template_group_used"]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [String, nil] why the group's template was not used, e.g.
|
|
94
|
+
# +language_missing+ (an open set); nil when it was or when no group
|
|
95
|
+
# applies. Only present for the +sms+ method.
|
|
96
|
+
def sms_template_fallback_reason
|
|
97
|
+
@sms && @sms["template_fallback_reason"]
|
|
62
98
|
end
|
|
63
99
|
|
|
64
100
|
# The tag the announcement is played in: the first requested language the
|
|
@@ -71,6 +107,31 @@ module DIDWW
|
|
|
71
107
|
@callout && @callout["language"]
|
|
72
108
|
end
|
|
73
109
|
|
|
110
|
+
# @return [Integer, nil] the length of this verification's code, 4..8
|
|
111
|
+
# (set per application). Only present for the +callout+ method.
|
|
112
|
+
def callout_code_length
|
|
113
|
+
@callout && @callout["code_length"]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @return [String, nil] the template group applied; nil when none applies
|
|
117
|
+
# or it no longer exists. Only present for the +callout+ method.
|
|
118
|
+
def callout_template_group
|
|
119
|
+
@callout && @callout["template_group"]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Boolean, nil] true when the group's template was used. Only
|
|
123
|
+
# present for the +callout+ method.
|
|
124
|
+
def callout_template_group_used
|
|
125
|
+
@callout && @callout["template_group_used"]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [String, nil] why the group's template was not used, e.g.
|
|
129
|
+
# +audio_not_ready+ (an open set); nil when it was or when no group
|
|
130
|
+
# applies. Only present for the +callout+ method.
|
|
131
|
+
def callout_template_fallback_reason
|
|
132
|
+
@callout && @callout["template_fallback_reason"]
|
|
133
|
+
end
|
|
134
|
+
|
|
74
135
|
def pending? = status == "pending"
|
|
75
136
|
def verified? = status == "verified"
|
|
76
137
|
def failed? = status == "failed"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: didww-otp_verification
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- DIDWW
|
|
@@ -85,7 +85,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
85
85
|
requirements:
|
|
86
86
|
- - ">="
|
|
87
87
|
- !ruby/object:Gem::Version
|
|
88
|
-
version: 3.
|
|
88
|
+
version: 3.3.0
|
|
89
89
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
90
90
|
requirements:
|
|
91
91
|
- - ">="
|