assinafy 1.8.1 → 1.10.1

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: de868e0ed87ada5a4456c4a811a1aa8057a6c482b344944661a0a4670ab86057
4
- data.tar.gz: cef6d34ac264a694a194ca46ec35810836d0660001fcea5d351a1bbfff1dc638
3
+ metadata.gz: d8a12972c20d3f42452948edca772428326d37eafaf2354286bd9d3285bb6bf2
4
+ data.tar.gz: ad4b481584b05d052d7f44cde2a0cc7c99873e127031c30c60662c0a95dbca40
5
5
  SHA512:
6
- metadata.gz: 24fff2f9be29fbcf69788f6f217f541c9d68ba22c10ec6dbb2f557e2c9c3d827fe57004294d6c8af7cc1c38dd6e8ca2ff12bb826995a97ea7a111eae8589dac4
7
- data.tar.gz: 6e7ffbaa337b9480c118bd1d5ef2d3423bba3622e333a5b6c11b20ed594df78cc03772e7243ff2ca919f272aab8efdf439db3bb06d3f9180553c8388e7f0753b
6
+ metadata.gz: 8990224d37bac65328f95e7bdae28c999381f33fa64f1cfe300f3fffc124e8e98ab4e8e8473802534b1155ec06fc5c8080ffffb8a809118abf8d58cc661ad6a7
7
+ data.tar.gz: 35be652f65fa20a29cef34f3487f4af98ac9e94d613ec51c816b24ee879ed436b4c168aaeb51f32fef81864a9b7e54824812fa713693b2e7235e424ae407f085
data/CHANGELOG.md CHANGED
@@ -2,6 +2,39 @@
2
2
 
3
3
  All notable changes to the `assinafy` Ruby gem are documented here.
4
4
 
5
+ ## 1.10.1
6
+
7
+ - Update the pinned Ruby setup action to support Ruby 4.0.7 in CI and release workflows.
8
+
9
+ ## 1.10.0
10
+
11
+ - Support the RFC 8693 token-exchange grant for Assinafy-provisioned internal service clients.
12
+ - Validate OAuth grant parameters, PKCE challenges, authorization URLs, and token response shapes.
13
+ - Validate assignment deadlines, notification and verification method combinations, and signing steps before requests.
14
+ - Validate template role and signer IDs, distinct role assignments, and editor field payloads before document creation.
15
+ - Validate contact addresses, scalar payload fields, decline-reason lengths, and finite polling intervals consistently.
16
+ - Expand the document workflow guides, OAuth integration instructions, and API schema reference.
17
+ - Use Ruby 4.0.7 for the development and release toolchain.
18
+
19
+ ## 1.9.0
20
+
21
+ - `OAuthResource#refresh`, and `#token` with the `refresh_token` grant, raise `Assinafy::Error` instead of
22
+ returning a success whose `refresh_token` is missing, blank, or the one sent. The token sent may already be
23
+ retired, so handle the error like `invalid_grant`.
24
+ - `OAuthResource` posts `/oauth/token` and `/oauth/revoke` bodies as `application/x-www-form-urlencoded`, as
25
+ RFC 6749 and RFC 7009 define them. The API accepts this and JSON alike; no caller change is needed.
26
+ - Every `ApiError`, not only `OAuthError`, carries the `WWW-Authenticate` challenge in
27
+ `context[:www_authenticate]`, including one raised for an error status inside a `2xx` envelope, so a `403` for
28
+ a missing OAuth scope can be told apart from one for another workspace or role.
29
+ - `DocumentResource#verify` documents the nullable `agreement_code` field, the agreement code printed on the
30
+ document certificate. `spec/fixtures/api_contract.json` tracks the current contract.
31
+ - `scripts/check_api_contract.rb` requires TLS 1.2 or newer.
32
+ - The OAuth guides cover refresh-token rotation (every refresh returns a new refresh token valid for 30 days and
33
+ retires the old one): storing the new tokens and rebuilding the client, never resending a refresh token after
34
+ an ambiguous failure, and revoking the refresh token currently stored on disconnect. They also cover checking
35
+ `state` and `iss` against the values stored for each authorization attempt (the sandbox has its own issuer)
36
+ and finding the connected workspace through `GET /accounts`.
37
+
5
38
  ## 1.8.1
6
39
 
7
40
  - The SDK's own HTTPS client now requires TLS 1.2 or newer; TLS 1.0 and 1.1 are refused. Caller-supplied clients are unchanged.
data/README.en.md CHANGED
@@ -57,6 +57,7 @@ client = Assinafy::Client.new(
57
57
  )
58
58
 
59
59
  document = client.documents.upload({ file_path: './contract.pdf' })
60
+ document = client.documents.wait_until_ready(document.fetch('id'))
60
61
  signer = client.signers.create(full_name: 'Alice Silva', email: 'alice@example.com')
61
62
 
62
63
  assignment = client.assignments.create(
@@ -72,6 +73,7 @@ puts assignment['id']
72
73
  ## Configuration
73
74
 
74
75
  ```ruby
76
+ require 'logger'
75
77
  client = Assinafy::Client.new(
76
78
  api_key: 'your-api-key',
77
79
  token: nil,
@@ -154,7 +156,7 @@ The flow is authorization-code with **mandatory PKCE**; the authorization server
154
156
  | `email` | Include the user's email and verification status in the claims |
155
157
  | `offline_access` | Be issued a refresh token |
156
158
 
157
- **1. Redirect the user.** Store the verifier and state in the session first.
159
+ **1. Redirect the user.** Store the verifier, the state, and the expected issuer in the session first.
158
160
 
159
161
  ```ruby
160
162
  verifier = Assinafy::OAuth.generate_code_verifier
@@ -162,6 +164,7 @@ state = Assinafy::OAuth.generate_state
162
164
 
163
165
  session[:assinafy_code_verifier] = verifier
164
166
  session[:assinafy_state] = state
167
+ session[:assinafy_issuer] = Assinafy::OAuth::AUTHORIZATION_SERVER # issuer of the server this attempt uses
165
168
 
166
169
  redirect_to Assinafy::OAuth.authorization_url(
167
170
  client_id: ENV.fetch('ASSINAFY_CLIENT_ID'),
@@ -173,11 +176,20 @@ redirect_to Assinafy::OAuth.authorization_url(
173
176
  ```
174
177
 
175
178
  The `code_challenge` is derived from the verifier; the verifier itself never appears in the URL.
179
+ `Assinafy::OAuth::AUTHORIZATION_SERVER` is the production issuer. The sandbox has its own: pass
180
+ `authorization_endpoint: 'https://auth-sandbox.assinafy.com.br/oauth/authorize'` and store
181
+ `https://auth-sandbox.assinafy.com.br` instead.
176
182
 
177
- **2. Exchange the code.** Compare `state` before exchanging — that is the CSRF check.
183
+ **2. Exchange the code.** Check `state` and `iss` against the values stored for this attempt before anything
184
+ else, including on an `error=` return — that is the CSRF check, and it rejects responses that are not yours. The
185
+ code is single-use and expires 60 seconds after approval: exchange it at once, and never retry.
178
186
 
179
187
  ```ruby
180
- raise 'state mismatch' unless params[:state] == session.delete(:assinafy_state)
188
+ unless params[:state] == session.delete(:assinafy_state) &&
189
+ params[:iss] == session.delete(:assinafy_issuer)
190
+ raise 'authorization response is not ours'
191
+ end
192
+ raise "authorization not granted: #{params[:error]}" if params[:error] # access_denied, invalid_scope, ...
181
193
 
182
194
  tokens = Assinafy::Client.new.oauth.exchange_code(
183
195
  code: params.fetch(:code),
@@ -189,34 +201,63 @@ tokens = Assinafy::Client.new.oauth.exchange_code(
189
201
  # 'refresh_token' => ..., 'scope' => 'documents:read documents:write' }
190
202
  ```
191
203
 
192
- **3. Act as the user.**
204
+ **3. Act as the user.** The token works only in the workspace the user picked; store its id next to the tokens.
193
205
 
194
206
  ```ruby
195
- user_client = Assinafy::Client.new(
196
- token: tokens.fetch('access_token'),
197
- account_id: ENV.fetch('ASSINAFY_ACCOUNT_ID')
198
- )
207
+ access_token = tokens.fetch('access_token')
208
+ workspace_id = Assinafy::Client.new(token: access_token).accounts.list[:data].first.fetch('id')
199
209
 
210
+ user_client = Assinafy::Client.new(token: access_token, account_id: workspace_id)
200
211
  user_client.documents.list
201
212
  user_client.oauth.userinfo # => { 'sub' => ..., 'name' => ..., 'email' => ... }
202
213
  ```
203
214
 
204
- **Refresh and revoke.**
215
+ **Refresh and revoke.** `connection` below is your stored record for this user: tokens, expiry, workspace id.
205
216
 
206
217
  ```ruby
207
- client.oauth.refresh(refresh_token: stored_refresh_token, client_id: client_id)
218
+ tokens = Assinafy::Client.new.oauth.refresh(
219
+ refresh_token: connection.refresh_token,
220
+ client_id: ENV.fetch('ASSINAFY_CLIENT_ID')
221
+ )
208
222
 
209
- client.oauth.revoke(
210
- token: stored_refresh_token, client_id: client_id, token_type_hint: 'refresh_token'
223
+ # The refresh token just sent is retired: store the new tokens before anything else.
224
+ connection.update!(
225
+ refresh_token: tokens.fetch('refresh_token'),
226
+ access_token: tokens.fetch('access_token'),
227
+ expires_at: Time.now + tokens.fetch('expires_in')
211
228
  )
229
+ user_client = Assinafy::Client.new(token: connection.access_token, account_id: connection.workspace_id)
212
230
  ```
213
231
 
214
- A refresh token exists only when `offline_access` was requested *and* consented. Revoking a refresh token also
215
- invalidates the access tokens issued from it. Every revoke outcome returns `200` — including an unknown or
216
- already-revoked token — so the endpoint cannot be used to probe whether a token exists.
232
+ When a user disconnects, revoke the refresh token in storage **now** — the latest one — then delete the stored
233
+ tokens:
217
234
 
218
- > The SDK does **not** refresh automatically. Persist `expires_in`, refresh before expiry, and treat
219
- > `invalid_grant` as a signal to restart the authorization flow.
235
+ ```ruby
236
+ Assinafy::Client.new.oauth.revoke(
237
+ token: connection.reload.refresh_token,
238
+ client_id: ENV.fetch('ASSINAFY_CLIENT_ID'),
239
+ token_type_hint: 'refresh_token'
240
+ )
241
+ connection.destroy!
242
+ ```
243
+
244
+ A refresh token exists only when `offline_access` was requested *and* consented. Revoking a refresh token also
245
+ invalidates the access tokens issued from it. Every revoke outcome returns `200` — including an unknown,
246
+ already-revoked, or rotated token — so the endpoint cannot be used to probe whether a token exists, and revoking
247
+ a stale copy can look successful while the connection stays active.
248
+
249
+ > The SDK does **not** refresh automatically. Persist `expires_in` and refresh before expiry. Every refresh
250
+ > returns a **new** refresh token, valid for another 30 days, and retires the old one, so a connection only
251
+ > expires after 30 days without a refresh. Reusing a retired refresh token ends the whole connection: store the
252
+ > new tokens before using them, and refresh one at a time per connection. `refresh` raises `Assinafy::Error`
253
+ > rather than return a success without a new refresh token; handle it like `invalid_grant`.
254
+ >
255
+ > The SDK sends each token request once. If a refresh fails without a clear answer — a timeout, a reset
256
+ > connection, a `5xx` — the server may have rotated the token without the response reaching you. Re-read the
257
+ > stored refresh token: if it is still the one you sent, **never send it again**; ask the user to connect again.
258
+ > Carry on only if another worker has since stored a different one. Only a failure that provably happened before
259
+ > the request was sent — DNS resolution, a refused connection, a failed TLS handshake — is safe to retry. On an
260
+ > API `401`, refresh once; if that fails, or on `invalid_grant`, ask the user to connect again.
220
261
 
221
262
  **Discovery**, instead of hardcoding endpoints:
222
263
 
@@ -231,6 +272,11 @@ client.oauth.authorization_server_metadata['code_challenge_methods_supported']
231
272
  `authorization_server_metadata` reaches a different host, so — like `/oauth/token` and `/oauth/revoke` — the SDK
232
273
  sends it with no workspace credentials attached.
233
274
 
275
+ **Internal service clients.** The advertised RFC 8693 token-exchange grant is restricted to
276
+ Assinafy-provisioned confidential service clients. Marketplace apps use PKCE authorization code
277
+ and refresh. `client.oauth.token` accepts token exchange with `client_secret`, `subject_token`,
278
+ `subject_token_type`, and `resource`; it issues no refresh token.
279
+
234
280
  **Errors.** OAuth routes answer with the flat RFC 6749 object rather than this API's envelope, so the SDK raises
235
281
  `Assinafy::OAuthError` (a subclass of `Assinafy::ApiError`):
236
282
 
@@ -241,6 +287,10 @@ rescue Assinafy::OAuthError => e
241
287
  e.context[:www_authenticate] # on a 403, names the missing scope
242
288
  ```
243
289
 
290
+ Any resource, not only these routes, puts that challenge in `context[:www_authenticate]`. Treat it as a prompt to
291
+ reconnect with that scope added, not as a retry; a `403` without it means another workspace, the user's role, or
292
+ an area OAuth tokens never reach.
293
+
244
294
  ### Accounts
245
295
 
246
296
  ```ruby
@@ -300,11 +350,11 @@ client.documents.append_tags('document-id', ['tag-id-3'])
300
350
  # The deployed sandbox also accepts existing tag names in these arrays.
301
351
  client.documents.detach_tag('document-id', 'tag-id')
302
352
 
303
- # Template-driven creation
353
+ # Template-driven creation (a template with one configured Signer role)
304
354
  client.documents.create_from_template(
305
355
  'template-id',
306
356
  [{ role_id: 'role', id: 'signer-id', verification_method: 'Email', notification_methods: ['Email'] }],
307
- { name: 'Contract', message: 'Please sign', expires_at: '2026-12-31T23:59:00Z' }
357
+ { name: 'Contract', message: 'Please sign', expires_at: '2099-12-31T23:59:00Z' }
308
358
  )
309
359
  client.documents.estimate_cost_from_template(
310
360
  'template-id',
@@ -358,7 +408,7 @@ client.assignments.create(
358
408
  method: 'virtual',
359
409
  signers: [{ id: 'signer-1', verification_method: 'Email', notification_methods: ['Email'], step: 1 }],
360
410
  message: 'Please sign',
361
- expires_at: '2026-12-31T23:59:00Z',
411
+ expires_at: '2099-12-31T23:59:00Z',
362
412
  copy_receivers: ['cc-signer-id']
363
413
  )
364
414
 
@@ -374,7 +424,7 @@ client.assignments.create(
374
424
 
375
425
  client.assignments.list # GET /assignments (scoped to the account)
376
426
  client.assignments.estimate_cost('document-id', signers: [{ verification_method: 'Whatsapp' }])
377
- client.assignments.reset_expiration('document-id', 'assignment-id', '2026-12-31T23:59:00Z')
427
+ client.assignments.reset_expiration('document-id', 'assignment-id', '2099-12-31T23:59:00Z')
378
428
  client.assignments.reset_expiration('document-id', 'assignment-id', nil) # clears the expiry
379
429
  client.assignments.resend_notification('document-id', 'assignment-id', 'signer-id')
380
430
  client.assignments.estimate_resend_cost('document-id', 'assignment-id', 'signer-id')
@@ -393,6 +443,11 @@ client.assignments.decline('document-id', 'assignment-id', decline_reason: 'Clau
393
443
 
394
444
  > The `sign` request body is the API's camelCase body-key exception. This SDK accepts the snake_case keys (`item_id`, `field_id`, `page_id`, `value`) shown above and maps them to `itemId/fieldId/pageId/value` automatically. CamelCase input is also passed through unchanged. Assignment listing separately uses the live-required `accountId` query parameter.
395
445
 
446
+ Use exactly one notification channel per signer: `Email` verification pairs with `Email`,
447
+ `Whatsapp` pairs with `Whatsapp`, and `DigitalCertificate` permits either. If any signer has a
448
+ `step`, all must have one, with contiguous steps starting at 1. A certificate signer must be alone
449
+ in its step. Deadlines require a timezone and must be at least one hour in the future.
450
+
396
451
  ### Signer documents (multi-document workflows)
397
452
 
398
453
  ```ruby
@@ -416,7 +471,12 @@ client.templates.delete('template-id')
416
471
  client.templates.download_page('template-id', 'page-id') # binary image bytes
417
472
  ```
418
473
 
419
- > Template endpoints (`get`/`create`/`update`/`delete`/`download_page`) are live-verified against the sandbox but are not part of the current OpenAPI document. `create` requires a source file (`multipart/form-data`); the template name defaults to the uploaded file's name.
474
+ > Template endpoints (`get`/`create`/`update`/`delete`/`download_page`) are supported by the deployed API but are not part of the current OpenAPI document. `create` requires a source file (`multipart/form-data`); the template name defaults to the uploaded file's name.
475
+
476
+ A template upload initially contains an `Editor` role. Configure at least one `Signer` role and its
477
+ fields in the Assinafy application before generating a document. Supply one entry per role, each with
478
+ a distinct existing signer ID; `role_id` comes from the template's `roles` array. Cost estimation
479
+ requires `role_id` but can omit the signer ID.
420
480
 
421
481
  ### Tags
422
482
 
@@ -596,16 +656,18 @@ assignment = client.assignments.create(
596
656
  method: 'virtual',
597
657
  signers: [signer_request],
598
658
  message: 'Please review and sign.',
599
- expires_at: '2026-12-31T23:59:00Z'
659
+ expires_at: '2099-12-31T23:59:00Z'
600
660
  )
601
661
  # => Assignment, including signing_urls
602
662
  ```
603
663
 
604
- The template alternative binds a signer to a template role and creates the document and assignment together:
664
+ For a template configured with exactly one `Signer` role, the alternative binds the existing signer
665
+ to that role and creates the document and assignment together. Templates with more roles require
666
+ a distinct signer entry for each role:
605
667
 
606
668
  ```ruby
607
669
  role_signer = {
608
- role_id: template.fetch('roles').first.fetch('id'),
670
+ role_id: template.fetch('roles').find { |role| role['assignment_type'] == 'Signer' }.fetch('id'),
609
671
  id: signer.fetch('id'),
610
672
  verification_method: 'Email',
611
673
  notification_methods: ['Email']
@@ -678,7 +740,8 @@ signer is created.
678
740
  Requesting the method is fully supported. Completing the signature is not: the two-step handshake
679
741
  (`/signers/certificate/start`, `/signers/certificate/complete`) is absent from the API v1 machine contract — its
680
742
  authentication and request/response schemas are not published — so the SDK does not expose those completion
681
- calls rather than guess at their payloads. Contact Assinafy before enabling a digital-certificate signing flow.
743
+ calls. Direct A1/A3 signers to Assinafy's hosted signing flow using the returned `signing_urls`;
744
+ contact Assinafy for a published contract before integrating the handshake directly.
682
745
  Once it completes, the `pades` artifact returns the qualified signature.
683
746
 
684
747
  ### 7. Inspect, tag, download, and verify
@@ -691,7 +754,8 @@ tags = client.documents.append_tags(document_id, ['tag-id']) # => Array<Tag>
691
754
  client.documents.replace_tags(document_id, tags.map { |tag| tag.fetch('id') })
692
755
 
693
756
  original_pdf = client.documents.download(document_id, 'original')
694
- signed_pdf = client.documents.download(document_id, 'pades')
757
+ signed_pdf = client.documents.download(document_id, 'certificated')
758
+ # For documents completed with ICP-Brasil certificates: download(document_id, 'pades')
695
759
  certificate_pdf = client.documents.download(document_id, 'certificated')
696
760
 
697
761
  verification = client.documents.verify('signature-hash-from-assinafy')
@@ -744,10 +808,10 @@ result = client.upload_and_request_signatures(
744
808
  source: { file_path: './contract.pdf' },
745
809
  signers: [
746
810
  { full_name: 'Alice Silva', email: 'alice@example.com' },
747
- { full_name: 'Bob Costa', whatsapp_phone_number: '+5548999990000' }
811
+ { full_name: 'Bob Costa', email: 'bob@example.com' }
748
812
  ],
749
813
  message: 'Please sign.',
750
- expires_at: '2026-12-31T23:59:00Z'
814
+ expires_at: '2099-12-31T23:59:00Z'
751
815
  )
752
816
 
753
817
  puts result[:document]['id']
@@ -760,11 +824,11 @@ result[:signer_ids] # => ['<sid-1>', '<sid-2>']
760
824
  The SDK raises one of:
761
825
 
762
826
  - `Assinafy::ValidationError` — caller-side input invalid (missing IDs, bad email, etc.).
763
- - `Assinafy::ApiError` — the API returned a non-2xx status. Includes `status_code` and `response_data`.
827
+ - `Assinafy::ApiError` — the API returned a non-2xx status. Includes `status_code` and `response_data`; on a
828
+ `403`, `context[:www_authenticate]` names the scope an OAuth token is missing.
764
829
  - `Assinafy::OAuthError` — an OAuth endpoint failed. Subclasses `ApiError`, and adds `error` and
765
- `error_description` from the flat RFC 6749 body; on a `403`, `context[:www_authenticate]` names the missing
766
- scope.
767
- - `Assinafy::NetworkError` — Faraday connection error or timeout.
830
+ `error_description` from the flat RFC 6749 body.
831
+ - `Assinafy::NetworkError` — Faraday connection error, timeout, or TLS failure.
768
832
  - `Assinafy::Error` — base class; other unexpected errors get wrapped here with the operation label.
769
833
 
770
834
  All inherit a `#context` Hash with debugging metadata.
@@ -798,10 +862,16 @@ ASSINAFY_API_KEY=... \
798
862
  ASSINAFY_ACCOUNT_ID=... \
799
863
  ASSINAFY_TEST_EMAIL=recipient1@example.com \
800
864
  ASSINAFY_TEST_EMAIL2=recipient2@example.com \
865
+ ASSINAFY_TEMPLATE_ID=configured-template-id \
801
866
  ASSINAFY_BASE_URL=https://sandbox.assinafy.com.br/v1 \
802
867
  bundle exec rspec spec/integration
803
868
  ```
804
869
 
870
+ `ASSINAFY_TEMPLATE_ID` must identify a sandbox template with a `Signer` role configured in the
871
+ application. Without it, the positive template-generation example is skipped. Supply different test
872
+ recipients for its roles. OAuth browser consent is tested separately with a registered app, an HTTPS
873
+ callback, and the matching authorization server; sandbox keys are not production OAuth credentials.
874
+
805
875
  > These tests create and clean up real resources and, for the assignment flow, send real signature-request emails to the addresses in `ASSINAFY_TEST_EMAIL` / `ASSINAFY_TEST_EMAIL2`.
806
876
 
807
877
  ## Contributing