verifiabl-issuer 1.0.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7c622f96124e2196fcc336d8295d30fef83a2efc296890d841aec4ef8376e50
4
- data.tar.gz: c0e18b541a27073c6026703acd7e8eeae0b1ce6867f55f8d4847451b9eaef812
3
+ metadata.gz: 369e3ae173f2115422efb4a81d7cd32b018e8cd15e291899e699d51048eeca4e
4
+ data.tar.gz: b058e79cd9791e2f17966455129d8df40e9869b3f550ff05906ad5bae98aea5f
5
5
  SHA512:
6
- metadata.gz: d36cfeaf56c836a4cd99d5786697668664b9003bf15c3689a50034bb1e9285aa93675ace858eff1cab65b6033ee64bffb3656290a61bd9c11e643881aa83918e
7
- data.tar.gz: dd8bd433cd88d3ab01373608eab57476afbb2770d922a4c0f22ea238dd8d0d2bd62a154a8aa30699785c68b3e8efb7a24dd0aaf93a9d2ae3b159cfeb9408bc64
6
+ metadata.gz: c2996561b125072f3d8a4fd9461d9164bf5689fc3773d3242e946d844351cb14fde3c0a39893ec50b74696b59cd6c880ce1917fedbe4a1da49d0f9642e11b22b
7
+ data.tar.gz: 719979e79cbc3853cc52d002d45757197d41dfc33fcd398dd1b125c25153b2c07a7e56a9558149c51c5e8c6a45c14cb9722c8a11e301695ba9fc2c7026830594
data/CHANGELOG.md CHANGED
@@ -4,6 +4,27 @@ 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
+
7
28
  ## [1.0.1] - 2026-10-20
8
29
 
9
30
  ### Changed
data/README.md CHANGED
@@ -1,85 +1,108 @@
1
- # Verifiabl Ruby SDK
1
+ # verifiabl-issuer
2
2
 
3
- Official Ruby SDK for issuing Verifiabl payslip QR codes. It includes offline protocol primitives,
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
- ## Installation
5
+ ## Documentation
8
6
 
9
- Add the stable release to your bundle:
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
- ```ruby
12
- gem "verifiabl-issuer", "1.0.1"
13
- ```
10
+ ## Requirements
14
11
 
15
- Then run `bundle install`.
12
+ Ruby 3.3 or newer.
16
13
 
17
- Bundler loads the gem through its package-name entry point:
14
+ ## Installation
18
15
 
19
- ```ruby
20
- require "verifiabl-issuer"
16
+ ```sh
17
+ gem install verifiabl-issuer
21
18
  ```
22
19
 
23
- That entry point is a compatibility shim and loads the canonical namespaced entry point. Applications
24
- that require dependencies explicitly can use this path instead:
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
- ```ruby
40
- issuer = Verifiabl::Issuer::Client.new(
41
- Verifiabl::Issuer::Configuration.new(
42
- environment: :sandbox,
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
- The SDK is framework-neutral: it does not load Rails or Active Support, install framework hooks, or
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
- The default timeout budget is 30 seconds across OAuth, token-lock waits, 401 refresh, retries,
59
- and backoff. Lock waits are bounded, and the budget is checked between operations. The built-in
60
- Net::HTTP transport applies the remaining budget to open, read, and write timeouts, but those are
61
- per-I/O inactivity timeouts, **not a hard wall-clock limit**: DNS or a response that keeps sending
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
- timeout: 10,
76
- max_retries: 2
46
+ token_store: Rails.cache
77
47
  )
78
- issuer = Verifiabl::Issuer::Client.new(configuration)
79
48
  ```
80
49
 
81
- Endpoint overrides are intended only for development. Issuer overrides require HTTPS except for
82
- loopback HTTP. OAuth overrides are restricted to Verifiabl auth hosts or loopback addresses.
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. Return an object with integer `status`,
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,396 +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
- ## Issue payslips
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`. A code may
229
- also be a Symbol, such as `type: :lump_sum`; the gem sends it as a String.
230
- Printed-text fields such as `tax_code` and `pay_cycle` are not code sets.
231
-
232
- ### Self-managed barcode flow
233
-
234
- Use this flow when your application renders the barcode. This AU2 example is generated from
235
- the runnable
236
- [`examples/issuer/basic/issue_payslips_self_managed.rb`](../../examples/issuer/basic/issue_payslips_self_managed.rb)
237
- source.
238
-
239
- <!-- snippet:ruby.basic-issuance:start -->
240
-
241
- ```ruby
242
- require "base64"
243
- require "fileutils"
244
- require "verifiabl/issuer"
245
-
246
- PAYSLIP = {
247
- external_id: "PAY-1001",
248
- pii: {
249
- employee_name: "Ana Leota",
250
- position: "Senior Developer",
251
- department: "Engineering",
252
- employer_name: "Example Software Pty Ltd",
253
- employer_abn: "12 345 678 901",
254
- bsb: "062-000",
255
- account_number: "****5678",
256
- account_name: "A Leota",
257
- address: {lines: ["12 Example St"], suburb: "Sydney", state_or_territory: "NSW", postcode: "2000"}
258
- },
259
- non_pii: {
260
- period_end: "2026-08-31",
261
- payment_date: "2026-09-04",
262
- currency: "AUD",
263
- gross: "9000.00",
264
- paygw: "2250.00",
265
- net: "6750.00"
266
- }
267
- }.freeze
268
-
269
- issuer = Verifiabl::Issuer::Client.new(
270
- Verifiabl::Issuer::Configuration.new(
271
- environment: :sandbox,
272
- client_id: ENV.fetch("VERIFIABL_CLIENT_ID"),
273
- client_secret: ENV.fetch("VERIFIABL_CLIENT_SECRET")
274
- )
275
- )
276
-
277
- provider_encryption_key = Base64.strict_decode64(
278
- ENV.fetch("VERIFIABL_ENCRYPTION_KEY_BASE64")
279
- )
280
- prepared = Verifiabl::Issuer.prepare_australian_v2_payslip(
281
- pii: PAYSLIP.fetch(:pii),
282
- payslip_non_pii: PAYSLIP.fetch(:non_pii),
283
- issued_at: Time.now.utc,
284
- key: provider_encryption_key
285
- )
286
- # Persist prepared.registration and the binary ciphertext from
287
- # prepared.barcode_parts(prepared.verifiabl_reference).fetch(:encrypted_pii)
288
- # atomically before sending. Reuse both after a process restart.
289
- result = issuer.register_non_pii(**prepared.registration)
290
-
291
- barcode = Verifiabl::Issuer.build_barcode_svg(
292
- **prepared.barcode_parts(result.verifiabl_reference),
293
- environment: :sandbox
294
- )
295
-
296
- output_path = File.expand_path("output/self-managed-barcode.svg", __dir__)
297
- FileUtils.mkdir_p(File.dirname(output_path))
298
-
299
- File.write(output_path, barcode.svg)
300
- puts "Registered #{result.verifiabl_reference} and wrote #{output_path}"
301
- ```
302
-
303
- <!-- snippet:ruby.basic-issuance:end -->
304
-
305
- `register_non_pii` sends the non-PII fields and encryption metadata, but not the encrypted PII. The
306
- application combines the returned reference with the encrypted PII to render the SVG locally.
307
-
308
- ### API-managed barcode flow
309
-
310
- Use this alternative when Verifiabl should render the barcode PNG. This NZ2 example is
311
- generated from the runnable
312
- [`examples/issuer/basic/issue_payslips_api_managed.rb`](../../examples/issuer/basic/issue_payslips_api_managed.rb)
313
- source.
314
-
315
- <!-- snippet:ruby.api-managed-issuance:start -->
316
-
317
- ```ruby
318
- require "base64"
319
- require "fileutils"
320
- require "verifiabl/issuer"
321
-
322
- PAYSLIP = {
323
- external_id: "PAY-1002",
324
- pii: {
325
- employee_name: "Ana Leota",
326
- ird_number: "***-***-***",
327
- position: "Senior Developer",
328
- department: "Engineering",
329
- employer_name: "Example Software Limited",
330
- account_number: "**-****-*******-**",
331
- account_name: "A Leota",
332
- address: {lines: ["12 Example Street"], suburb: "Te Aro", city: "Wellington", postcode: "6011"}
333
- },
334
- non_pii: {
335
- period_end: "2026-08-31",
336
- payment_date: "2026-09-04",
337
- currency: "NZD",
338
- gross: "9000.00",
339
- paye: "2283.96",
340
- net: "6716.04"
341
- }
342
- }.freeze
343
-
344
- issuer = Verifiabl::Issuer::Client.new(
345
- Verifiabl::Issuer::Configuration.new(
346
- environment: :sandbox,
347
- client_id: ENV.fetch("VERIFIABL_CLIENT_ID"),
348
- client_secret: ENV.fetch("VERIFIABL_CLIENT_SECRET")
349
- )
350
- )
351
-
352
- provider_encryption_key = Base64.strict_decode64(
353
- ENV.fetch("VERIFIABL_ENCRYPTION_KEY_BASE64")
354
- )
355
- prepared = Verifiabl::Issuer.prepare_new_zealand_v2_payslip(
356
- pii: PAYSLIP.fetch(:pii),
357
- payslip_non_pii: PAYSLIP.fetch(:non_pii),
358
- issued_at: Time.now.utc,
359
- key: provider_encryption_key
360
- )
361
- # API-managed registration allocates its own reference. Do not replay an
362
- # ambiguous failure as if prepared.verifiabl_reference were its idempotency key.
363
- result = issuer.register_and_build_barcode(**prepared.api_managed_registration)
364
-
365
- output_path = File.expand_path("output/api-managed-barcode.png", __dir__)
366
- FileUtils.mkdir_p(File.dirname(output_path))
367
-
368
- File.binwrite(output_path, Base64.strict_decode64(result.barcode.data))
369
- puts "Registered #{result.verifiabl_reference} and wrote #{output_path}"
370
- ```
371
-
372
- <!-- snippet:ruby.api-managed-issuance:end -->
373
-
374
- `register_and_build_barcode` sends the encrypted PII in addition to the non-PII fields and encryption
375
- metadata. Verifiabl uses the ciphertext to render the returned PNG and does not retain it. Plaintext
376
- PII is never sent in either flow.
377
-
378
- ### Retries and batches
379
-
380
- For a self-managed registration that must remain retryable across process
381
- restarts, persist `prepared.registration` and the binary ciphertext from
382
- `prepared.barcode_parts(prepared.verifiabl_reference).fetch(:encrypted_pii)`
383
- together before the first call. The registration includes the reference, IV and
384
- tag but not the ciphertext. Reuse the *same* registration for a later attempt,
385
- then render from the saved ciphertext and the returned reference. You can pass `verifiabl_reference:` to the preparation helper if your
386
- system already allocated one. Do not prepare and encrypt again for an
387
- idempotent replay. The API-managed request does not include this reference.
388
-
389
- For pay runs, prepare each record with the matching jurisdiction helper. Pass
390
- its `registration` hash to batch registration with an optional `external_id`.
391
- Use each prepared result's `barcode_parts` with the corresponding batch result:
392
-
393
- ```ruby
394
- prepared = payslips.map do |payslip|
395
- if payslip.fetch(:country) == "AU"
396
- Verifiabl::Issuer.prepare_australian_v2_payslip(
397
- pii: payslip.fetch(:pii), payslip_non_pii: payslip.fetch(:non_pii),
398
- issued_at: Time.now.utc, key: provider_encryption_key
399
- )
400
- else
401
- Verifiabl::Issuer.prepare_new_zealand_v2_payslip(
402
- pii: payslip.fetch(:pii), payslip_non_pii: payslip.fetch(:non_pii),
403
- issued_at: Time.now.utc, key: provider_encryption_key
404
- )
405
- end
406
- end
407
- # Persist each prepared reference, registration, and ciphertext before sending.
408
- batch = issuer.register_non_pii_batch(prepared.each_with_index.map do |item, index|
409
- item.registration.merge(external_id: payslips.fetch(index).fetch(:external_id))
410
- end)
411
- batch.results.each_with_index do |item, index|
412
- if %w[created duplicate].include?(item.status)
413
- parts = prepared.fetch(index).barcode_parts(item.verifiabl_reference)
414
- # Render this record's barcode from parts.
415
- else
416
- # Handle item.code; do not parse item.detail.
417
- end
418
- end
419
- ```
420
-
421
- The API returns `201` for the first registration and `200` for an identical replay. Reusing a
422
- reference with different content raises `ApiError` with code `CONFLICT`.
423
-
424
- Successful calls return immutable typed result objects under `Verifiabl::Issuer::Responses`.
425
- Malformed successful API responses raise `TransportError`. Issuer HTTP errors raise `ApiError` (or
426
- `IvReuseError` for `IV_REUSED`). Non-timeout OAuth connectivity failures and invalid or unsuccessful
427
- token responses raise `AuthError`. Deadline expiry raises `TimeoutError`, while issuer network
428
- failures raise `TransportError`. Request and error messages never include the submitted payslip body.
429
-
430
- ## Offline protocol primitives
431
-
432
- Most integrations should use the v2 preparation helpers. To select the schema and formatter
433
- yourself, format and encrypt PII locally with `format_australian_pii` (AU2, for `au.payslip.v2`) or
434
- `format_new_zealand_pii` (NZ2, for `nz.payslip.v2`). The plaintext must never be logged or sent to
435
- Verifiabl:
436
-
437
- ```ruby
438
- plaintext = Verifiabl::Issuer.format_australian_pii(
439
- employee_name: "Ana Leota",
440
- employer_abn: "53004085616",
441
- address: {lines: ["12 Example St"], suburb: "Sydney", state_or_territory: "NSW", postcode: "2000"}
442
- )
443
-
444
- encrypted = Verifiabl::Issuer.encrypt_pii(plaintext, provider_encryption_key)
445
- reference = Verifiabl::Issuer.generate_verifiabl_reference
157
+ ## Support
446
158
 
447
- scan_url = Verifiabl::Issuer.build_scan_url(
448
- verifiabl_reference: reference,
449
- encrypted_pii: encrypted.encrypted_pii,
450
- environment: :sandbox
451
- )
452
-
453
- xmp_payload = Verifiabl::Issuer.build_barcode_payload(
454
- verifiabl_reference: reference,
455
- encrypted_pii: encrypted.encrypted_pii
456
- )
457
-
458
- svg = Verifiabl::Issuer.build_barcode_svg(
459
- verifiabl_reference: reference,
460
- encrypted_pii: encrypted.encrypted_pii,
461
- environment: :sandbox,
462
- width: 480
463
- )
464
- File.write("barcode.svg", svg.svg)
465
-
466
- png = Verifiabl::Issuer.build_barcode_png(
467
- verifiabl_reference: reference,
468
- encrypted_pii: encrypted.encrypted_pii,
469
- environment: :sandbox,
470
- width: 720
471
- )
472
- File.binwrite("barcode.png", png.png)
473
- ```
474
-
475
- The renderers use the same official vector frame, rounded finder geometry, and QR error-correction
476
- ladder as the Node and .NET SDKs. Vertical is the default; pass `layout: :horizontal` to put the
477
- QR on the left at full badge height, followed by a 7-unit white gap and the light-tinted
478
- "Secured by Verifiabl" panel on the right. Horizontal rendering is included in `0.4.0`.
479
-
480
- | Layout | SVG minimum/default width | PNG widths | PNG default width |
481
- | --- | ---: | --- | ---: |
482
- | `:vertical` | 480 | 480, 720, 960, 1440 | 720 |
483
- | `:horizontal` | 940 | 940, 1410, 1880, 2820 | 1410 |
484
-
485
- SVG widths scale continuously above the minimum. PNG frames are pre-rasterised for deterministic
486
- cross-SDK output and support only the listed widths. Both layouts render the QR at the same sizes
487
- at corresponding widths; the horizontal badge's total width includes its brand panel and gap.
488
- For example, `build_barcode_svg(**parts, layout: :horizontal)` returns a 940×480 SVG and
489
- `build_barcode_png(**parts, layout: :horizontal)` returns a 1410×720 PNG.
490
-
491
- ### Badge placement
492
-
493
- Place the badge on a light background with a clear margin of at least four QR modules
494
- (`result.module_px` at the rendered size) on every open QR side: **left, right and bottom** for
495
- vertical; **left, top and bottom** for horizontal. A tenth of the QR side covers realistic records.
496
- The host document supplies these quiet zones; the badge supplies only the gap facing the brand
497
- frame. Size the QR, not the whole horizontal badge: a 22 mm QR means a horizontal badge about
498
- 43.08 mm wide and 22 mm high.
499
-
500
- Use `max_error_correction: :q` for greater damage recovery; the default is
501
- `:m`, and the renderer steps down only when necessary to preserve the three-pixel module floor.
502
- Each result's `degraded` field (`svg.degraded` or `png.degraded` above) is true when the renderer
503
- steps down from the requested ceiling or the modules fall below the ideal four-pixel size.
504
-
505
- `provider_encryption_key` must be exactly 32 bytes and loaded from a KMS or secrets manager. Every
506
- encryption call creates a fresh AES-256-GCM IV. Ciphertext, IV, and authentication tags are exposed
507
- as binary Ruby strings (`Encoding::BINARY`) and can be persisted directly in binary database
508
- columns. The SDK encodes them only at an external boundary: base64url for issuer API requests and
509
- uppercase, unpadded RFC 4648 Base32 for v2 barcode and XMP output. PDF integrations should store `xmp_payload` under
510
- XMP namespace `https://verifiabl.io/ns/`, property `payload`. QR v2 is encoded as an explicit byte
511
- prefix and alphanumeric ciphertext segment. Matrix version, mask, and modules match the Node and
512
- .NET SDKs.
513
-
514
- ## Development
515
-
516
- ```sh
517
- bundle install
518
- bundle exec rake
519
- (cd gems/verifiabl-issuer && bundle exec gem build verifiabl-issuer.gemspec)
520
- bundle exec ruby script/packed_gem_check.rb
521
- ```
159
+ Report issues at [github.com/Verifiabl/verifiabl-ruby](https://github.com/Verifiabl/verifiabl-ruby/issues).
522
160
 
523
161
  ## License
524
162
 
525
- [MIT](./LICENSE)
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 a provider-generated reference. When the
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
- def access_token(deadline)
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.value if cached && token_fresh?(cached, now)
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
- now = @clock.call
178
- return @token.value if @token && token_fresh?(@token, now)
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
- @token = fetch_token(now, deadline)
181
- @token.value
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(method: :post, url: @token_url, headers: json_headers, body: token_request_body, deadline: deadline)
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
@@ -15,7 +15,8 @@ module Verifiabl
15
15
  max_retries: 2,
16
16
  on_request: nil,
17
17
  on_response: nil,
18
- on_error: nil
18
+ on_error: nil,
19
+ token_store: nil
19
20
  }.freeze
20
21
  private_constant :DEFAULTS
21
22
 
@@ -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
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Verifiabl
4
4
  module Issuer
5
- VERSION = "1.0.1"
5
+ VERSION = "1.1.0"
6
6
  end
7
7
  end
@@ -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"
@@ -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
- 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?) -> void
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.0.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-20 00:00:00.000000000 Z
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