verifiabl-issuer 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/README.md +85 -446
- data/lib/verifiabl/issuer/client.rb +135 -15
- data/lib/verifiabl/issuer/configuration.rb +2 -1
- data/lib/verifiabl/issuer/instrumentation.rb +6 -0
- data/lib/verifiabl/issuer/non_pii_v2.rb +26 -21
- data/lib/verifiabl/issuer/serialization.rb +4 -1
- data/lib/verifiabl/issuer/shared_token_store.rb +158 -0
- data/lib/verifiabl/issuer/validation.rb +5 -6
- data/lib/verifiabl/issuer/version.rb +1 -1
- data/lib/verifiabl/issuer.rb +1 -0
- data/sig/verifiabl-issuer.rbs +7 -1
- metadata +31 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 369e3ae173f2115422efb4a81d7cd32b018e8cd15e291899e699d51048eeca4e
|
|
4
|
+
data.tar.gz: b058e79cd9791e2f17966455129d8df40e9869b3f550ff05906ad5bae98aea5f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c2996561b125072f3d8a4fd9461d9164bf5689fc3773d3242e946d844351cb14fde3c0a39893ec50b74696b59cd6c880ce1917fedbe4a1da49d0f9642e11b22b
|
|
7
|
+
data.tar.gz: 719979e79cbc3853cc52d002d45757197d41dfc33fcd398dd1b125c25153b2c07a7e56a9558149c51c5e8c6a45c14cb9722c8a11e301695ba9fc2c7026830594
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,41 @@ All notable changes to the Verifiabl Ruby SDK will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## [1.1.0] - 2026-10-23
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `Configuration` accepts `token_store:`, a cache that worker processes
|
|
12
|
+
share, such as `Rails.cache`. The processes then use one OAuth token
|
|
13
|
+
instead of one each. The client encrypts each token with a key derived
|
|
14
|
+
from the client secret before it stores the token. When the stored token
|
|
15
|
+
is missing or due, one process fetches a new token. The other processes
|
|
16
|
+
use their current token or wait for the new token. The client does not
|
|
17
|
+
replace a token that the API rejects less than one minute after its
|
|
18
|
+
fetch. A store failure counts as a miss. `on_error` gets it as an event
|
|
19
|
+
with only `token_store:` (`:read`, `:write` or `:claim`) and `error:`.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- The OAuth token request has its own 10-second limit within the request's
|
|
24
|
+
`timeout:` budget.
|
|
25
|
+
- The README now links to the documentation at docs.verifiabl.io instead of
|
|
26
|
+
repeating it.
|
|
27
|
+
|
|
28
|
+
## [1.0.1] - 2026-10-20
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- AU2 and NZ2 codes may be Symbols, such as `currency: :AUD` or
|
|
33
|
+
`type: :lump_sum`. The gem converts them to Strings before its checks, so
|
|
34
|
+
every check treats both forms alike. Amounts must still be Strings.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- An AU2 earnings line with `type: :lump_sum` (a Symbol) and no
|
|
39
|
+
`lump_sum_type` now raises `ArgumentError`, as the String form already did.
|
|
40
|
+
1.0.0 sent it without the code.
|
|
41
|
+
|
|
7
42
|
## [1.0.0] - 2026-10-19
|
|
8
43
|
|
|
9
44
|
First stable release. The public API now follows Semantic Versioning.
|
data/README.md
CHANGED
|
@@ -1,85 +1,108 @@
|
|
|
1
|
-
#
|
|
1
|
+
# verifiabl-issuer
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
cross-SDK-compatible QR generation, deterministic SVG/PNG rendering, a hardened OAuth issuer
|
|
5
|
-
client, RBS signatures, and packed-gem consumer qualification.
|
|
3
|
+
The Verifiabl issuer SDK for Ruby. It registers payslip records with Verifiabl and makes their QR codes.
|
|
6
4
|
|
|
7
|
-
##
|
|
5
|
+
## Documentation
|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
- [Issuer SDK guide](https://docs.verifiabl.io/issuer-sdk?lang=ruby)
|
|
8
|
+
- [API reference](https://docs.verifiabl.io/api-reference/sdks/ruby)
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
gem "verifiabl-issuer", "1.0.0"
|
|
13
|
-
```
|
|
10
|
+
## Requirements
|
|
14
11
|
|
|
15
|
-
|
|
12
|
+
Ruby 3.3 or newer.
|
|
16
13
|
|
|
17
|
-
|
|
14
|
+
## Installation
|
|
18
15
|
|
|
19
|
-
```
|
|
20
|
-
|
|
16
|
+
```sh
|
|
17
|
+
gem install verifiabl-issuer
|
|
21
18
|
```
|
|
22
19
|
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
Create one client with the client ID and client secret from your onboarding:
|
|
25
23
|
|
|
26
24
|
```ruby
|
|
27
25
|
require "verifiabl/issuer"
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
Both forms load the same `Verifiabl::Issuer` implementation; applications do not need to require
|
|
31
|
-
both. Ruby 3.3 or newer is required. QR encoding uses the pure-Ruby `rqrcode_core` gem; Verifiabl
|
|
32
|
-
owns canonical mask selection and badge rendering rather than depending on the gem's PNG or SVG
|
|
33
|
-
exporters.
|
|
34
|
-
|
|
35
|
-
## Configuration
|
|
36
|
-
|
|
37
|
-
Construct and retain an issuer client explicitly:
|
|
38
26
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
client_id: ENV.fetch("VERIFIABL_CLIENT_ID"),
|
|
44
|
-
client_secret: ENV.fetch("VERIFIABL_CLIENT_SECRET")
|
|
45
|
-
)
|
|
27
|
+
configuration = Verifiabl::Issuer::Configuration.new(
|
|
28
|
+
environment: :sandbox,
|
|
29
|
+
client_id: ENV.fetch("VERIFIABL_CLIENT_ID"),
|
|
30
|
+
client_secret: ENV.fetch("VERIFIABL_CLIENT_SECRET")
|
|
46
31
|
)
|
|
32
|
+
issuer = Verifiabl::Issuer::Client.new(configuration)
|
|
47
33
|
```
|
|
48
34
|
|
|
49
|
-
|
|
50
|
-
configure logging. Applications own their long-lived client and place it in their normal dependency
|
|
51
|
-
container; Rails applications commonly construct it in an initializer. A client is safe to share
|
|
52
|
-
between threads and refreshes its OAuth cache after a process fork.
|
|
53
|
-
|
|
54
|
-
Set `on_request`, `on_response`, and `on_error` on `Configuration` when request telemetry is needed.
|
|
55
|
-
Events contain request metadata but never request bodies, credentials, or payslip data. Observer
|
|
56
|
-
failures do not change request behavior.
|
|
35
|
+
### Share tokens between processes
|
|
57
36
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
bytes can outlast the budget. A completed request that returns after the deadline raises
|
|
63
|
-
`TimeoutError`; blocking custom adapters can also exceed it. The SDK does not use
|
|
64
|
-
`Timeout.timeout`, which can asynchronously interrupt sockets and corrupt persistent connections.
|
|
65
|
-
|
|
66
|
-
The Verifiabl reference is the idempotency key. `register_non_pii` generates and sends one
|
|
67
|
-
when omitted, so it and batch registration can safely retry transport failures, 408s, 429s, and 5xx
|
|
68
|
-
responses. API-managed barcode registration uses a server-generated reference and retries only 429,
|
|
69
|
-
which is rejected before processing. Configure these limits when needed:
|
|
37
|
+
Each client fetches an OAuth token and keeps it in memory, so each process fetches its own token.
|
|
38
|
+
If you run many worker processes, for example an autoscaled job queue, set `token_store:` to a
|
|
39
|
+
cache that the processes share. In a Rails application, use `Rails.cache` if Redis, Memcached or
|
|
40
|
+
Solid Cache stores its data:
|
|
70
41
|
|
|
71
42
|
```ruby
|
|
72
43
|
configuration = Verifiabl::Issuer::Configuration.new(
|
|
73
44
|
client_id: ENV.fetch("VERIFIABL_CLIENT_ID"),
|
|
74
45
|
client_secret: ENV.fetch("VERIFIABL_CLIENT_SECRET"),
|
|
75
|
-
|
|
76
|
-
max_retries: 2
|
|
46
|
+
token_store: Rails.cache
|
|
77
47
|
)
|
|
78
|
-
issuer = Verifiabl::Issuer::Client.new(configuration)
|
|
79
48
|
```
|
|
80
49
|
|
|
81
|
-
|
|
82
|
-
|
|
50
|
+
You can also use a different Active Support cache store, or any object with `read(key)` and
|
|
51
|
+
`write(key, value, expires_in:, unless_exist: false)`. With `unless_exist: true`, `write` must
|
|
52
|
+
write only if the key is absent, and return `false` if the key exists.
|
|
53
|
+
|
|
54
|
+
The client encrypts each token with a key derived from the client secret before it writes the
|
|
55
|
+
token. It names the entry with a keyed hash. The cache never holds a usable token or the client
|
|
56
|
+
credentials.
|
|
57
|
+
|
|
58
|
+
Each process uses its in-memory token. It reads the store only when that token is due for
|
|
59
|
+
renewal, one minute before it expires. When the stored token is missing or due, one process
|
|
60
|
+
claims the fetch and fetches a new token. The claim is an `unless_exist: true` write that lasts
|
|
61
|
+
for 10 seconds, the limit of the token request. While that process fetches:
|
|
62
|
+
|
|
63
|
+
- A process with a valid token continues to use it until the new token is in the store.
|
|
64
|
+
- A process with no valid token polls the store for the new token. The interval starts at 0.1
|
|
65
|
+
seconds and doubles to a maximum of one second. If the claim expires, that process claims the
|
|
66
|
+
fetch.
|
|
67
|
+
|
|
68
|
+
A fleet that renews together, or starts together after a quiet period, therefore fetches one
|
|
69
|
+
token. When the API rejects a token, the client fetches a new token immediately. If the client
|
|
70
|
+
fetched the rejected token less than one minute before, it returns the 401 instead.
|
|
71
|
+
|
|
72
|
+
Limits:
|
|
73
|
+
|
|
74
|
+
- A store failure costs a token fetch, not a registration. `Rails.cache` catches backend errors
|
|
75
|
+
and sends them to the Rails error reporter and logger, so `on_error` does not get them. To get
|
|
76
|
+
them in `on_error`, as an event with `token_store:` (`:read`, `:write` or `:claim`) and
|
|
77
|
+
`error:`, give the SDK a different store with an `error_handler:` that raises the exception
|
|
78
|
+
again. Do not make `Rails.cache` raise.
|
|
79
|
+
- If the process that holds the claim stops during its fetch, the processes with no token wait
|
|
80
|
+
until the claim expires, at most 10 seconds.
|
|
81
|
+
- The SDK cannot interrupt a store call. It checks the request's time budget after each call,
|
|
82
|
+
and store calls run while the client holds its token lock. Keep the store timeouts short. The
|
|
83
|
+
Rails Redis store default is one second.
|
|
84
|
+
- The claim is best effort. If the store has no atomic `unless_exist`, such as a file store,
|
|
85
|
+
several processes can fetch. A store that is local to each process, such as a memory store,
|
|
86
|
+
shares nothing.
|
|
87
|
+
- Stored expiry times are absolute. Keep worker clocks within one minute of each other.
|
|
88
|
+
|
|
89
|
+
A store event has only `token_store:` and `error:`. A request failure has `method:`, `url:`,
|
|
90
|
+
`path:` and `elapsed_ms:`. Check the kind of event before you read its fields. The SDK ignores
|
|
91
|
+
exceptions from `on_error`, so if your handler fails on a store event, the report is lost:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
on_error = lambda do |event|
|
|
95
|
+
if event.key?(:token_store)
|
|
96
|
+
logger.warn("Verifiabl token store #{event[:token_store]} failed: #{event[:error].class}")
|
|
97
|
+
else
|
|
98
|
+
logger.error("Verifiabl #{event[:method]} #{event[:path]} failed: #{event[:error].class}")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
To prepare and register payslips and make their QR codes, follow the [issuer SDK guide](https://docs.verifiabl.io/issuer-sdk?lang=ruby).
|
|
104
|
+
|
|
105
|
+
Before the first send, save each prepared `registration` and its ciphertext together. After a failure or restart, send that saved registration unchanged. It carries its reference, so the API does not register it twice. Preparing the payslip again makes a new reference. An `api_managed_registration` has no reference, so do not resend it after an ambiguous failure. See [Register again later](https://docs.verifiabl.io/issuer-sdk/retries-and-timeouts?lang=ruby#register-again-later).
|
|
83
106
|
|
|
84
107
|
## Connection reuse and custom transports
|
|
85
108
|
|
|
@@ -121,7 +144,8 @@ end
|
|
|
121
144
|
```
|
|
122
145
|
|
|
123
146
|
The adapter receives `method:`, `url:`, `headers:`, `body:` (serialized JSON), and `timeout:`
|
|
124
|
-
(remaining seconds), for both OAuth and issuer requests.
|
|
147
|
+
(remaining seconds), for both OAuth and issuer requests. The OAuth token request gets at most
|
|
148
|
+
10 seconds. Return an object with integer `status`,
|
|
125
149
|
`headers` (prefer lowercase keys), and string `body` readers. Adapters own their I/O and
|
|
126
150
|
pool-acquisition timeouts, failed-connection cleanup, and must not retry non-idempotent calls.
|
|
127
151
|
For a **strict total deadline**, bring an adapter that bounds DNS, pool waits, TLS, writes, headers,
|
|
@@ -130,395 +154,10 @@ cannot forcibly interrupt arbitrary blocking adapter code. Native `Timeout::Erro
|
|
|
130
154
|
become SDK `TimeoutError`; socket/I/O failures become SDK errors. The application owns adapter
|
|
131
155
|
shutdown, thread safety, and post-fork recreation.
|
|
132
156
|
|
|
133
|
-
##
|
|
134
|
-
|
|
135
|
-
For new AU/NZ v2 integrations, prepare the payslip once with its jurisdiction-specific helper. The helper selects the schema and PII formatter, validates non-PII fields with the SDK's existing rules, and encrypts locally. Choose one barcode flow for each payslip. Do not call both registration methods for the same issuance.
|
|
136
|
-
|
|
137
|
-
### AU2 and NZ2 payslip profiles
|
|
138
|
-
|
|
139
|
-
Use `prepare_australian_v2_payslip` for Australian records or
|
|
140
|
-
`prepare_new_zealand_v2_payslip` for New Zealand records. Each selects the
|
|
141
|
-
matching v2 schema and PII formatter internally. The AU2 formatter accepts
|
|
142
|
-
employer name and ABN separately, then writes the ABN when present or falls
|
|
143
|
-
back to the name. Structured address components collapse into one address
|
|
144
|
-
display field.
|
|
145
|
-
|
|
146
|
-
```ruby
|
|
147
|
-
require "base64"
|
|
148
|
-
|
|
149
|
-
provider_encryption_key = Base64.strict_decode64(
|
|
150
|
-
ENV.fetch("VERIFIABL_ENCRYPTION_KEY_BASE64")
|
|
151
|
-
)
|
|
152
|
-
prepared = Verifiabl::Issuer.prepare_australian_v2_payslip(
|
|
153
|
-
pii: {
|
|
154
|
-
employee_name: "Ana Leota",
|
|
155
|
-
employer_name: "Example Software Pty Ltd",
|
|
156
|
-
employer_abn: "12 345 678 901",
|
|
157
|
-
address: {lines: ["12 Example St"], suburb: "Sydney", state_or_territory: "NSW", postcode: "2000"}
|
|
158
|
-
},
|
|
159
|
-
payslip_non_pii: {
|
|
160
|
-
period_end: "2026-05-31", payment_date: "2026-06-04", currency: "AUD",
|
|
161
|
-
gross: "9000.00", paygw: "2250.00", net: "6750.00"
|
|
162
|
-
},
|
|
163
|
-
issued_at: Time.now.utc,
|
|
164
|
-
key: provider_encryption_key
|
|
165
|
-
)
|
|
166
|
-
# Persist the registration and ciphertext together before sending (binary columns).
|
|
167
|
-
saved_registration = prepared.registration
|
|
168
|
-
saved_ciphertext = prepared.barcode_parts(prepared.verifiabl_reference).fetch(:encrypted_pii)
|
|
169
|
-
# Persist saved_registration and saved_ciphertext atomically. The registration
|
|
170
|
-
# has the reference, IV and tag, but not the ciphertext.
|
|
171
|
-
registration = issuer.register_non_pii(**saved_registration)
|
|
172
|
-
# After a restart, resend saved_registration unchanged and render from saved_ciphertext.
|
|
173
|
-
barcode = Verifiabl::Issuer.build_barcode_svg(
|
|
174
|
-
verifiabl_reference: registration.verifiabl_reference,
|
|
175
|
-
encrypted_pii: saved_ciphertext, environment: :sandbox
|
|
176
|
-
)
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
For NZ, pass the `ird_number` and the printed employer and bank details in the
|
|
180
|
-
NZ helper's `pii:` input. NZ2 carries the printed employee IRD number,
|
|
181
|
-
employer name, account number and account name. It has no BSB or NZBN field.
|
|
182
|
-
|
|
183
|
-
Both formatters always write eight positions, including empty trailing fields. AU addresses render
|
|
184
|
-
as address lines followed by `suburb state postcode`; NZ addresses render as address lines, optional
|
|
185
|
-
suburb, then `city postcode`. Country is implicit. The complete UTF-8 plaintext is limited to 1024
|
|
186
|
-
bytes.
|
|
187
|
-
|
|
188
|
-
The preparation helpers pair the AU2/NZ2 PII format with the matching v2
|
|
189
|
-
non-PII schema. Their input does not accept a schema, formatted plaintext, or
|
|
190
|
-
ciphertext. They do not check whether input values describe a real payslip or
|
|
191
|
-
whether printed non-PII strings contain personal information. Keep employee
|
|
192
|
-
PII out of non-PII fields. Advanced integrations can still select the schema
|
|
193
|
-
and formatter separately with the low-level APIs. PII format and non-PII
|
|
194
|
-
schema versions are independent; legacy v1 verification remains supported.
|
|
195
|
-
The verifier currently interprets AU2 only for `au.payslip.v2` and NZ2 only
|
|
196
|
-
for `nz.payslip.v2`. Future non-PII schemas need an explicit verifier reader
|
|
197
|
-
mapping before reusing either PII format; an unknown schema falls back to raw
|
|
198
|
-
PII text rather than structured fields.
|
|
199
|
-
|
|
200
|
-
Every AU2 and NZ2 amount, rate and quantity is a plain decimal `String`, for example `"1234.56"`,
|
|
201
|
-
`"-25.00"` or `"47.3684"`: an optional leading `-`, digits, and an optional `.` followed by digits.
|
|
202
|
-
The SDK sends the string exactly as given, so `"1.50"` and `"1.5"` stay distinct. It rejects any
|
|
203
|
-
other value, including an `Integer`, `Float` or `BigDecimal`, before sending. `period_start` is
|
|
204
|
-
optional for AU2 and NZ2. `currency` is required and must be a current ISO 4217 currency code
|
|
205
|
-
(`Verifiabl::Issuer::SUPPORTED_V2_CURRENCIES`). Fund codes and codes with no minor unit, for example
|
|
206
|
-
`XAU` or `XXX`, are not accepted, because wages are paid in legal tender. The SDK also rejects unlisted fields (including
|
|
207
|
-
nested fields) before transport to avoid sending accidental PII; the API validates the remaining
|
|
208
|
-
payslip rules.
|
|
209
|
-
|
|
210
|
-
For common fixed AU/NZ v2 fields, use the frozen discovery lists in
|
|
211
|
-
`Verifiabl::Issuer::PayslipCodes`. The lists are scoped by jurisdiction and
|
|
212
|
-
contain the known codes, including the earnings discriminators:
|
|
213
|
-
|
|
214
|
-
```ruby
|
|
215
|
-
au = Verifiabl::Issuer::PayslipCodes::Australian
|
|
216
|
-
nz = Verifiabl::Issuer::PayslipCodes::NewZealand
|
|
217
|
-
au::PAY_FREQUENCIES # => ["weekly", "fortnightly", "monthly", "quarterly", "four_weekly", "semi_monthly"]
|
|
218
|
-
au::OTHER_ALLOWANCE_CATEGORIES.include?("home_office") # => true
|
|
219
|
-
nz::LEAVE_BALANCE_UNITS # => ["hours", "days", "weeks"]
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
`EARNINGS_TYPES`, `PAID_LEAVE_TYPES` and `ALLOWANCE_TYPES` are available for
|
|
223
|
-
both jurisdictions. An AU2 `lump_sum` line takes a `lump_sum_type` from
|
|
224
|
-
`LUMP_SUM_TYPES`, and an `etp` line takes an `etp_type` from `ETP_TYPES` and
|
|
225
|
-
an `etp_component` from `ETP_COMPONENTS`. Use their strings in your normal
|
|
226
|
-
payslip hash; the lists do not restrict input or add local enum validation,
|
|
227
|
-
and the API may accept a new code before this gem is updated. The gem raises
|
|
228
|
-
before sending an AU2 `lump_sum` line without a `lump_sum_type`.
|
|
229
|
-
Printed-text fields such as `tax_code` and `pay_cycle` are not code sets.
|
|
230
|
-
|
|
231
|
-
### Self-managed barcode flow
|
|
232
|
-
|
|
233
|
-
Use this flow when your application renders the barcode. This AU2 example is generated from
|
|
234
|
-
the runnable
|
|
235
|
-
[`examples/issuer/basic/issue_payslips_self_managed.rb`](../../examples/issuer/basic/issue_payslips_self_managed.rb)
|
|
236
|
-
source.
|
|
237
|
-
|
|
238
|
-
<!-- snippet:ruby.basic-issuance:start -->
|
|
239
|
-
|
|
240
|
-
```ruby
|
|
241
|
-
require "base64"
|
|
242
|
-
require "fileutils"
|
|
243
|
-
require "verifiabl/issuer"
|
|
244
|
-
|
|
245
|
-
PAYSLIP = {
|
|
246
|
-
external_id: "PAY-1001",
|
|
247
|
-
pii: {
|
|
248
|
-
employee_name: "Ana Leota",
|
|
249
|
-
position: "Senior Developer",
|
|
250
|
-
department: "Engineering",
|
|
251
|
-
employer_name: "Example Software Pty Ltd",
|
|
252
|
-
employer_abn: "12 345 678 901",
|
|
253
|
-
bsb: "062-000",
|
|
254
|
-
account_number: "****5678",
|
|
255
|
-
account_name: "A Leota",
|
|
256
|
-
address: {lines: ["12 Example St"], suburb: "Sydney", state_or_territory: "NSW", postcode: "2000"}
|
|
257
|
-
},
|
|
258
|
-
non_pii: {
|
|
259
|
-
period_end: "2026-08-31",
|
|
260
|
-
payment_date: "2026-09-04",
|
|
261
|
-
currency: "AUD",
|
|
262
|
-
gross: "9000.00",
|
|
263
|
-
paygw: "2250.00",
|
|
264
|
-
net: "6750.00"
|
|
265
|
-
}
|
|
266
|
-
}.freeze
|
|
267
|
-
|
|
268
|
-
issuer = Verifiabl::Issuer::Client.new(
|
|
269
|
-
Verifiabl::Issuer::Configuration.new(
|
|
270
|
-
environment: :sandbox,
|
|
271
|
-
client_id: ENV.fetch("VERIFIABL_CLIENT_ID"),
|
|
272
|
-
client_secret: ENV.fetch("VERIFIABL_CLIENT_SECRET")
|
|
273
|
-
)
|
|
274
|
-
)
|
|
275
|
-
|
|
276
|
-
provider_encryption_key = Base64.strict_decode64(
|
|
277
|
-
ENV.fetch("VERIFIABL_ENCRYPTION_KEY_BASE64")
|
|
278
|
-
)
|
|
279
|
-
prepared = Verifiabl::Issuer.prepare_australian_v2_payslip(
|
|
280
|
-
pii: PAYSLIP.fetch(:pii),
|
|
281
|
-
payslip_non_pii: PAYSLIP.fetch(:non_pii),
|
|
282
|
-
issued_at: Time.now.utc,
|
|
283
|
-
key: provider_encryption_key
|
|
284
|
-
)
|
|
285
|
-
# Persist prepared.registration and the binary ciphertext from
|
|
286
|
-
# prepared.barcode_parts(prepared.verifiabl_reference).fetch(:encrypted_pii)
|
|
287
|
-
# atomically before sending. Reuse both after a process restart.
|
|
288
|
-
result = issuer.register_non_pii(**prepared.registration)
|
|
289
|
-
|
|
290
|
-
barcode = Verifiabl::Issuer.build_barcode_svg(
|
|
291
|
-
**prepared.barcode_parts(result.verifiabl_reference),
|
|
292
|
-
environment: :sandbox
|
|
293
|
-
)
|
|
294
|
-
|
|
295
|
-
output_path = File.expand_path("output/self-managed-barcode.svg", __dir__)
|
|
296
|
-
FileUtils.mkdir_p(File.dirname(output_path))
|
|
297
|
-
|
|
298
|
-
File.write(output_path, barcode.svg)
|
|
299
|
-
puts "Registered #{result.verifiabl_reference} and wrote #{output_path}"
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
<!-- snippet:ruby.basic-issuance:end -->
|
|
303
|
-
|
|
304
|
-
`register_non_pii` sends the non-PII fields and encryption metadata, but not the encrypted PII. The
|
|
305
|
-
application combines the returned reference with the encrypted PII to render the SVG locally.
|
|
306
|
-
|
|
307
|
-
### API-managed barcode flow
|
|
308
|
-
|
|
309
|
-
Use this alternative when Verifiabl should render the barcode PNG. This NZ2 example is
|
|
310
|
-
generated from the runnable
|
|
311
|
-
[`examples/issuer/basic/issue_payslips_api_managed.rb`](../../examples/issuer/basic/issue_payslips_api_managed.rb)
|
|
312
|
-
source.
|
|
313
|
-
|
|
314
|
-
<!-- snippet:ruby.api-managed-issuance:start -->
|
|
315
|
-
|
|
316
|
-
```ruby
|
|
317
|
-
require "base64"
|
|
318
|
-
require "fileutils"
|
|
319
|
-
require "verifiabl/issuer"
|
|
320
|
-
|
|
321
|
-
PAYSLIP = {
|
|
322
|
-
external_id: "PAY-1002",
|
|
323
|
-
pii: {
|
|
324
|
-
employee_name: "Ana Leota",
|
|
325
|
-
ird_number: "***-***-***",
|
|
326
|
-
position: "Senior Developer",
|
|
327
|
-
department: "Engineering",
|
|
328
|
-
employer_name: "Example Software Limited",
|
|
329
|
-
account_number: "**-****-*******-**",
|
|
330
|
-
account_name: "A Leota",
|
|
331
|
-
address: {lines: ["12 Example Street"], suburb: "Te Aro", city: "Wellington", postcode: "6011"}
|
|
332
|
-
},
|
|
333
|
-
non_pii: {
|
|
334
|
-
period_end: "2026-08-31",
|
|
335
|
-
payment_date: "2026-09-04",
|
|
336
|
-
currency: "NZD",
|
|
337
|
-
gross: "9000.00",
|
|
338
|
-
paye: "2283.96",
|
|
339
|
-
net: "6716.04"
|
|
340
|
-
}
|
|
341
|
-
}.freeze
|
|
342
|
-
|
|
343
|
-
issuer = Verifiabl::Issuer::Client.new(
|
|
344
|
-
Verifiabl::Issuer::Configuration.new(
|
|
345
|
-
environment: :sandbox,
|
|
346
|
-
client_id: ENV.fetch("VERIFIABL_CLIENT_ID"),
|
|
347
|
-
client_secret: ENV.fetch("VERIFIABL_CLIENT_SECRET")
|
|
348
|
-
)
|
|
349
|
-
)
|
|
350
|
-
|
|
351
|
-
provider_encryption_key = Base64.strict_decode64(
|
|
352
|
-
ENV.fetch("VERIFIABL_ENCRYPTION_KEY_BASE64")
|
|
353
|
-
)
|
|
354
|
-
prepared = Verifiabl::Issuer.prepare_new_zealand_v2_payslip(
|
|
355
|
-
pii: PAYSLIP.fetch(:pii),
|
|
356
|
-
payslip_non_pii: PAYSLIP.fetch(:non_pii),
|
|
357
|
-
issued_at: Time.now.utc,
|
|
358
|
-
key: provider_encryption_key
|
|
359
|
-
)
|
|
360
|
-
# API-managed registration allocates its own reference. Do not replay an
|
|
361
|
-
# ambiguous failure as if prepared.verifiabl_reference were its idempotency key.
|
|
362
|
-
result = issuer.register_and_build_barcode(**prepared.api_managed_registration)
|
|
363
|
-
|
|
364
|
-
output_path = File.expand_path("output/api-managed-barcode.png", __dir__)
|
|
365
|
-
FileUtils.mkdir_p(File.dirname(output_path))
|
|
366
|
-
|
|
367
|
-
File.binwrite(output_path, Base64.strict_decode64(result.barcode.data))
|
|
368
|
-
puts "Registered #{result.verifiabl_reference} and wrote #{output_path}"
|
|
369
|
-
```
|
|
370
|
-
|
|
371
|
-
<!-- snippet:ruby.api-managed-issuance:end -->
|
|
372
|
-
|
|
373
|
-
`register_and_build_barcode` sends the encrypted PII in addition to the non-PII fields and encryption
|
|
374
|
-
metadata. Verifiabl uses the ciphertext to render the returned PNG and does not retain it. Plaintext
|
|
375
|
-
PII is never sent in either flow.
|
|
376
|
-
|
|
377
|
-
### Retries and batches
|
|
378
|
-
|
|
379
|
-
For a self-managed registration that must remain retryable across process
|
|
380
|
-
restarts, persist `prepared.registration` and the binary ciphertext from
|
|
381
|
-
`prepared.barcode_parts(prepared.verifiabl_reference).fetch(:encrypted_pii)`
|
|
382
|
-
together before the first call. The registration includes the reference, IV and
|
|
383
|
-
tag but not the ciphertext. Reuse the *same* registration for a later attempt,
|
|
384
|
-
then render from the saved ciphertext and the returned reference. You can pass `verifiabl_reference:` to the preparation helper if your
|
|
385
|
-
system already allocated one. Do not prepare and encrypt again for an
|
|
386
|
-
idempotent replay. The API-managed request does not include this reference.
|
|
387
|
-
|
|
388
|
-
For pay runs, prepare each record with the matching jurisdiction helper. Pass
|
|
389
|
-
its `registration` hash to batch registration with an optional `external_id`.
|
|
390
|
-
Use each prepared result's `barcode_parts` with the corresponding batch result:
|
|
391
|
-
|
|
392
|
-
```ruby
|
|
393
|
-
prepared = payslips.map do |payslip|
|
|
394
|
-
if payslip.fetch(:country) == "AU"
|
|
395
|
-
Verifiabl::Issuer.prepare_australian_v2_payslip(
|
|
396
|
-
pii: payslip.fetch(:pii), payslip_non_pii: payslip.fetch(:non_pii),
|
|
397
|
-
issued_at: Time.now.utc, key: provider_encryption_key
|
|
398
|
-
)
|
|
399
|
-
else
|
|
400
|
-
Verifiabl::Issuer.prepare_new_zealand_v2_payslip(
|
|
401
|
-
pii: payslip.fetch(:pii), payslip_non_pii: payslip.fetch(:non_pii),
|
|
402
|
-
issued_at: Time.now.utc, key: provider_encryption_key
|
|
403
|
-
)
|
|
404
|
-
end
|
|
405
|
-
end
|
|
406
|
-
# Persist each prepared reference, registration, and ciphertext before sending.
|
|
407
|
-
batch = issuer.register_non_pii_batch(prepared.each_with_index.map do |item, index|
|
|
408
|
-
item.registration.merge(external_id: payslips.fetch(index).fetch(:external_id))
|
|
409
|
-
end)
|
|
410
|
-
batch.results.each_with_index do |item, index|
|
|
411
|
-
if %w[created duplicate].include?(item.status)
|
|
412
|
-
parts = prepared.fetch(index).barcode_parts(item.verifiabl_reference)
|
|
413
|
-
# Render this record's barcode from parts.
|
|
414
|
-
else
|
|
415
|
-
# Handle item.code; do not parse item.detail.
|
|
416
|
-
end
|
|
417
|
-
end
|
|
418
|
-
```
|
|
419
|
-
|
|
420
|
-
The API returns `201` for the first registration and `200` for an identical replay. Reusing a
|
|
421
|
-
reference with different content raises `ApiError` with code `CONFLICT`.
|
|
422
|
-
|
|
423
|
-
Successful calls return immutable typed result objects under `Verifiabl::Issuer::Responses`.
|
|
424
|
-
Malformed successful API responses raise `TransportError`. Issuer HTTP errors raise `ApiError` (or
|
|
425
|
-
`IvReuseError` for `IV_REUSED`). Non-timeout OAuth connectivity failures and invalid or unsuccessful
|
|
426
|
-
token responses raise `AuthError`. Deadline expiry raises `TimeoutError`, while issuer network
|
|
427
|
-
failures raise `TransportError`. Request and error messages never include the submitted payslip body.
|
|
428
|
-
|
|
429
|
-
## Offline protocol primitives
|
|
430
|
-
|
|
431
|
-
Most integrations should use the v2 preparation helpers. To select the schema and formatter
|
|
432
|
-
yourself, format and encrypt PII locally with `format_australian_pii` (AU2, for `au.payslip.v2`) or
|
|
433
|
-
`format_new_zealand_pii` (NZ2, for `nz.payslip.v2`). The plaintext must never be logged or sent to
|
|
434
|
-
Verifiabl:
|
|
435
|
-
|
|
436
|
-
```ruby
|
|
437
|
-
plaintext = Verifiabl::Issuer.format_australian_pii(
|
|
438
|
-
employee_name: "Ana Leota",
|
|
439
|
-
employer_abn: "53004085616",
|
|
440
|
-
address: {lines: ["12 Example St"], suburb: "Sydney", state_or_territory: "NSW", postcode: "2000"}
|
|
441
|
-
)
|
|
442
|
-
|
|
443
|
-
encrypted = Verifiabl::Issuer.encrypt_pii(plaintext, provider_encryption_key)
|
|
444
|
-
reference = Verifiabl::Issuer.generate_verifiabl_reference
|
|
157
|
+
## Support
|
|
445
158
|
|
|
446
|
-
|
|
447
|
-
verifiabl_reference: reference,
|
|
448
|
-
encrypted_pii: encrypted.encrypted_pii,
|
|
449
|
-
environment: :sandbox
|
|
450
|
-
)
|
|
451
|
-
|
|
452
|
-
xmp_payload = Verifiabl::Issuer.build_barcode_payload(
|
|
453
|
-
verifiabl_reference: reference,
|
|
454
|
-
encrypted_pii: encrypted.encrypted_pii
|
|
455
|
-
)
|
|
456
|
-
|
|
457
|
-
svg = Verifiabl::Issuer.build_barcode_svg(
|
|
458
|
-
verifiabl_reference: reference,
|
|
459
|
-
encrypted_pii: encrypted.encrypted_pii,
|
|
460
|
-
environment: :sandbox,
|
|
461
|
-
width: 480
|
|
462
|
-
)
|
|
463
|
-
File.write("barcode.svg", svg.svg)
|
|
464
|
-
|
|
465
|
-
png = Verifiabl::Issuer.build_barcode_png(
|
|
466
|
-
verifiabl_reference: reference,
|
|
467
|
-
encrypted_pii: encrypted.encrypted_pii,
|
|
468
|
-
environment: :sandbox,
|
|
469
|
-
width: 720
|
|
470
|
-
)
|
|
471
|
-
File.binwrite("barcode.png", png.png)
|
|
472
|
-
```
|
|
473
|
-
|
|
474
|
-
The renderers use the same official vector frame, rounded finder geometry, and QR error-correction
|
|
475
|
-
ladder as the Node and .NET SDKs. Vertical is the default; pass `layout: :horizontal` to put the
|
|
476
|
-
QR on the left at full badge height, followed by a 7-unit white gap and the light-tinted
|
|
477
|
-
"Secured by Verifiabl" panel on the right. Horizontal rendering is included in `0.4.0`.
|
|
478
|
-
|
|
479
|
-
| Layout | SVG minimum/default width | PNG widths | PNG default width |
|
|
480
|
-
| --- | ---: | --- | ---: |
|
|
481
|
-
| `:vertical` | 480 | 480, 720, 960, 1440 | 720 |
|
|
482
|
-
| `:horizontal` | 940 | 940, 1410, 1880, 2820 | 1410 |
|
|
483
|
-
|
|
484
|
-
SVG widths scale continuously above the minimum. PNG frames are pre-rasterised for deterministic
|
|
485
|
-
cross-SDK output and support only the listed widths. Both layouts render the QR at the same sizes
|
|
486
|
-
at corresponding widths; the horizontal badge's total width includes its brand panel and gap.
|
|
487
|
-
For example, `build_barcode_svg(**parts, layout: :horizontal)` returns a 940×480 SVG and
|
|
488
|
-
`build_barcode_png(**parts, layout: :horizontal)` returns a 1410×720 PNG.
|
|
489
|
-
|
|
490
|
-
### Badge placement
|
|
491
|
-
|
|
492
|
-
Place the badge on a light background with a clear margin of at least four QR modules
|
|
493
|
-
(`result.module_px` at the rendered size) on every open QR side: **left, right and bottom** for
|
|
494
|
-
vertical; **left, top and bottom** for horizontal. A tenth of the QR side covers realistic records.
|
|
495
|
-
The host document supplies these quiet zones; the badge supplies only the gap facing the brand
|
|
496
|
-
frame. Size the QR, not the whole horizontal badge: a 22 mm QR means a horizontal badge about
|
|
497
|
-
43.08 mm wide and 22 mm high.
|
|
498
|
-
|
|
499
|
-
Use `max_error_correction: :q` for greater damage recovery; the default is
|
|
500
|
-
`:m`, and the renderer steps down only when necessary to preserve the three-pixel module floor.
|
|
501
|
-
Each result's `degraded` field (`svg.degraded` or `png.degraded` above) is true when the renderer
|
|
502
|
-
steps down from the requested ceiling or the modules fall below the ideal four-pixel size.
|
|
503
|
-
|
|
504
|
-
`provider_encryption_key` must be exactly 32 bytes and loaded from a KMS or secrets manager. Every
|
|
505
|
-
encryption call creates a fresh AES-256-GCM IV. Ciphertext, IV, and authentication tags are exposed
|
|
506
|
-
as binary Ruby strings (`Encoding::BINARY`) and can be persisted directly in binary database
|
|
507
|
-
columns. The SDK encodes them only at an external boundary: base64url for issuer API requests and
|
|
508
|
-
uppercase, unpadded RFC 4648 Base32 for v2 barcode and XMP output. PDF integrations should store `xmp_payload` under
|
|
509
|
-
XMP namespace `https://verifiabl.io/ns/`, property `payload`. QR v2 is encoded as an explicit byte
|
|
510
|
-
prefix and alphanumeric ciphertext segment. Matrix version, mask, and modules match the Node and
|
|
511
|
-
.NET SDKs.
|
|
512
|
-
|
|
513
|
-
## Development
|
|
514
|
-
|
|
515
|
-
```sh
|
|
516
|
-
bundle install
|
|
517
|
-
bundle exec rake
|
|
518
|
-
(cd gems/verifiabl-issuer && bundle exec gem build verifiabl-issuer.gemspec)
|
|
519
|
-
bundle exec ruby script/packed_gem_check.rb
|
|
520
|
-
```
|
|
159
|
+
Report issues at [github.com/Verifiabl/verifiabl-ruby](https://github.com/Verifiabl/verifiabl-ruby/issues).
|
|
521
160
|
|
|
522
161
|
## License
|
|
523
162
|
|
|
524
|
-
[
|
|
163
|
+
MIT. See [LICENSE](https://github.com/Verifiabl/verifiabl-ruby/blob/main/gems/verifiabl-issuer/LICENSE).
|
|
@@ -17,6 +17,10 @@ module Verifiabl
|
|
|
17
17
|
RETRY_BASE_SECONDS = 0.5
|
|
18
18
|
RETRY_MAX_SECONDS = 8.0
|
|
19
19
|
REQUEST_ID_HEADERS = %w[x-request-id request-id x-verifiabl-request-id].freeze
|
|
20
|
+
SHARED_FETCH_POLL_SECONDS = 0.1
|
|
21
|
+
SHARED_FETCH_MAX_POLL_SECONDS = 1.0
|
|
22
|
+
REJECTED_TOKEN_MIN_AGE_SECONDS = 60
|
|
23
|
+
TOKEN_FETCH_TIMEOUT_SECONDS = 10
|
|
20
24
|
|
|
21
25
|
def self.request_id(headers)
|
|
22
26
|
REQUEST_ID_HEADERS.each do |name|
|
|
@@ -41,11 +45,12 @@ module Verifiabl
|
|
|
41
45
|
@on_request = configuration.on_request
|
|
42
46
|
@on_response = configuration.on_response
|
|
43
47
|
@on_error = configuration.on_error
|
|
48
|
+
@shared_token_store = build_shared_token_store(configuration.token_store)
|
|
44
49
|
@token_mutex = Mutex.new
|
|
45
50
|
@process_id = Process.pid
|
|
46
51
|
end
|
|
47
52
|
|
|
48
|
-
# Registers non-PII data under
|
|
53
|
+
# Registers non-PII data under an issuer-generated reference. When the
|
|
49
54
|
# caller omits one, generate it once before the request loop so every
|
|
50
55
|
# automatic retry uses the same idempotency key. Callers that need to
|
|
51
56
|
# retry from another process or invocation should generate, persist, and
|
|
@@ -124,11 +129,17 @@ module Verifiabl
|
|
|
124
129
|
|
|
125
130
|
def send_with_auth(path, body, deadline)
|
|
126
131
|
token = access_token(deadline)
|
|
127
|
-
response = issuer_request(path, body, token, deadline)
|
|
128
|
-
return response unless response.status == 401
|
|
132
|
+
response = issuer_request(path, body, token.value, deadline)
|
|
133
|
+
return response unless response.status == 401 && replaceable?(token)
|
|
129
134
|
|
|
130
|
-
invalidate_token(token, deadline)
|
|
131
|
-
issuer_request(path, body, access_token(deadline), deadline)
|
|
135
|
+
invalidate_token(token.value, deadline)
|
|
136
|
+
issuer_request(path, body, access_token(deadline, rejected: token.value).value, deadline)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A token the API rejects within a minute of its fetch is not replaced:
|
|
140
|
+
# a replacement would be rejected too, once per request.
|
|
141
|
+
def replaceable?(token)
|
|
142
|
+
@clock.call - token.issued_at >= REJECTED_TOKEN_MIN_AGE_SECONDS
|
|
132
143
|
end
|
|
133
144
|
|
|
134
145
|
def issuer_request(path, body, token, deadline)
|
|
@@ -165,23 +176,101 @@ module Verifiabl
|
|
|
165
176
|
{"content-type" => "application/json", "accept" => "application/json", "user-agent" => "verifiabl-issuer-ruby/#{VERSION} (ruby #{RUBY_VERSION})"}
|
|
166
177
|
end
|
|
167
178
|
|
|
168
|
-
|
|
179
|
+
# A token the API just rejected is never reused, whichever layer still
|
|
180
|
+
# holds it: the shared store keeps it until a replacement overwrites it.
|
|
181
|
+
def access_token(deadline, rejected: nil)
|
|
169
182
|
reset_token_cache_after_fork!
|
|
170
|
-
now = @clock.call
|
|
171
183
|
cached = @token
|
|
172
|
-
return cached
|
|
184
|
+
return cached if usable_token?(cached, @clock.call, rejected)
|
|
173
185
|
|
|
174
186
|
ensure_before_deadline!(deadline)
|
|
175
187
|
with_token_lock(deadline) do
|
|
176
188
|
ensure_before_deadline!(deadline)
|
|
177
|
-
|
|
178
|
-
|
|
189
|
+
return @token if usable_token?(@token, @clock.call, rejected)
|
|
190
|
+
|
|
191
|
+
@token = shared_token(rejected) || fetch_or_wait(deadline, rejected)
|
|
192
|
+
# The store owns its timeouts, like a transport adapter, so a slow
|
|
193
|
+
# store is only noticed afterwards. The token is kept either way: it
|
|
194
|
+
# was paid for, and the next request can use it.
|
|
195
|
+
ensure_before_deadline!(deadline)
|
|
196
|
+
@token
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Fresh enough to use without looking for a renewal.
|
|
201
|
+
def usable_token?(token, now, rejected)
|
|
202
|
+
token && token.value != rejected && token_fresh?(token, now)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Due for renewal but still accepted by the API.
|
|
206
|
+
def valid_token?(token, now, rejected)
|
|
207
|
+
token && token.value != rejected && token.expires_at > now
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The store read takes time, so freshness is judged by the clock after
|
|
211
|
+
# it returns, not before.
|
|
212
|
+
def shared_token(rejected = nil)
|
|
213
|
+
token = @shared_token_store&.read(@clock.call)
|
|
214
|
+
token if usable_token?(token, @clock.call, rejected)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Workers that share a store find its token due, or missing after a
|
|
218
|
+
# quiet period, at the same time. One claims the fetch. A worker with a
|
|
219
|
+
# valid token keeps using it. A worker with none polls the store, and
|
|
220
|
+
# claims the fetch itself if the claim expires. The poll interval
|
|
221
|
+
# doubles up to one second, so a stalled claimant costs a large fleet
|
|
222
|
+
# about one store read per worker each second.
|
|
223
|
+
def fetch_or_wait(deadline, rejected)
|
|
224
|
+
poll_seconds = SHARED_FETCH_POLL_SECONDS
|
|
225
|
+
loop do
|
|
226
|
+
fetch_deadline = token_fetch_deadline(deadline)
|
|
227
|
+
return fetch_and_share_token(fetch_deadline) if fetch_claim_mine?(deadline, fetch_deadline, rejected)
|
|
228
|
+
return @token if valid_token?(@token, @clock.call, rejected)
|
|
179
229
|
|
|
180
|
-
|
|
181
|
-
@
|
|
230
|
+
ensure_before_deadline!(deadline)
|
|
231
|
+
@sleeper.call([poll_seconds, remaining_time(deadline)].min)
|
|
232
|
+
poll_seconds = [poll_seconds * 2, SHARED_FETCH_MAX_POLL_SECONDS].min
|
|
233
|
+
ensure_before_deadline!(deadline)
|
|
234
|
+
token = shared_token(rejected)
|
|
235
|
+
return token if token
|
|
182
236
|
end
|
|
183
237
|
end
|
|
184
238
|
|
|
239
|
+
# True when this process should fetch: there is no store, the API
|
|
240
|
+
# rejected the stored token, or no process holds the claim. The claim
|
|
241
|
+
# ends at the token request's deadline, so it covers the time the store
|
|
242
|
+
# calls take as well. A refused claim is read back: a store that
|
|
243
|
+
# swallows a backend error returns `false` for the write and `nil` for
|
|
244
|
+
# the read.
|
|
245
|
+
def fetch_claim_mine?(deadline, fetch_deadline, rejected)
|
|
246
|
+
return true if @shared_token_store.nil? || rejected
|
|
247
|
+
|
|
248
|
+
ensure_before_deadline!(deadline)
|
|
249
|
+
return false if @shared_token_store.fetch_claim_held?
|
|
250
|
+
|
|
251
|
+
ensure_before_deadline!(deadline)
|
|
252
|
+
return true unless @shared_token_store.claim_fetch(remaining_time(fetch_deadline).ceil) == false
|
|
253
|
+
|
|
254
|
+
ensure_before_deadline!(deadline)
|
|
255
|
+
!@shared_token_store.fetch_claim_held?
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# The token request gets its own short limit, so a process that takes
|
|
259
|
+
# over a lapsed claim still has time to register, and a claim that
|
|
260
|
+
# outlives a fast fetch expires before the new token is due.
|
|
261
|
+
def token_fetch_deadline(deadline)
|
|
262
|
+
[deadline, @monotonic_clock.call + TOKEN_FETCH_TIMEOUT_SECONDS].min
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# The clock is sampled after the store miss: a token dated from before
|
|
266
|
+
# a slow read would look older than it is.
|
|
267
|
+
def fetch_and_share_token(fetch_deadline)
|
|
268
|
+
now = @clock.call
|
|
269
|
+
token = fetch_token(now, fetch_deadline)
|
|
270
|
+
@shared_token_store&.write(token, now)
|
|
271
|
+
token
|
|
272
|
+
end
|
|
273
|
+
|
|
185
274
|
def invalidate_token(rejected, deadline)
|
|
186
275
|
reset_token_cache_after_fork!
|
|
187
276
|
with_token_lock(deadline) { @token = nil if @token&.value == rejected }
|
|
@@ -204,11 +293,15 @@ module Verifiabl
|
|
|
204
293
|
# permanently locked copy of the parent's mutex. A child has one thread
|
|
205
294
|
# immediately after fork, so replacing this local state before locking is
|
|
206
295
|
# safe and gives it an independent token cache.
|
|
296
|
+
#
|
|
297
|
+
# Each child also inherits the parent's random generator state. A fresh
|
|
298
|
+
# generator stops children from retrying in lockstep.
|
|
207
299
|
def reset_token_cache_after_fork!
|
|
208
300
|
return if @process_id == Process.pid
|
|
209
301
|
|
|
210
302
|
@token_mutex = Mutex.new
|
|
211
303
|
@token = nil
|
|
304
|
+
@random = Random.new
|
|
212
305
|
@process_id = Process.pid
|
|
213
306
|
end
|
|
214
307
|
|
|
@@ -217,9 +310,29 @@ module Verifiabl
|
|
|
217
310
|
token.expires_at - now > [60, ttl / 2].min
|
|
218
311
|
end
|
|
219
312
|
|
|
313
|
+
def build_shared_token_store(backend)
|
|
314
|
+
return if backend.nil?
|
|
315
|
+
raise ArgumentError, "token_store must respond to read and write" unless SharedTokenStore.valid_backend?(backend)
|
|
316
|
+
# Invalid credentials fail at the token fetch, as they do without a store.
|
|
317
|
+
return unless @client_id.is_a?(String) && @client_secret.is_a?(String)
|
|
318
|
+
|
|
319
|
+
SharedTokenStore.new(
|
|
320
|
+
backend,
|
|
321
|
+
client_secret: @client_secret.strip,
|
|
322
|
+
identity: [@token_url, @client_id.strip, @issuer_base_url, ISSUER_SCOPE],
|
|
323
|
+
on_error: @on_error
|
|
324
|
+
)
|
|
325
|
+
end
|
|
326
|
+
|
|
220
327
|
def fetch_token(now, deadline)
|
|
221
328
|
validate_credentials!
|
|
222
|
-
response = request(
|
|
329
|
+
response = request(
|
|
330
|
+
method: :post,
|
|
331
|
+
url: @token_url,
|
|
332
|
+
headers: json_headers,
|
|
333
|
+
body: token_request_body,
|
|
334
|
+
deadline: deadline
|
|
335
|
+
)
|
|
223
336
|
parsed = parse_json(response.body)
|
|
224
337
|
validate_token_response!(response, parsed)
|
|
225
338
|
CachedToken.new(value: parsed["access_token"], issued_at: now, expires_at: now + parsed["expires_in"])
|
|
@@ -326,13 +439,20 @@ module Verifiabl
|
|
|
326
439
|
end
|
|
327
440
|
|
|
328
441
|
def configure_endpoints(configuration)
|
|
329
|
-
@client_id = configuration.client_id
|
|
330
|
-
@client_secret = configuration.client_secret
|
|
442
|
+
@client_id = frozen_copy(configuration.client_id)
|
|
443
|
+
@client_secret = frozen_copy(configuration.client_secret)
|
|
331
444
|
origins = Payload.resolve_environment(configuration.environment)
|
|
332
445
|
@token_url = normalize_token_url(configuration.token_url || origins.fetch(:token_url))
|
|
333
446
|
@issuer_base_url = normalize_issuer_base_url(configuration.issuer_base_url || origins.fetch(:issuer_base_url))
|
|
334
447
|
end
|
|
335
448
|
|
|
449
|
+
# The shared store's keys are derived once from these credentials, so the
|
|
450
|
+
# caller must not be able to change them afterwards: a secret swapped
|
|
451
|
+
# later would store another account's token under this account's entry.
|
|
452
|
+
def frozen_copy(value)
|
|
453
|
+
value.is_a?(String) ? value.dup.freeze : value
|
|
454
|
+
end
|
|
455
|
+
|
|
336
456
|
def configure_retry_policy(configuration)
|
|
337
457
|
@timeout = configuration.timeout
|
|
338
458
|
@max_retries = configuration.max_retries
|
|
@@ -28,6 +28,12 @@ module Verifiabl
|
|
|
28
28
|
raise
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Reports a failure that the SDK recovered from, such as a token store
|
|
32
|
+
# outage, so it is visible without changing request behaviour.
|
|
33
|
+
def emit_error(payload, on_error)
|
|
34
|
+
emit(:error, payload, on_error)
|
|
35
|
+
end
|
|
36
|
+
|
|
31
37
|
def emit(type, payload, callback)
|
|
32
38
|
event = payload.freeze
|
|
33
39
|
begin
|
|
@@ -12,9 +12,11 @@ module Verifiabl
|
|
|
12
12
|
|
|
13
13
|
module_function
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
# Returns the payload with Symbol codes as Strings, so later checks see what JSON encoding sends.
|
|
16
|
+
def to_wire!(schema, payload)
|
|
17
|
+
wire = convert!(FIELDS.fetch(schema), payload, "payslip_non_pii")
|
|
18
|
+
check_lump_sum_types!(wire) if schema == AUSTRALIAN_PAYSLIP_V2_SCHEMA
|
|
19
|
+
wire
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
# The API still accepts a bare lump sum from older SDKs, but a lump sum without its STP2 code
|
|
@@ -28,45 +30,48 @@ module Verifiabl
|
|
|
28
30
|
end
|
|
29
31
|
private_class_method :check_lump_sum_types!
|
|
30
32
|
|
|
31
|
-
def
|
|
33
|
+
def convert!(allowed, value, path)
|
|
32
34
|
case allowed
|
|
33
|
-
when Hash then
|
|
34
|
-
when Array then
|
|
35
|
-
when "decimal" then
|
|
36
|
-
else
|
|
35
|
+
when Hash then convert_object!(allowed, value, path)
|
|
36
|
+
when Array then convert_array!(allowed.first, value, path)
|
|
37
|
+
when "decimal" then decimal!(value, path)
|
|
38
|
+
else scalar!(value, path)
|
|
37
39
|
end
|
|
38
40
|
end
|
|
39
|
-
private_class_method :
|
|
41
|
+
private_class_method :convert!
|
|
40
42
|
|
|
41
|
-
def
|
|
43
|
+
def convert_object!(allowed, value, path)
|
|
42
44
|
raise ArgumentError, "#{path} must be an object" unless value.is_a?(Hash)
|
|
43
45
|
|
|
44
|
-
value.
|
|
46
|
+
value.to_h do |key, child|
|
|
45
47
|
raise ArgumentError, "#{path}.#{key} is not permitted" unless allowed.key?(key)
|
|
46
48
|
|
|
47
|
-
|
|
49
|
+
[key, convert!(allowed.fetch(key), child, "#{path}.#{key}")]
|
|
48
50
|
end
|
|
49
51
|
end
|
|
50
|
-
private_class_method :
|
|
52
|
+
private_class_method :convert_object!
|
|
51
53
|
|
|
52
|
-
def
|
|
54
|
+
def convert_array!(allowed, value, path)
|
|
53
55
|
raise ArgumentError, "#{path} must be an array" unless value.is_a?(Array)
|
|
54
56
|
|
|
55
|
-
value.each_with_index { |child, index|
|
|
57
|
+
value.each_with_index.map { |child, index| convert!(allowed, child, "#{path}[#{index}]") }
|
|
56
58
|
end
|
|
57
|
-
private_class_method :
|
|
59
|
+
private_class_method :convert_array!
|
|
58
60
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
# Amounts stay Strings only, so the SDK never guesses at a number's text.
|
|
62
|
+
def decimal!(value, path)
|
|
63
|
+
return value if value.is_a?(String) && DECIMAL_PATTERN.match?(value)
|
|
61
64
|
|
|
62
65
|
raise ArgumentError, "#{path} must be a plain decimal String, for example \"1234.56\""
|
|
63
66
|
end
|
|
64
|
-
private_class_method :
|
|
67
|
+
private_class_method :decimal!
|
|
65
68
|
|
|
66
|
-
def
|
|
69
|
+
def scalar!(value, path)
|
|
67
70
|
raise ArgumentError, "#{path} must be a scalar" if value.is_a?(Hash) || value.is_a?(Array)
|
|
71
|
+
|
|
72
|
+
value.is_a?(Symbol) ? value.to_s : value
|
|
68
73
|
end
|
|
69
|
-
private_class_method :
|
|
74
|
+
private_class_method :scalar!
|
|
70
75
|
end
|
|
71
76
|
end
|
|
72
77
|
end
|
|
@@ -12,7 +12,10 @@ module Verifiabl
|
|
|
12
12
|
def registration_to_wire(schema:, issued_at:, payslip_non_pii:, encryption_metadata:)
|
|
13
13
|
Validation.registration!(schema: schema, issued_at: issued_at, payslip_non_pii: payslip_non_pii, encryption_metadata: encryption_metadata)
|
|
14
14
|
wire_payslip = stringify_keys(payslip_non_pii)
|
|
15
|
-
|
|
15
|
+
if [AUSTRALIAN_PAYSLIP_V2_SCHEMA, NEW_ZEALAND_PAYSLIP_V2_SCHEMA].include?(schema)
|
|
16
|
+
wire_payslip = NonPiiV2.to_wire!(schema, wire_payslip)
|
|
17
|
+
Validation.v2_currency!(wire_payslip)
|
|
18
|
+
end
|
|
16
19
|
{
|
|
17
20
|
"schema" => schema,
|
|
18
21
|
"issued_at" => serialize_time(issued_at),
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Verifiabl
|
|
8
|
+
module Issuer
|
|
9
|
+
# Keeps OAuth tokens in an application cache that several processes
|
|
10
|
+
# share, such as a Redis-backed cache, so a fleet of job workers reuses
|
|
11
|
+
# one token instead of each worker fetching its own.
|
|
12
|
+
#
|
|
13
|
+
# The backend sees only an opaque key and AES-256-GCM ciphertext. Both
|
|
14
|
+
# are derived from the client secret and the token's identity (token
|
|
15
|
+
# URL, client ID, audience and scope): reading the cache without the
|
|
16
|
+
# secret reveals no token, and the secret alone can already mint one.
|
|
17
|
+
#
|
|
18
|
+
# Backend failures and unreadable entries count as a miss and are
|
|
19
|
+
# reported through `on_error`, so a cache outage costs a token fetch
|
|
20
|
+
# rather than blocking registration.
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
class SharedTokenStore
|
|
24
|
+
FORMAT = "v1"
|
|
25
|
+
KEY_PREFIX = "verifiabl-issuer:token:#{FORMAT}:"
|
|
26
|
+
VALUE_PREFIX = "#{FORMAT}:"
|
|
27
|
+
KEY_BYTES = 32
|
|
28
|
+
IV_BYTES = 12
|
|
29
|
+
TAG_BYTES = 16
|
|
30
|
+
BACKEND_METHODS = %i[read write].freeze
|
|
31
|
+
FETCH_CLAIM_SUFFIX = ":fetch"
|
|
32
|
+
|
|
33
|
+
def self.valid_backend?(backend)
|
|
34
|
+
BACKEND_METHODS.all? { |name| backend.respond_to?(name) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param identity [Array] everything besides the secret that selects a
|
|
38
|
+
# token: token URL, client ID, audience and scope.
|
|
39
|
+
# @param on_error [#call, nil] receives `{token_store:, error:}` when
|
|
40
|
+
# the backend raises; the failure never changes request behaviour.
|
|
41
|
+
def initialize(backend, client_secret:, identity:, on_error: nil)
|
|
42
|
+
@backend = backend
|
|
43
|
+
@on_error = on_error
|
|
44
|
+
identity_json = JSON.generate(identity)
|
|
45
|
+
@encryption_key = derive_key(client_secret, "encryption", identity_json)
|
|
46
|
+
lookup_key = derive_key(client_secret, "lookup", identity_json)
|
|
47
|
+
@key = KEY_PREFIX + OpenSSL::HMAC.hexdigest("SHA256", lookup_key, identity_json)
|
|
48
|
+
@fetch_claim_key = @key + FETCH_CLAIM_SUFFIX
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns the stored token, or nil when there is none that is unexpired.
|
|
52
|
+
def read(now)
|
|
53
|
+
observe(:read) { decode(read_backend(@key), now) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def write(token, now)
|
|
57
|
+
seconds_left = (token.expires_at - now).floor
|
|
58
|
+
return unless seconds_left.positive?
|
|
59
|
+
|
|
60
|
+
observe(:write) { @backend.write(@key, encode(token), expires_in: seconds_left) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Claims the next token fetch for `seconds`. Returns `false` only when a
|
|
64
|
+
# claim exists. The claim expires instead of being deleted, so it never
|
|
65
|
+
# removes a later process's claim.
|
|
66
|
+
def claim_fetch(seconds)
|
|
67
|
+
observe(:claim) { @backend.write(@fetch_claim_key, "1", expires_in: seconds, unless_exist: true) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# True while a process holds the claim. A store that swallows a backend
|
|
71
|
+
# error answers `nil`, so an outage never makes a process wait.
|
|
72
|
+
def fetch_claim_held?
|
|
73
|
+
observe(:claim) { read_backend(@fetch_claim_key) } == "1"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# In a Rails request, Active Support stores answer repeat reads from a
|
|
79
|
+
# request-local cache, so a poll would see only its first miss. A fresh
|
|
80
|
+
# local cache for each read sends it to the backend.
|
|
81
|
+
def read_backend(key)
|
|
82
|
+
return @backend.read(key) unless @backend.respond_to?(:with_local_cache)
|
|
83
|
+
|
|
84
|
+
@backend.with_local_cache { @backend.read(key) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The backend is the application's cache, so its failures and any
|
|
88
|
+
# unreadable entry count as a miss. The report lets an operator see a
|
|
89
|
+
# store that is misconfigured or down, which would otherwise look like
|
|
90
|
+
# the store doing nothing.
|
|
91
|
+
def observe(operation)
|
|
92
|
+
yield
|
|
93
|
+
rescue => error
|
|
94
|
+
Instrumentation.emit_error({token_store: operation, error: error}, @on_error)
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def derive_key(secret, purpose, identity_json)
|
|
99
|
+
OpenSSL::KDF.hkdf(
|
|
100
|
+
secret,
|
|
101
|
+
salt: "",
|
|
102
|
+
info: "verifiabl-issuer token store #{FORMAT} #{purpose} #{identity_json}",
|
|
103
|
+
length: KEY_BYTES,
|
|
104
|
+
hash: "SHA256"
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def encode(token)
|
|
109
|
+
payload = JSON.generate(
|
|
110
|
+
"access_token" => token.value,
|
|
111
|
+
"issued_at" => token.issued_at.to_f,
|
|
112
|
+
"expires_at" => token.expires_at.to_f
|
|
113
|
+
)
|
|
114
|
+
iv = SecureRandom.random_bytes(IV_BYTES)
|
|
115
|
+
cipher = start_cipher(:encrypt, iv)
|
|
116
|
+
ciphertext = cipher.update(payload) + cipher.final
|
|
117
|
+
VALUE_PREFIX + [iv + ciphertext + cipher.auth_tag].pack("m0")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def decode(value, now)
|
|
121
|
+
return unless value.is_a?(String) && value.start_with?(VALUE_PREFIX)
|
|
122
|
+
|
|
123
|
+
bytes = value.delete_prefix(VALUE_PREFIX).unpack1("m0")
|
|
124
|
+
return if bytes.bytesize <= IV_BYTES + TAG_BYTES
|
|
125
|
+
|
|
126
|
+
token_from(JSON.parse(decrypt(bytes)), now)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The key is authenticated with the ciphertext, so an entry copied to
|
|
130
|
+
# another credential's key fails to decrypt instead of being served.
|
|
131
|
+
def decrypt(bytes)
|
|
132
|
+
cipher = start_cipher(:decrypt, bytes.byteslice(0, IV_BYTES))
|
|
133
|
+
cipher.auth_tag = bytes.byteslice(-TAG_BYTES, TAG_BYTES)
|
|
134
|
+
ciphertext = bytes.byteslice(IV_BYTES, bytes.bytesize - IV_BYTES - TAG_BYTES)
|
|
135
|
+
cipher.update(ciphertext) + cipher.final
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def start_cipher(direction, iv)
|
|
139
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm").public_send(direction)
|
|
140
|
+
cipher.key = @encryption_key
|
|
141
|
+
cipher.iv = iv
|
|
142
|
+
cipher.auth_data = @key
|
|
143
|
+
cipher
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def token_from(payload, now)
|
|
147
|
+
value = payload["access_token"]
|
|
148
|
+
issued_at = payload["issued_at"]
|
|
149
|
+
expires_at = payload["expires_at"]
|
|
150
|
+
return unless value.is_a?(String) && !value.empty?
|
|
151
|
+
return unless issued_at.is_a?(Numeric) && expires_at.is_a?(Numeric)
|
|
152
|
+
return unless issued_at < expires_at && Time.at(expires_at) > now
|
|
153
|
+
|
|
154
|
+
Client::CachedToken.new(value: value, issued_at: Time.at(issued_at), expires_at: Time.at(expires_at))
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -14,7 +14,7 @@ module Verifiabl
|
|
|
14
14
|
|
|
15
15
|
def registration!(schema:, issued_at:, payslip_non_pii:, encryption_metadata:)
|
|
16
16
|
registration_envelope!(schema:, issued_at:, encryption_metadata:)
|
|
17
|
-
validate_payslip_non_pii!(
|
|
17
|
+
validate_payslip_non_pii!(payslip_non_pii)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def registration_envelope!(schema:, issued_at:, encryption_metadata:)
|
|
@@ -55,20 +55,19 @@ module Verifiabl
|
|
|
55
55
|
end
|
|
56
56
|
private_class_method :validate_encryption_metadata!
|
|
57
57
|
|
|
58
|
-
def validate_payslip_non_pii!(
|
|
58
|
+
def validate_payslip_non_pii!(payslip_non_pii)
|
|
59
59
|
raise ArgumentError, "payslip_non_pii must be a Hash" unless payslip_non_pii.is_a?(Hash)
|
|
60
60
|
validate_iso_date!(value(payslip_non_pii, :period_start), "period_start") if key?(payslip_non_pii, :period_start)
|
|
61
61
|
validate_iso_date!(value(payslip_non_pii, :period_end), "period_end") if key?(payslip_non_pii, :period_end)
|
|
62
|
-
validate_v2_currency!(payslip_non_pii) if [AUSTRALIAN_PAYSLIP_V2_SCHEMA, NEW_ZEALAND_PAYSLIP_V2_SCHEMA].include?(schema)
|
|
63
62
|
end
|
|
64
63
|
private_class_method :validate_payslip_non_pii!
|
|
65
64
|
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
# Takes the wire payslip, so a Symbol code has already become a String.
|
|
66
|
+
def v2_currency!(wire_payslip)
|
|
67
|
+
return if SUPPORTED_V2_CURRENCIES.include?(wire_payslip["currency"])
|
|
68
68
|
|
|
69
69
|
raise ArgumentError, "currency is required and must be a supported ISO 4217 currency code, for example AUD"
|
|
70
70
|
end
|
|
71
|
-
private_class_method :validate_v2_currency!
|
|
72
71
|
|
|
73
72
|
def validate_issued_at!(issued_at)
|
|
74
73
|
return if issued_at.is_a?(Time)
|
data/lib/verifiabl/issuer.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "issuer/configuration"
|
|
|
5
5
|
require_relative "issuer/errors"
|
|
6
6
|
require_relative "issuer/validation"
|
|
7
7
|
require_relative "issuer/responses"
|
|
8
|
+
require_relative "issuer/shared_token_store"
|
|
8
9
|
require_relative "issuer/client"
|
|
9
10
|
require_relative "issuer/crypto"
|
|
10
11
|
require_relative "issuer/instrumentation"
|
data/sig/verifiabl-issuer.rbs
CHANGED
|
@@ -89,7 +89,13 @@ module Verifiabl
|
|
|
89
89
|
attr_reader on_response: Proc?
|
|
90
90
|
attr_reader on_error: Proc?
|
|
91
91
|
attr_reader environment: Symbol
|
|
92
|
-
|
|
92
|
+
attr_reader token_store: _TokenStore?
|
|
93
|
+
def initialize: (?client_id: String?, ?client_secret: String?, ?environment: Symbol | String, ?issuer_base_url: String?, ?token_url: String?, ?timeout: Numeric, ?max_retries: Integer, ?on_request: Proc?, ?on_response: Proc?, ?on_error: Proc?, ?token_store: _TokenStore?) -> void
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
interface _TokenStore
|
|
97
|
+
def read: (String) -> untyped
|
|
98
|
+
def write: (String, String, expires_in: Integer, ?unless_exist: bool) -> untyped
|
|
93
99
|
end
|
|
94
100
|
|
|
95
101
|
interface _TransportResponse
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: verifiabl-issuer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Verifiabl
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|
|
@@ -38,6 +38,20 @@ dependencies:
|
|
|
38
38
|
- - "~>"
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '2.1'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: activesupport
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '8.1'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '8.1'
|
|
41
55
|
- !ruby/object:Gem::Dependency
|
|
42
56
|
name: bigdecimal
|
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -108,6 +122,20 @@ dependencies:
|
|
|
108
122
|
- - "~>"
|
|
109
123
|
- !ruby/object:Gem::Version
|
|
110
124
|
version: '4.2'
|
|
125
|
+
- !ruby/object:Gem::Dependency
|
|
126
|
+
name: redis
|
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
|
128
|
+
requirements:
|
|
129
|
+
- - "~>"
|
|
130
|
+
- !ruby/object:Gem::Version
|
|
131
|
+
version: '6.0'
|
|
132
|
+
type: :development
|
|
133
|
+
prerelease: false
|
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
135
|
+
requirements:
|
|
136
|
+
- - "~>"
|
|
137
|
+
- !ruby/object:Gem::Version
|
|
138
|
+
version: '6.0'
|
|
111
139
|
- !ruby/object:Gem::Dependency
|
|
112
140
|
name: rubocop
|
|
113
141
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -196,6 +224,7 @@ files:
|
|
|
196
224
|
- lib/verifiabl/issuer/rendering_assets.rb
|
|
197
225
|
- lib/verifiabl/issuer/responses.rb
|
|
198
226
|
- lib/verifiabl/issuer/serialization.rb
|
|
227
|
+
- lib/verifiabl/issuer/shared_token_store.rb
|
|
199
228
|
- lib/verifiabl/issuer/validation.rb
|
|
200
229
|
- lib/verifiabl/issuer/version.rb
|
|
201
230
|
- sig/manifest.yaml
|