didww-otp_verification 1.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 +79 -0
- data/LICENSE +21 -0
- data/README.md +409 -0
- data/lib/didww/otp_verification/callback_verifier.rb +72 -0
- data/lib/didww/otp_verification/client.rb +196 -0
- data/lib/didww/otp_verification/configuration.rb +52 -0
- data/lib/didww/otp_verification/errors.rb +54 -0
- data/lib/didww/otp_verification/middleware/signature.rb +37 -0
- data/lib/didww/otp_verification/rails.rb +55 -0
- data/lib/didww/otp_verification/signer.rb +59 -0
- data/lib/didww/otp_verification/util.rb +13 -0
- data/lib/didww/otp_verification/verification.rb +85 -0
- data/lib/didww/otp_verification/version.rb +5 -0
- data/lib/didww/otp_verification.rb +39 -0
- metadata +98 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 95cc4c0b5d246e12eeb76854405106c8ef277a4790dbbfee8620f6984a6074a8
|
|
4
|
+
data.tar.gz: 5d157aba75750f9b9a605b1d114dfdd289dbc9ea949b3d1d2a0a5636da12c0a9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: fa0921c7b444d6efaba56f12c050d68b408c98f956a21fd3656c3672eabc8c73415a8d3fb5e16bc9c32f7bbcfca930856d5cc1c80fabc0f98a758e40bfb22d59
|
|
7
|
+
data.tar.gz: aa033d4b455850aa7ed1c5a79861bfb35a303fd8e677387ebf1d77be9ccb78468cbb5bcc4d7fe1438995a3277faa2eae67c07d689068aa5fa531bd54627cab62
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable changes to the DIDWW OTP Verification Ruby SDK.
|
|
4
|
+
|
|
5
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions follow
|
|
6
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html): from 1.0.0 onwards a breaking change
|
|
7
|
+
to the public surface requires a major version.
|
|
8
|
+
|
|
9
|
+
## [1.0.0] — 2026-09
|
|
10
|
+
|
|
11
|
+
First public release.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Every verification endpoint, addressable two ways.** `start_verification`,
|
|
16
|
+
`report_verification` and `get_verification` take a verification id;
|
|
17
|
+
`report_verification_by_number` and `get_verification_by_number` take a phone number in E.164
|
|
18
|
+
with the leading `+` optional, for when the id was never persisted. The number is
|
|
19
|
+
percent-encoded into its path segment, so a leading `+` survives a proxy that would otherwise
|
|
20
|
+
decode it to a space. "Latest" means the active verification when there is one, and otherwise
|
|
21
|
+
the most recent finished attempt — so an outcome that was missed can still be read.
|
|
22
|
+
|
|
23
|
+
- **All three authentication modes**, selectable per client or globally. `:basic` sends
|
|
24
|
+
`Basic base64(key:secret)`. `:public` sends `Application <key>` and uses no secret, for
|
|
25
|
+
applications whose callback URL authorises each start. `:application` signs every request with
|
|
26
|
+
HMAC-SHA256 and adds an `x-timestamp` header; signing is automatic and is installed as the last
|
|
27
|
+
Faraday middleware, so the signature always covers the exact bytes that go on the wire — after
|
|
28
|
+
any middleware the caller added. A secret that is not valid URL-safe base64 fails at
|
|
29
|
+
construction rather than on the first request.
|
|
30
|
+
|
|
31
|
+
- **The API's coded error envelope as typed exceptions.** A non-2xx response raises under
|
|
32
|
+
`DIDWW::OTPVerification::Error`: `UnauthorizedError` (401), `BalanceInsufficientError` (402),
|
|
33
|
+
`NotFoundError` (404), `ValidationError` (400/422) and `ServerError` (5xx); any other status
|
|
34
|
+
— a 403 or a 429 from a proxy, say — raises the `APIError` base class rather than going
|
|
35
|
+
unnoticed. Each carries `#errors`, an array of `ErrorItem`s with a stable `#code` and a fixed
|
|
36
|
+
human `#detail`, plus `#code` and `#codes` shortcuts. One response can carry several errors,
|
|
37
|
+
so `#codes` is the reliable one.
|
|
38
|
+
|
|
39
|
+
- **Verification outcomes as data, not exceptions.** A failed, expired or denied verification is
|
|
40
|
+
a successful HTTP call: `Verification#status`, `#error_code` and `#error_detail` say what
|
|
41
|
+
happened, and `#finished?` is the signal to stop polling. Statuses and codes are plain strings
|
|
42
|
+
and an open set, so new ones ship without an SDK release.
|
|
43
|
+
|
|
44
|
+
- **Inbound callback signature verification.** `CallbackVerifier` checks the signature DIDWW
|
|
45
|
+
sends with a `verification_request` callback, enforcing a configurable 5-minute timestamp
|
|
46
|
+
window against replays and comparing in constant time. Requiring
|
|
47
|
+
`didww/otp_verification/callback_verifier` on its own loads no HTTP client at all, so a
|
|
48
|
+
service that only receives callbacks never pays the cost of loading Faraday. (The gem still
|
|
49
|
+
declares Faraday as a runtime dependency, so it is installed either way.)
|
|
50
|
+
|
|
51
|
+
- **A Rails helper for the same.** `RailsCallbackVerifier` reads every signed field off an
|
|
52
|
+
`ActionDispatch::Request`, using `raw_post` so the received bytes are what get verified. It
|
|
53
|
+
lives in `didww/otp_verification/rails`, which is deliberately not auto-required — Rails is
|
|
54
|
+
never a runtime dependency of this gem.
|
|
55
|
+
|
|
56
|
+
- **Per-delivery-method options, passed through verbatim.** `sms:` carries `languages` and
|
|
57
|
+
`app_hash`, `callout:` carries `languages`; each travels under the key the API names the method
|
|
58
|
+
by, and only the block matching `delivery_method` is read. The response block comes back the
|
|
59
|
+
same way and is readable via `#sms_template`, `#sms_language`, `#sms_interception_timeout`,
|
|
60
|
+
`#sms_app_hash` and `#callout_language`, or `#sms`/`#callout` for the whole thing. Naming no
|
|
61
|
+
field inside a block individually means a field the API gains needs no release here, and a
|
|
62
|
+
method that gains a block gains one keyword rather than a new call shape.
|
|
63
|
+
|
|
64
|
+
- **The language the server actually chose.** `#sms_language` and `#callout_language` report the
|
|
65
|
+
tag the message was rendered in or the announcement is played in — the first requested tag that
|
|
66
|
+
matched, otherwise the `en-US` fallback — so a fallback is detected rather than guessed at. The
|
|
67
|
+
templates and the recordings are separate catalogues, so a tag honoured on `sms` can still fall
|
|
68
|
+
back on `callout`.
|
|
69
|
+
|
|
70
|
+
- **Configuration with no global-only settings.** Credentials, environment, auth mode, Faraday
|
|
71
|
+
adapter and a Faraday customization block can all be set globally through
|
|
72
|
+
`DIDWW::OTPVerification.configure` and overridden per `Client` — which is what a multi-tenant
|
|
73
|
+
host needs. `register_env` adds environments beyond the published production and sandbox.
|
|
74
|
+
|
|
75
|
+
### Notes
|
|
76
|
+
|
|
77
|
+
- Requires Ruby 3.1 or newer. Faraday is the only runtime HTTP dependency.
|
|
78
|
+
- The SDK does not auto-retry. If you add `faraday-retry`, exclude `POST` (it would
|
|
79
|
+
double-charge) and `PATCH` (each report counts against the three-attempt limit).
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DIDWW
|
|
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,409 @@
|
|
|
1
|
+
# DIDWW OTP Verification Ruby SDK
|
|
2
|
+
|
|
3
|
+
Ruby client for the [DIDWW OTP Verification API](https://verification.didww.com)
|
|
4
|
+
(`/api/v1`). Wraps all verification endpoints (address a verification by id or
|
|
5
|
+
by phone number), all three auth modes, and inbound callback signature
|
|
6
|
+
verification.
|
|
7
|
+
|
|
8
|
+
📖 [API documentation](https://doc.didww.com/otp-verification/index.html)
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
gem "didww-otp_verification"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
require "didww/otp_verification"
|
|
20
|
+
|
|
21
|
+
client = DIDWW::OTPVerification::Client.new(
|
|
22
|
+
key: "your-app-key",
|
|
23
|
+
secret: "your-app-secret"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
verification = client.start_verification(
|
|
27
|
+
destination: "+4915112345678",
|
|
28
|
+
delivery_method: "sms", # "sms" | "callout"
|
|
29
|
+
sms: {languages: ["en-US"]} # optional, see below
|
|
30
|
+
)
|
|
31
|
+
verification.id # => "0f9c8b7a-..."
|
|
32
|
+
verification.status # => "pending"
|
|
33
|
+
verification.pending? # => true
|
|
34
|
+
verification.to_h # => raw response data Hash with string keys
|
|
35
|
+
|
|
36
|
+
# Report the code the user entered (counts as an attempt; max 3, expires in 2 min)
|
|
37
|
+
result = client.report_verification(
|
|
38
|
+
verification.id, delivery_method: "sms", code: "1234"
|
|
39
|
+
)
|
|
40
|
+
result.verified? # => true/false
|
|
41
|
+
|
|
42
|
+
# Poll status
|
|
43
|
+
client.get_verification(verification.id)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Both delivery methods are reported the same way: `code:` carries what the user
|
|
47
|
+
entered, whether they read it off a message or heard it in a call.
|
|
48
|
+
|
|
49
|
+
### Per-method options
|
|
50
|
+
|
|
51
|
+
Options that apply to one delivery method only go in a keyword named after that
|
|
52
|
+
method and travel verbatim, so the call above goes on the wire as:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{ "data": { "destination": "+4915112345678", "delivery_method": "sms",
|
|
56
|
+
"sms": { "languages": ["en-US"] } } }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`sms:` and `callout:` are the keywords today, one per delivery method. The API
|
|
60
|
+
reads only the block matching `delivery_method` and ignores the others.
|
|
61
|
+
|
|
62
|
+
#### `sms:`
|
|
63
|
+
|
|
64
|
+
| Field | Description |
|
|
65
|
+
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
66
|
+
| `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
|
+
| `app_hash` | Android SMS Retriever hash: exactly 11 characters of `[A-Za-z0-9+/]`. The delivered message is then prefixed with `<#> ` and the hash appended as its last token, so the handset can auto-fill the code. Omit it on every other platform. |
|
|
68
|
+
|
|
69
|
+
`app_hash` is here because a Ruby backend often starts the verification on behalf
|
|
70
|
+
of an Android app — the hash identifies that app, so only the app can compute it.
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
client.start_verification(
|
|
74
|
+
destination: "+4915112345678", delivery_method: "sms",
|
|
75
|
+
sms: {languages: ["en-US"], app_hash: "A1b2C3d4E5f"}
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
#### `callout:`
|
|
80
|
+
|
|
81
|
+
| Field | Description |
|
|
82
|
+
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
83
|
+
| `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`. |
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
client.start_verification(
|
|
87
|
+
destination: "+4915112345678", delivery_method: "callout",
|
|
88
|
+
callout: {languages: ["de-DE", "en-US"]}
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The SDK names no field inside a block individually — the block travels verbatim,
|
|
93
|
+
so a field the API adds to either one needs no release here and works the day the
|
|
94
|
+
API ships it. The cost of that is no typo protection: an unrecognized option is
|
|
95
|
+
ignored rather than rejected, and a misspelled key returns `201` with the
|
|
96
|
+
defaults applied, not an error.
|
|
97
|
+
|
|
98
|
+
### Reading a method block back
|
|
99
|
+
|
|
100
|
+
The response carries the block for the method that was used, readable field by
|
|
101
|
+
field or as a whole:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
v.sms_template # => "Your code is {{CODE}}"
|
|
105
|
+
v.sms_language # => "en-US"
|
|
106
|
+
v.sms_interception_timeout # => 120
|
|
107
|
+
v.sms_app_hash # => "A1b2C3d4E5f", or nil if none was stored
|
|
108
|
+
v.sms # => the raw block, or nil on a callout verification
|
|
109
|
+
|
|
110
|
+
v.callout_language # => "de-DE"
|
|
111
|
+
v.callout # => the raw block, or nil on an sms verification
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`sms_language` and `callout_language` are the tag the **server** chose — the
|
|
115
|
+
first requested language it had a template (or a recording) for, otherwise the
|
|
116
|
+
`en-US` fallback — never an echo of what was asked for. Comparing one against the
|
|
117
|
+
list that was sent is how a fallback is detected rather than guessed at, and
|
|
118
|
+
because the two catalogues are separate, a tag honoured on `sms` can still fall
|
|
119
|
+
back on `callout`.
|
|
120
|
+
|
|
121
|
+
`sms_interception_timeout` is how many seconds a client should keep an on-device
|
|
122
|
+
SMS listener armed. It is a fixed budget, not a countdown, and **not** a deadline
|
|
123
|
+
for the verification — manual entry keeps working until `expires_at`.
|
|
124
|
+
`sms_app_hash` is echoed back only when one was stored, so it reflects what was
|
|
125
|
+
persisted rather than what was requested.
|
|
126
|
+
|
|
127
|
+
### Address by phone number
|
|
128
|
+
|
|
129
|
+
Every report/fetch call has a `_by_number` variant that targets the latest
|
|
130
|
+
verification for a phone number (E.164, leading `+` optional) instead of an id:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
client.get_verification_by_number("+4915112345678")
|
|
134
|
+
client.report_verification_by_number(
|
|
135
|
+
"+4915112345678", delivery_method: "sms", code: "1234"
|
|
136
|
+
)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Numbers are matched on their digits, so any formatting works. "Latest" is the
|
|
140
|
+
active verification when there is one, otherwise the most recent finished
|
|
141
|
+
attempt — so you can still read an outcome you missed. A `404` means the number
|
|
142
|
+
has no verification at all.
|
|
143
|
+
|
|
144
|
+
## Sandbox environment
|
|
145
|
+
|
|
146
|
+
The SDK targets production by default. To hit the sandbox, pass `env: :sandbox`:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
client = DIDWW::OTPVerification::Client.new(
|
|
150
|
+
key: "your-app-key",
|
|
151
|
+
secret: "your-app-secret",
|
|
152
|
+
env: :sandbox
|
|
153
|
+
)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Or set it as a global default:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
DIDWW::OTPVerification.configure do |c|
|
|
160
|
+
c.env = :sandbox
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Configuration
|
|
165
|
+
|
|
166
|
+
Set global defaults; every field is overridable per `Client`.
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
DIDWW::OTPVerification.configure do |c|
|
|
170
|
+
c.key = ENV["DIDWW_OTP_KEY"]
|
|
171
|
+
c.secret = ENV["DIDWW_OTP_SECRET"]
|
|
172
|
+
c.auth_mode = :basic # :basic (default) | :public | :application
|
|
173
|
+
|
|
174
|
+
# Register extra environments (a local dev server, a private mock, ...)
|
|
175
|
+
c.register_env(:local, "http://localhost:3000")
|
|
176
|
+
|
|
177
|
+
# Customize every Faraday connection (proxy, logging, timeouts, adapter)
|
|
178
|
+
c.faraday do |conn|
|
|
179
|
+
conn.options.timeout = 10
|
|
180
|
+
conn.response :logger
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
DIDWW::OTPVerification::Client.new # picks up the globals
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Per-request credentials / config
|
|
188
|
+
|
|
189
|
+
Nothing is global-only. Pass anything straight to `Client.new` — ideal for
|
|
190
|
+
multi-tenant apps:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
DIDWW::OTPVerification::Client.new(
|
|
194
|
+
key: tenant.key, secret: tenant.secret,
|
|
195
|
+
base_url: "https://custom.host", # wins over env
|
|
196
|
+
adapter: :typhoeus # any Faraday adapter
|
|
197
|
+
) { |conn| conn.proxy = "http://proxy:3128" }
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Auth modes
|
|
201
|
+
|
|
202
|
+
| Mode | Header | Secret | Notes |
|
|
203
|
+
| ------------------ | ----------------------------------------------- | -------- | ---------------------------------- |
|
|
204
|
+
| `:basic` (default) | `Basic base64(key:secret)` | required | Documented, simplest |
|
|
205
|
+
| `:public` | `Application <key>` | not used | Requires a callback URL on the app |
|
|
206
|
+
| `:application` | `Application <key>:<signature>` + `x-timestamp` | required | HMAC-SHA256 signed |
|
|
207
|
+
|
|
208
|
+
Set the mode per `Client` via `auth_mode:`, or globally via
|
|
209
|
+
`c.auth_mode` in `configure`.
|
|
210
|
+
|
|
211
|
+
### `:basic` (default)
|
|
212
|
+
|
|
213
|
+
Sends `Basic base64(key:secret)`. Requires both `key` and `secret`.
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
client = DIDWW::OTPVerification::Client.new(
|
|
217
|
+
key: "your-app-key",
|
|
218
|
+
secret: "your-app-secret",
|
|
219
|
+
auth_mode: :basic # optional — this is the default
|
|
220
|
+
)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### `:public`
|
|
224
|
+
|
|
225
|
+
Sends `Application <key>`. No secret is used; the app must have a callback URL
|
|
226
|
+
configured so DIDWW can deliver results.
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
client = DIDWW::OTPVerification::Client.new(
|
|
230
|
+
key: "your-app-key",
|
|
231
|
+
auth_mode: :public
|
|
232
|
+
)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### `:application`
|
|
236
|
+
|
|
237
|
+
Sends `Application <key>:<signature>` plus an `x-timestamp` header. The `secret`
|
|
238
|
+
is the URL-safe base64 signing key and is required. Signing is applied
|
|
239
|
+
automatically; the signature is computed over the exact request bytes.
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
client = DIDWW::OTPVerification::Client.new(
|
|
243
|
+
key: "your-app-key",
|
|
244
|
+
secret: "your-app-secret",
|
|
245
|
+
auth_mode: :application
|
|
246
|
+
)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Verifying inbound callbacks
|
|
250
|
+
|
|
251
|
+
DIDWW sends signed `verification_request` callbacks to your server using the
|
|
252
|
+
same HMAC scheme. The verifier has no HTTP-client dependency. The callback
|
|
253
|
+
request payload is documented at
|
|
254
|
+
[Request from DIDWW](https://doc.didww.com/otp-verification/callbacks.html#request-from-didww).
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
verifier = DIDWW::OTPVerification::CallbackVerifier.new(secret: app_secret)
|
|
258
|
+
|
|
259
|
+
key, signature = DIDWW::OTPVerification::CallbackVerifier
|
|
260
|
+
.parse_authorization(request.headers["Authorization"])
|
|
261
|
+
|
|
262
|
+
ok = verifier.valid?(
|
|
263
|
+
method: request.request_method, # "POST"
|
|
264
|
+
path: request.path, # your callback URL's path
|
|
265
|
+
content_type: request.content_type,
|
|
266
|
+
body: request.raw_post, # RAW body bytes — do not re-serialize
|
|
267
|
+
timestamp: request.headers["x-timestamp"],
|
|
268
|
+
signature: signature
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
render json: { action: ok ? "allow" : "deny" }
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
The verifier also enforces a 5-minute timestamp window (configurable via
|
|
275
|
+
`tolerance:`) to reject replays.
|
|
276
|
+
|
|
277
|
+
### Rails
|
|
278
|
+
|
|
279
|
+
`RailsCallbackVerifier` pulls every signed field off an `ActionDispatch`
|
|
280
|
+
request for you. It lives in a separate file that is **not** auto-required —
|
|
281
|
+
Rails is an optional runtime dependency — so require it explicitly:
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
require "didww/otp_verification/rails"
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Controller:
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
class DidwwCallbacksController < ActionController::API
|
|
291
|
+
before_action :verify_didww_signature
|
|
292
|
+
|
|
293
|
+
# Signature is already verified here — decide allow/deny from your own
|
|
294
|
+
# logic and the callback params.
|
|
295
|
+
def create
|
|
296
|
+
# some custom logic to decide whether to allow or deny the verification request
|
|
297
|
+
allow = expected_destination? params.dig(:data, :destination)
|
|
298
|
+
|
|
299
|
+
render json: { action: allow ? "allow" : "deny" }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
private
|
|
303
|
+
|
|
304
|
+
def verify_didww_signature
|
|
305
|
+
verifier = DIDWW::OTPVerification::RailsCallbackVerifier.new(
|
|
306
|
+
secret: ENV["DIDWW_OTP_SECRET"]
|
|
307
|
+
)
|
|
308
|
+
head(:unauthorized) unless verifier.valid?(request)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Routes (`config/routes.rb`) — the path must match the callback URL registered
|
|
314
|
+
on your app, since it is part of the signed payload:
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
post "/callbacks/didww", to: "didww_callbacks#create"
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
## Verification outcomes
|
|
321
|
+
|
|
322
|
+
Every 2xx response carries the verification's `status`. One that ended badly also
|
|
323
|
+
carries `error_code` (switch on it) and `error_detail` (display it). These are
|
|
324
|
+
**not** exceptions — a failed or denied verification is a successful HTTP call.
|
|
325
|
+
|
|
326
|
+
| `status` | Terminal | `error_code` |
|
|
327
|
+
| ---------- | -------- | -------------------------------------------------------------------------------------------- |
|
|
328
|
+
| `pending` | no | `nil` — on its way, or awaiting a report |
|
|
329
|
+
| `verified` | yes | `nil` |
|
|
330
|
+
| `failed` | yes | `too_many_attempts`, `dispatch_failed`, `stale_dispatch`, `superseded`, `application_deleted` |
|
|
331
|
+
| `expired` | yes | `expired` — past the 2-minute window |
|
|
332
|
+
| `denied` | yes | `denied_by_callback`, `denied_missing_callback_url`, `denied_invalid_callback_response` |
|
|
333
|
+
|
|
334
|
+
`superseded` means a newer `start_verification` for the same number retired this
|
|
335
|
+
one — only the newest verification per number stays active.
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
v = client.get_verification(id)
|
|
339
|
+
|
|
340
|
+
if v.finished? # verified / failed / expired / denied — stop polling
|
|
341
|
+
case v.error_code
|
|
342
|
+
when nil then grant_access
|
|
343
|
+
when "too_many_attempts" then lock_out
|
|
344
|
+
when "expired" then offer_resend
|
|
345
|
+
else logger.warn("verification #{v.id}: #{v.error_detail}")
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Statuses and codes are plain strings and an open set — new ones ship without an
|
|
351
|
+
SDK release, so always keep a default branch and treat the
|
|
352
|
+
[API documentation](https://doc.didww.com/otp-verification/index.html) as the
|
|
353
|
+
authoritative list.
|
|
354
|
+
|
|
355
|
+
## Errors
|
|
356
|
+
|
|
357
|
+
Non-2xx responses raise a typed error under `DIDWW::OTPVerification::Error`:
|
|
358
|
+
|
|
359
|
+
| Class | Status | Typical code |
|
|
360
|
+
| -------------------------- | --------- | ------------------------------------- |
|
|
361
|
+
| `UnauthorizedError` | 401 | `unauthorized` |
|
|
362
|
+
| `BalanceInsufficientError` | 402 | `balance_insufficient` |
|
|
363
|
+
| `NotFoundError` | 404 | `not_found` |
|
|
364
|
+
| `ValidationError` | 400 / 422 | `parameter_missing` / per-field codes |
|
|
365
|
+
| `ServerError` | 5xx | `internal_error` |
|
|
366
|
+
|
|
367
|
+
Every error carries the API's coded envelope: `#errors` is an array of
|
|
368
|
+
`ErrorItem`s (`#code`, `#detail`), and `#code`/`#codes` are shortcuts to the
|
|
369
|
+
stable, machine-readable slugs. Switch on `code`; the human `detail` is fixed per
|
|
370
|
+
code and only for display. One response can carry several errors — check `#codes`
|
|
371
|
+
rather than `#code`, which only returns the first.
|
|
372
|
+
|
|
373
|
+
Every status the API produces carries this envelope. `#errors` is empty only when
|
|
374
|
+
a response has no JSON body at all — e.g. a 502 from a proxy in front of it.
|
|
375
|
+
|
|
376
|
+
```ruby
|
|
377
|
+
begin
|
|
378
|
+
client.start_verification(destination: "", delivery_method: "sms")
|
|
379
|
+
rescue DIDWW::OTPVerification::ValidationError => e
|
|
380
|
+
e.status # => 422
|
|
381
|
+
e.code # => "destination_blank"
|
|
382
|
+
e.codes # => ["destination_blank"]
|
|
383
|
+
e.errors.first.code # => "destination_blank"
|
|
384
|
+
e.message # => every error's detail, joined by ", "
|
|
385
|
+
end
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
## Retries
|
|
389
|
+
|
|
390
|
+
The SDK does **not** auto-retry. If you add `faraday-retry`, exclude `POST`
|
|
391
|
+
(double-charges) and `PATCH` (each report counts against the 3-attempt limit).
|
|
392
|
+
|
|
393
|
+
## Development
|
|
394
|
+
|
|
395
|
+
Development is pinned to the Ruby in `.ruby-version`; the gem itself supports
|
|
396
|
+
3.1+.
|
|
397
|
+
|
|
398
|
+
```sh
|
|
399
|
+
bundle install
|
|
400
|
+
bundle exec rake spec # core gem specs (no Rails)
|
|
401
|
+
bundle exec rake spec:rails # Rails integration suite (spec-rails/)
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
The signing implementation is pinned to the server's own test vectors in
|
|
405
|
+
`spec/didww/otp_verification/signer_spec.rb`.
|
|
406
|
+
|
|
407
|
+
Rails is a **dev-only** dependency: it is exercised solely by the `spec-rails/`
|
|
408
|
+
suite and is never a runtime dependency of the gem. The core specs under
|
|
409
|
+
`spec/` never load Rails.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
require "openssl"
|
|
3
|
+
|
|
4
|
+
# Required directly by callback-only services; cannot assume the entry point.
|
|
5
|
+
require_relative "util"
|
|
6
|
+
require_relative "signer"
|
|
7
|
+
|
|
8
|
+
module DIDWW
|
|
9
|
+
module OTPVerification
|
|
10
|
+
# Verifies the signature of an inbound verification-request callback that
|
|
11
|
+
# DIDWW sends to your server. Uses the same HMAC scheme as outbound signing,
|
|
12
|
+
# where PATH is the path of *your* callback URL.
|
|
13
|
+
#
|
|
14
|
+
# Has no Faraday dependency on purpose: a service that only receives
|
|
15
|
+
# callbacks should not need the HTTP client.
|
|
16
|
+
#
|
|
17
|
+
# verifier = DIDWW::OTPVerification::CallbackVerifier.new(secret: app_secret)
|
|
18
|
+
# key, signature = DIDWW::OTPVerification::CallbackVerifier.parse_authorization(auth_header)
|
|
19
|
+
# verifier.valid?(
|
|
20
|
+
# method: "POST", path: "/callbacks/didww",
|
|
21
|
+
# content_type: "application/json", body: raw_body,
|
|
22
|
+
# timestamp: request.headers["x-timestamp"], signature: signature
|
|
23
|
+
# )
|
|
24
|
+
class CallbackVerifier
|
|
25
|
+
# @param tolerance [Integer] max allowed clock skew, in seconds (server uses 5 minutes).
|
|
26
|
+
def initialize(secret:, tolerance: 300, clock: -> { Time.now.to_i })
|
|
27
|
+
@signer = Signer.new(secret)
|
|
28
|
+
@tolerance = tolerance
|
|
29
|
+
@clock = clock
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Splits an "Application <key>:<signature>" header into [key, signature].
|
|
33
|
+
# Returns [nil, nil] if the header is missing or not in that form.
|
|
34
|
+
def self.parse_authorization(header)
|
|
35
|
+
return [nil, nil] if header.nil?
|
|
36
|
+
|
|
37
|
+
scheme, credentials = header.split(" ", 2)
|
|
38
|
+
return [nil, nil] unless scheme == "Application" && credentials
|
|
39
|
+
|
|
40
|
+
key, signature = credentials.split(":", 2)
|
|
41
|
+
[key, signature]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Boolean] true when both the timestamp is fresh and the
|
|
45
|
+
# signature matches (constant-time comparison).
|
|
46
|
+
def valid?(method:, path:, content_type:, body:, timestamp:, signature:)
|
|
47
|
+
return false if Util.blank?(signature)
|
|
48
|
+
return false unless fresh?(timestamp)
|
|
49
|
+
|
|
50
|
+
expected = @signer.sign(
|
|
51
|
+
method:, path:, content_type:, body:, timestamp: timestamp
|
|
52
|
+
)
|
|
53
|
+
secure_compare(expected, signature)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def fresh?(timestamp)
|
|
59
|
+
return false if Util.blank?(timestamp)
|
|
60
|
+
|
|
61
|
+
(@clock.call - timestamp.to_i).abs <= @tolerance
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Constant-time string comparison via the audited stdlib implementation.
|
|
65
|
+
def secure_compare(a, b)
|
|
66
|
+
return false unless a.bytesize == b.bytesize
|
|
67
|
+
|
|
68
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
require "base64"
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module DIDWW
|
|
6
|
+
module OTPVerification
|
|
7
|
+
# The API client. All three verification endpoints hang off an instance.
|
|
8
|
+
#
|
|
9
|
+
# client = DIDWW::OTPVerification::Client.new(
|
|
10
|
+
# key: "app-uuid", secret: "app-secret", env: :sandbox
|
|
11
|
+
# )
|
|
12
|
+
# verification = client.start_verification(
|
|
13
|
+
# destination: "+4915112345678", delivery_method: "sms"
|
|
14
|
+
# )
|
|
15
|
+
# client.report_verification(verification.id, delivery_method: "sms", code: "1234")
|
|
16
|
+
# client.get_verification(verification.id)
|
|
17
|
+
#
|
|
18
|
+
# The +*_by_number+ variants address the latest verification for a phone
|
|
19
|
+
# number instead of an id.
|
|
20
|
+
#
|
|
21
|
+
# Unspecified arguments fall back to DIDWW::OTPVerification.configuration.
|
|
22
|
+
class Client
|
|
23
|
+
API_PREFIX = "/api/v1".freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :key, :auth_mode, :base_url
|
|
26
|
+
|
|
27
|
+
# @param base_url [String, nil] explicit base URL; wins over +env+.
|
|
28
|
+
# @param env [Symbol, nil] a registered environment name.
|
|
29
|
+
# @param auth_mode [Symbol] :basic (default), :public, or :application.
|
|
30
|
+
# @param adapter [Symbol, Array, nil] a Faraday adapter override.
|
|
31
|
+
# @yield [conn] optional per-client Faraday customization.
|
|
32
|
+
def initialize(key: nil, secret: nil, env: nil, base_url: nil,
|
|
33
|
+
auth_mode: nil, adapter: nil, &faraday_block)
|
|
34
|
+
config = OTPVerification.configuration
|
|
35
|
+
@key = key || config.key
|
|
36
|
+
@secret = secret || config.secret
|
|
37
|
+
@auth_mode = (auth_mode || config.auth_mode).to_sym
|
|
38
|
+
@base_url = base_url || config.base_url_for(env || config.env)
|
|
39
|
+
@adapter = adapter || config.adapter
|
|
40
|
+
@faraday_block = faraday_block || config.faraday
|
|
41
|
+
validate!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# POST /api/v1/verifications
|
|
45
|
+
#
|
|
46
|
+
# Options specific to one delivery method go in a keyword named after it,
|
|
47
|
+
# e.g. <tt>sms: { languages: ["en-US"] }</tt> or
|
|
48
|
+
# <tt>callout: { languages: ["de-DE"] }</tt>, and travel verbatim. The API
|
|
49
|
+
# reads only the block matching +delivery_method+ and ignores the others.
|
|
50
|
+
# An unrecognized option is ignored rather than rejected, so a typo there
|
|
51
|
+
# fails silently. Methods with no options of their own take no keyword.
|
|
52
|
+
def start_verification(destination:, delivery_method:, sms: nil, callout: nil)
|
|
53
|
+
data = {destination:, delivery_method:}
|
|
54
|
+
data[:sms] = sms unless sms.nil?
|
|
55
|
+
data[:callout] = callout unless callout.nil?
|
|
56
|
+
request(:post, "#{API_PREFIX}/verifications", data)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# PATCH /api/v1/verifications/:id. Reports the +code+ the user entered;
|
|
60
|
+
# every delivery method is reported the same way. NB: reporting counts as
|
|
61
|
+
# an attempt (max 3), so never auto-retry this call.
|
|
62
|
+
def report_verification(id, delivery_method:, code:)
|
|
63
|
+
request(:patch, "#{API_PREFIX}/verifications/#{id}", {delivery_method:, code:})
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# GET /api/v1/verifications/:id
|
|
67
|
+
def get_verification(id)
|
|
68
|
+
handle(connection.get("#{API_PREFIX}/verifications/#{id}"))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# PATCH /api/v1/verifications/by_number/:number. Reports against the latest
|
|
72
|
+
# verification for +number+ (E.164, leading + optional). Same attempt
|
|
73
|
+
# caveat as #report_verification: never auto-retry.
|
|
74
|
+
def report_verification_by_number(number, delivery_method:, code:)
|
|
75
|
+
request(:patch, "#{API_PREFIX}/verifications/by_number/#{encode(number)}",
|
|
76
|
+
{delivery_method:, code:})
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# GET /api/v1/verifications/by_number/:number. +number+ is E.164 with an
|
|
80
|
+
# optional leading +.
|
|
81
|
+
def get_verification_by_number(number)
|
|
82
|
+
handle(connection.get("#{API_PREFIX}/verifications/by_number/#{encode(number)}"))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Percent-encode a value for use as a single URL path segment. Notably
|
|
88
|
+
# turns a leading "+" into "%2B" so it survives proxies that would
|
|
89
|
+
# otherwise decode "+" to a space in the path.
|
|
90
|
+
def encode(segment)
|
|
91
|
+
ERB::Util.url_encode(segment.to_s)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def request(method, path, data)
|
|
95
|
+
handle(connection.public_send(method, path, {data: data}))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def handle(response)
|
|
99
|
+
body = response.body
|
|
100
|
+
|
|
101
|
+
if response.success?
|
|
102
|
+
unless body.is_a?(Hash)
|
|
103
|
+
raise APIError.new(
|
|
104
|
+
"unexpected response body",
|
|
105
|
+
status: response.status,
|
|
106
|
+
response: response
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
return Verification.new(body["data"])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
errors = parse_errors(body)
|
|
113
|
+
raise error_class(response.status).new(
|
|
114
|
+
status: response.status,
|
|
115
|
+
errors: errors,
|
|
116
|
+
response: response
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Map the {"errors": [{"code", "detail"}]} envelope to ErrorItem objects.
|
|
121
|
+
# Tolerates a plain-string element by exposing it as the +detail+.
|
|
122
|
+
def parse_errors(body)
|
|
123
|
+
return [] unless body.is_a?(Hash)
|
|
124
|
+
|
|
125
|
+
Array(body["errors"]).map do |item|
|
|
126
|
+
if item.is_a?(Hash)
|
|
127
|
+
ErrorItem.new(item["code"], item["detail"])
|
|
128
|
+
else
|
|
129
|
+
ErrorItem.new(nil, item.to_s)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def error_class(status)
|
|
135
|
+
case status
|
|
136
|
+
when 401 then UnauthorizedError
|
|
137
|
+
when 402 then BalanceInsufficientError
|
|
138
|
+
when 404 then NotFoundError
|
|
139
|
+
when 400, 422 then ValidationError
|
|
140
|
+
when 500..599 then ServerError
|
|
141
|
+
else APIError
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def connection
|
|
146
|
+
@connection ||= Faraday.new(url: @base_url) do |conn|
|
|
147
|
+
conn.request :json
|
|
148
|
+
conn.response :json, content_type: /\bjson$/
|
|
149
|
+
@faraday_block&.call(conn)
|
|
150
|
+
# Auth (signing) must be installed last so it sees the final request
|
|
151
|
+
# bytes/headers/path, after any user middleware from @faraday_block.
|
|
152
|
+
apply_auth(conn)
|
|
153
|
+
conn.adapter(*Array(@adapter || Faraday.default_adapter))
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def apply_auth(conn)
|
|
158
|
+
case @auth_mode
|
|
159
|
+
when :basic
|
|
160
|
+
conn.request :authorization, :basic, @key, @secret
|
|
161
|
+
when :public
|
|
162
|
+
conn.headers["Authorization"] = "Application #{@key}"
|
|
163
|
+
when :application
|
|
164
|
+
conn.use Middleware::Signature, key: @key, secret: @secret
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def validate!
|
|
169
|
+
unless AUTH_MODES.include?(@auth_mode)
|
|
170
|
+
raise ConfigurationError,
|
|
171
|
+
"unknown auth_mode #{@auth_mode.inspect}; expected one of #{AUTH_MODES.inspect}"
|
|
172
|
+
end
|
|
173
|
+
raise ConfigurationError, "key is required" if Util.blank?(@key)
|
|
174
|
+
if secret_required? && Util.blank?(@secret)
|
|
175
|
+
raise ConfigurationError, "secret is required for #{@auth_mode} auth mode"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
validate_signing_secret! if @auth_mode == :application
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def secret_required?
|
|
182
|
+
@auth_mode == :basic || @auth_mode == :application
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The application secret is the URL-safe base64 signing key; decode it now
|
|
186
|
+
# so a malformed secret fails fast here instead of raising an untyped
|
|
187
|
+
# ArgumentError from Signer on the first request.
|
|
188
|
+
def validate_signing_secret!
|
|
189
|
+
Base64.urlsafe_decode64(@secret)
|
|
190
|
+
rescue ArgumentError
|
|
191
|
+
raise ConfigurationError,
|
|
192
|
+
"secret is not valid URL-safe base64 for #{@auth_mode} auth mode"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require_relative "errors"
|
|
2
|
+
|
|
3
|
+
module DIDWW
|
|
4
|
+
module OTPVerification
|
|
5
|
+
# Global defaults for Client. Every field can be overridden per-Client at
|
|
6
|
+
# construction time.
|
|
7
|
+
class Configuration
|
|
8
|
+
# The environments the API publishes. Add more with #register_env.
|
|
9
|
+
DEFAULT_ENVS = {
|
|
10
|
+
production: "https://verification.didww.com",
|
|
11
|
+
sandbox: "https://verification-sandbox.didww.com"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
attr_accessor :key, :secret, :env, :auth_mode, :adapter
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@envs = DEFAULT_ENVS.dup
|
|
18
|
+
@env = :production
|
|
19
|
+
@auth_mode = :basic
|
|
20
|
+
@key = nil
|
|
21
|
+
@secret = nil
|
|
22
|
+
@adapter = nil
|
|
23
|
+
@faraday_block = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Register a named environment, e.g. a local dev server:
|
|
27
|
+
# config.register_env(:local, "http://localhost:3000")
|
|
28
|
+
def register_env(name, base_url)
|
|
29
|
+
@envs[name.to_sym] = base_url
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [String] base URL for a registered environment.
|
|
33
|
+
# @raise [ConfigurationError] if the environment is unknown.
|
|
34
|
+
def base_url_for(env)
|
|
35
|
+
@envs.fetch(env.to_sym) do
|
|
36
|
+
raise ConfigurationError,
|
|
37
|
+
"unknown env #{env.inspect}; registered: #{@envs.keys.inspect}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Register a block to customize every Faraday connection (proxy, logging,
|
|
42
|
+
# timeouts, custom adapter, etc). Runs after the built-in request/response
|
|
43
|
+
# middleware but before auth signing, so application-mode signatures are
|
|
44
|
+
# always computed over the final request bytes.
|
|
45
|
+
# config.faraday { |conn| conn.response :logger }
|
|
46
|
+
def faraday(&block)
|
|
47
|
+
@faraday_block = block if block
|
|
48
|
+
@faraday_block
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module DIDWW
|
|
2
|
+
module OTPVerification
|
|
3
|
+
# Base class for every error raised by this SDK.
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised when the client is misconfigured (missing key/secret, unknown env, etc).
|
|
7
|
+
class ConfigurationError < Error; end
|
|
8
|
+
|
|
9
|
+
# A single coded error from the API's error envelope
|
|
10
|
+
# +{ "errors": [ { "code", "detail" } ] }+. +code+ is a stable,
|
|
11
|
+
# machine-readable slug (switch on it); +detail+ is its fixed human text.
|
|
12
|
+
ErrorItem = Struct.new(:code, :detail) do
|
|
13
|
+
def to_s = detail || code || ""
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Raised when the API responds with a non-2xx status. Subclasses map to
|
|
17
|
+
# specific HTTP statuses. Every documented status carries the coded error
|
|
18
|
+
# envelope; +errors+ is empty only when a response has no (or a non-JSON)
|
|
19
|
+
# body, e.g. an error produced by a proxy in front of the API.
|
|
20
|
+
class APIError < Error
|
|
21
|
+
# @return [Array<ErrorItem>] coded errors from the response body.
|
|
22
|
+
attr_reader :status, :errors, :response
|
|
23
|
+
|
|
24
|
+
def initialize(message = nil, status:, errors: [], response: nil)
|
|
25
|
+
@status = status
|
|
26
|
+
@errors = errors
|
|
27
|
+
@response = response
|
|
28
|
+
details = errors.map(&:detail).compact
|
|
29
|
+
super(message || (details.empty? ? "HTTP #{status}" : details.join(", ")))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [String, nil] machine-readable code of the first error.
|
|
33
|
+
def code = errors.first&.code
|
|
34
|
+
|
|
35
|
+
# @return [Array<String>] machine-readable codes of every error.
|
|
36
|
+
def codes = errors.map(&:code)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# 401 Unauthorized (+unauthorized+ code).
|
|
40
|
+
class UnauthorizedError < APIError; end
|
|
41
|
+
|
|
42
|
+
# 402 Payment Required (+balance_insufficient+ code).
|
|
43
|
+
class BalanceInsufficientError < APIError; end
|
|
44
|
+
|
|
45
|
+
# 404 Not Found.
|
|
46
|
+
class NotFoundError < APIError; end
|
|
47
|
+
|
|
48
|
+
# 400 Bad Request / 422 Unprocessable Content (validation errors in +errors+).
|
|
49
|
+
class ValidationError < APIError; end
|
|
50
|
+
|
|
51
|
+
# 5xx Server Error (+internal_error+ code).
|
|
52
|
+
class ServerError < APIError; end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
|
|
3
|
+
module DIDWW
|
|
4
|
+
module OTPVerification
|
|
5
|
+
module Middleware
|
|
6
|
+
# Faraday request middleware that signs the outgoing request using the
|
|
7
|
+
# +application+ auth scheme. It MUST run after the request body has been
|
|
8
|
+
# serialized (i.e. after +conn.request :json+) so that CONTENT-MD5 is
|
|
9
|
+
# computed over the exact bytes that go on the wire.
|
|
10
|
+
#
|
|
11
|
+
# Injects:
|
|
12
|
+
# Authorization: Application <key>:<signature>
|
|
13
|
+
# x-timestamp: <unix-seconds>
|
|
14
|
+
class Signature < Faraday::Middleware
|
|
15
|
+
def initialize(app, key:, secret:, clock: -> { Time.now.to_i })
|
|
16
|
+
super(app)
|
|
17
|
+
@key = key
|
|
18
|
+
@signer = Signer.new(secret)
|
|
19
|
+
@clock = clock
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def on_request(env)
|
|
23
|
+
timestamp = @clock.call.to_s
|
|
24
|
+
signature = @signer.sign(
|
|
25
|
+
method: env.method,
|
|
26
|
+
path: env.url.path,
|
|
27
|
+
content_type: env.request_headers["Content-Type"].to_s,
|
|
28
|
+
body: env.body.to_s,
|
|
29
|
+
timestamp: timestamp
|
|
30
|
+
)
|
|
31
|
+
env.request_headers["Authorization"] = "Application #{@key}:#{signature}"
|
|
32
|
+
env.request_headers["x-timestamp"] = timestamp
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require_relative "callback_verifier"
|
|
2
|
+
|
|
3
|
+
module DIDWW
|
|
4
|
+
module OTPVerification
|
|
5
|
+
# Rails glue for verifying inbound callback signatures straight from an
|
|
6
|
+
# ActionDispatch request.
|
|
7
|
+
#
|
|
8
|
+
# NOT auto-required by "didww/otp_verification" on purpose: Rails is an
|
|
9
|
+
# optional runtime dependency, so services that don't run Rails pay nothing.
|
|
10
|
+
# Require it explicitly where you need it:
|
|
11
|
+
#
|
|
12
|
+
# require "didww/otp_verification/rails"
|
|
13
|
+
#
|
|
14
|
+
# class DidwwCallbacksController < ActionController::API
|
|
15
|
+
# before_action :verify_didww_signature
|
|
16
|
+
#
|
|
17
|
+
# def create
|
|
18
|
+
# # signature already verified; decide allow/deny from params
|
|
19
|
+
# render json: { action: allowed?(params) ? "allow" : "deny" }
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# private
|
|
23
|
+
#
|
|
24
|
+
# def verify_didww_signature
|
|
25
|
+
# verifier = DIDWW::OTPVerification::RailsCallbackVerifier.new(secret: app_secret)
|
|
26
|
+
# head(:unauthorized) unless verifier.valid?(request)
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
class RailsCallbackVerifier
|
|
30
|
+
# Same options as CallbackVerifier.
|
|
31
|
+
def initialize(secret:, tolerance: 300, clock: -> { Time.now.to_i })
|
|
32
|
+
@verifier = CallbackVerifier.new(secret: secret, tolerance: tolerance, clock: clock)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Pulls every signed field off the ActionDispatch::Request and verifies it.
|
|
36
|
+
# Uses request.raw_post so the exact received bytes are signed — never
|
|
37
|
+
# re-serialize the parsed params.
|
|
38
|
+
#
|
|
39
|
+
# @param request [ActionDispatch::Request]
|
|
40
|
+
# @return [Boolean]
|
|
41
|
+
def valid?(request)
|
|
42
|
+
_key, signature = CallbackVerifier.parse_authorization(request.headers["Authorization"])
|
|
43
|
+
|
|
44
|
+
@verifier.valid?(
|
|
45
|
+
method: request.request_method,
|
|
46
|
+
path: request.path,
|
|
47
|
+
content_type: request.content_type,
|
|
48
|
+
body: request.raw_post,
|
|
49
|
+
timestamp: request.headers["x-timestamp"],
|
|
50
|
+
signature: signature
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "util"
|
|
7
|
+
|
|
8
|
+
module DIDWW
|
|
9
|
+
module OTPVerification
|
|
10
|
+
# HMAC-SHA256 request signer for the +application+ auth mode.
|
|
11
|
+
#
|
|
12
|
+
# The signing key is the application +secret+ decoded from URL-safe base64.
|
|
13
|
+
# The string to sign is 5 lines joined by "\n":
|
|
14
|
+
#
|
|
15
|
+
# <HTTP-METHOD>
|
|
16
|
+
# <CONTENT-MD5> # Base64(MD5(body)), empty string when no body
|
|
17
|
+
# <CONTENT-TYPE>
|
|
18
|
+
# x-timestamp:<TIMESTAMP>
|
|
19
|
+
# <PATH>
|
|
20
|
+
#
|
|
21
|
+
# The signature is Base64(HMAC-SHA256(key, string_to_sign)).
|
|
22
|
+
class Signer
|
|
23
|
+
def initialize(secret)
|
|
24
|
+
raise ConfigurationError, "secret is required for signing" if Util.blank?(secret)
|
|
25
|
+
|
|
26
|
+
@key = Base64.urlsafe_decode64(secret)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [String] the base64-encoded signature.
|
|
30
|
+
def sign(method:, path:, content_type:, body:, timestamp:)
|
|
31
|
+
digest = OpenSSL::HMAC.digest(
|
|
32
|
+
"SHA256",
|
|
33
|
+
@key,
|
|
34
|
+
string_to_sign(method:, path:, content_type:, body:, timestamp:)
|
|
35
|
+
)
|
|
36
|
+
Base64.strict_encode64(digest)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [String] the canonical string that gets signed. Exposed for
|
|
40
|
+
# debugging / cross-checking against the server implementation.
|
|
41
|
+
def string_to_sign(method:, path:, content_type:, body:, timestamp:)
|
|
42
|
+
content_md5 =
|
|
43
|
+
if body.nil? || body.empty?
|
|
44
|
+
""
|
|
45
|
+
else
|
|
46
|
+
Base64.strict_encode64(Digest::MD5.digest(body))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
[
|
|
50
|
+
method.to_s.upcase,
|
|
51
|
+
content_md5,
|
|
52
|
+
content_type.to_s,
|
|
53
|
+
"x-timestamp:#{timestamp}",
|
|
54
|
+
path
|
|
55
|
+
].join("\n")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module DIDWW
|
|
2
|
+
module OTPVerification
|
|
3
|
+
# Small internal helpers shared across the SDK.
|
|
4
|
+
module Util
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# @return [Boolean] true when +value+ is nil or an empty string.
|
|
8
|
+
def blank?(value)
|
|
9
|
+
value.nil? || value.to_s.empty?
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
require "bigdecimal"
|
|
3
|
+
|
|
4
|
+
module DIDWW
|
|
5
|
+
module OTPVerification
|
|
6
|
+
# Thin value object wrapping the +data+ envelope returned by every
|
|
7
|
+
# verification endpoint. Statuses and error codes are an open set, so the
|
|
8
|
+
# predicates below are conveniences, not an exhaustive enum.
|
|
9
|
+
#
|
|
10
|
+
# +error_code+/+error_detail+ say why a verification ended badly; both are
|
|
11
|
+
# nil while pending and when verified. They are unrelated to the errors on
|
|
12
|
+
# an APIError, which is raised instead of returning a Verification.
|
|
13
|
+
class Verification
|
|
14
|
+
attr_reader :id, :destination, :delivery_method, :fee, :status,
|
|
15
|
+
:error_code, :error_detail, :expires_at, :sms, :callout, :raw
|
|
16
|
+
|
|
17
|
+
def initialize(data)
|
|
18
|
+
data ||= {}
|
|
19
|
+
@raw = data
|
|
20
|
+
@id = data["id"]
|
|
21
|
+
@destination = data["destination"]
|
|
22
|
+
@delivery_method = data["delivery_method"]
|
|
23
|
+
@fee = data["fee"] ? BigDecimal(data["fee"]) : nil
|
|
24
|
+
@status = data["status"]
|
|
25
|
+
@error_code = data["error_code"]
|
|
26
|
+
@error_detail = data["error_detail"]
|
|
27
|
+
@expires_at = data["expires_at"] ? Time.parse(data["expires_at"]) : nil
|
|
28
|
+
@sms = data["sms"]
|
|
29
|
+
@callout = data["callout"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Hash] the raw +data+ envelope this object was built from.
|
|
33
|
+
def to_h
|
|
34
|
+
@raw
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [String, nil] SMS template, only present for the +sms+ method.
|
|
38
|
+
def sms_template
|
|
39
|
+
@sms && @sms["template"]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The tag the server chose for the message: the first requested language
|
|
43
|
+
# it had a template for, or the +en-US+ fallback. It is a choice, never an
|
|
44
|
+
# echo, so comparing it against what was asked for is how a fallback is
|
|
45
|
+
# detected rather than guessed at.
|
|
46
|
+
#
|
|
47
|
+
# @return [String, nil] BCP 47 tag, only present for the +sms+ method.
|
|
48
|
+
def sms_language
|
|
49
|
+
@sms && @sms["language"]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Integer, nil] seconds to keep an SMS listener armed. Not a
|
|
53
|
+
# deadline: manual entry stays live until +expires_at+.
|
|
54
|
+
def sms_interception_timeout
|
|
55
|
+
@sms && @sms["interception_timeout"]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [String, nil] SMS Retriever hash, echoed back only when one was
|
|
59
|
+
# stored on this verification.
|
|
60
|
+
def sms_app_hash
|
|
61
|
+
@sms && @sms["app_hash"]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The tag the announcement is played in: the first requested language the
|
|
65
|
+
# server had a recording for, or the +en-US+ fallback. The recordings are
|
|
66
|
+
# a different catalogue from the SMS templates, so a tag honoured on +sms+
|
|
67
|
+
# can still fall back here.
|
|
68
|
+
#
|
|
69
|
+
# @return [String, nil] BCP 47 tag, only present for the +callout+ method.
|
|
70
|
+
def callout_language
|
|
71
|
+
@callout && @callout["language"]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def pending? = status == "pending"
|
|
75
|
+
def verified? = status == "verified"
|
|
76
|
+
def failed? = status == "failed"
|
|
77
|
+
def expired? = status == "expired"
|
|
78
|
+
def denied? = status == "denied"
|
|
79
|
+
|
|
80
|
+
# @return [Boolean] true once the verification reached a terminal status
|
|
81
|
+
# (successfully or not) — stop polling.
|
|
82
|
+
def finished? = verified? || failed? || expired? || denied?
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
|
|
3
|
+
require_relative "otp_verification/version"
|
|
4
|
+
require_relative "otp_verification/errors"
|
|
5
|
+
require_relative "otp_verification/util"
|
|
6
|
+
require_relative "otp_verification/configuration"
|
|
7
|
+
require_relative "otp_verification/signer"
|
|
8
|
+
require_relative "otp_verification/middleware/signature"
|
|
9
|
+
require_relative "otp_verification/verification"
|
|
10
|
+
require_relative "otp_verification/callback_verifier"
|
|
11
|
+
require_relative "otp_verification/client"
|
|
12
|
+
|
|
13
|
+
module DIDWW
|
|
14
|
+
module OTPVerification
|
|
15
|
+
AUTH_MODES = %i[basic public application].freeze
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Global configuration singleton. Used as defaults by Client.
|
|
19
|
+
def configuration
|
|
20
|
+
@configuration ||= Configuration.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# DIDWW::OTPVerification.configure do |c|
|
|
24
|
+
# c.env = :sandbox
|
|
25
|
+
# c.key = ENV["DIDWW_OTP_KEY"]
|
|
26
|
+
# c.secret = ENV["DIDWW_OTP_SECRET"]
|
|
27
|
+
# end
|
|
28
|
+
def configure
|
|
29
|
+
yield configuration
|
|
30
|
+
configuration
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Mainly for tests.
|
|
34
|
+
def reset_configuration!
|
|
35
|
+
@configuration = Configuration.new
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: didww-otp_verification
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- DIDWW
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: base64
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.2'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: bigdecimal
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: faraday
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '2.0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '2.0'
|
|
54
|
+
description: Client for the DIDWW OTP verification API with basic, public and HMAC-signed
|
|
55
|
+
(application) auth, plus inbound callback signature verification.
|
|
56
|
+
executables: []
|
|
57
|
+
extensions: []
|
|
58
|
+
extra_rdoc_files: []
|
|
59
|
+
files:
|
|
60
|
+
- CHANGELOG.md
|
|
61
|
+
- LICENSE
|
|
62
|
+
- README.md
|
|
63
|
+
- lib/didww/otp_verification.rb
|
|
64
|
+
- lib/didww/otp_verification/callback_verifier.rb
|
|
65
|
+
- lib/didww/otp_verification/client.rb
|
|
66
|
+
- lib/didww/otp_verification/configuration.rb
|
|
67
|
+
- lib/didww/otp_verification/errors.rb
|
|
68
|
+
- lib/didww/otp_verification/middleware/signature.rb
|
|
69
|
+
- lib/didww/otp_verification/rails.rb
|
|
70
|
+
- lib/didww/otp_verification/signer.rb
|
|
71
|
+
- lib/didww/otp_verification/util.rb
|
|
72
|
+
- lib/didww/otp_verification/verification.rb
|
|
73
|
+
- lib/didww/otp_verification/version.rb
|
|
74
|
+
homepage: https://github.com/didww/didww-verification-ruby-sdk
|
|
75
|
+
licenses:
|
|
76
|
+
- MIT
|
|
77
|
+
metadata:
|
|
78
|
+
source_code_uri: https://github.com/didww/didww-verification-ruby-sdk
|
|
79
|
+
changelog_uri: https://github.com/didww/didww-verification-ruby-sdk/blob/main/CHANGELOG.md
|
|
80
|
+
rubygems_mfa_required: 'true'
|
|
81
|
+
rdoc_options: []
|
|
82
|
+
require_paths:
|
|
83
|
+
- lib
|
|
84
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: 3.1.0
|
|
89
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - ">="
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: '0'
|
|
94
|
+
requirements: []
|
|
95
|
+
rubygems_version: 3.6.9
|
|
96
|
+
specification_version: 4
|
|
97
|
+
summary: Ruby SDK for the DIDWW OTP Verification API
|
|
98
|
+
test_files: []
|