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 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,5 @@
1
+ module DIDWW
2
+ module OTPVerification
3
+ VERSION = "1.0.0"
4
+ end
5
+ 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: []