assinafy 1.8.1 → 1.9.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: de868e0ed87ada5a4456c4a811a1aa8057a6c482b344944661a0a4670ab86057
4
- data.tar.gz: cef6d34ac264a694a194ca46ec35810836d0660001fcea5d351a1bbfff1dc638
3
+ metadata.gz: 6c202496157dd16d5ae51e1e6416ca30ab0d6ec3665ffa6dead88219214c82b8
4
+ data.tar.gz: 9c07ecf32a3c9e9e94d964d56f6f38dc8c670d2cc011059d4c14409e79972b9e
5
5
  SHA512:
6
- metadata.gz: 24fff2f9be29fbcf69788f6f217f541c9d68ba22c10ec6dbb2f557e2c9c3d827fe57004294d6c8af7cc1c38dd6e8ca2ff12bb826995a97ea7a111eae8589dac4
7
- data.tar.gz: 6e7ffbaa337b9480c118bd1d5ef2d3423bba3622e333a5b6c11b20ed594df78cc03772e7243ff2ca919f272aab8efdf439db3bb06d3f9180553c8388e7f0753b
6
+ metadata.gz: 4bd51928265eb56100d62653932be2145442efb23e4708007070a027aeaff0e96678014c4d2a5b39ca2a9dcb37c6a13e5caf3b16798e28b6ad2ad535ef937068
7
+ data.tar.gz: 7c1c6a6f07f0794b685b414195161f9832c74e9e196adff5f8713b26b44d7b94c188651184144af0b7393ad8bfea97d601a0e350c7165d2a4709356bcda0743a
data/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to the `assinafy` Ruby gem are documented here.
4
4
 
5
+ ## 1.9.0
6
+
7
+ - `OAuthResource#refresh`, and `#token` with the `refresh_token` grant, raise `Assinafy::Error` instead of
8
+ returning a success whose `refresh_token` is missing, blank, or the one sent. The token sent may already be
9
+ retired, so handle the error like `invalid_grant`.
10
+ - `OAuthResource` posts `/oauth/token` and `/oauth/revoke` bodies as `application/x-www-form-urlencoded`, as
11
+ RFC 6749 and RFC 7009 define them. The API accepts this and JSON alike; no caller change is needed.
12
+ - Every `ApiError`, not only `OAuthError`, carries the `WWW-Authenticate` challenge in
13
+ `context[:www_authenticate]`, including one raised for an error status inside a `2xx` envelope, so a `403` for
14
+ a missing OAuth scope can be told apart from one for another workspace or role.
15
+ - `DocumentResource#verify` documents the nullable `agreement_code` field, the agreement code printed on the
16
+ document certificate. `spec/fixtures/api_contract.json` tracks the current contract.
17
+ - `scripts/check_api_contract.rb` requires TLS 1.2 or newer.
18
+ - The OAuth guides cover refresh-token rotation (every refresh returns a new refresh token valid for 30 days and
19
+ retires the old one): storing the new tokens and rebuilding the client, never resending a refresh token after
20
+ an ambiguous failure, and revoking the refresh token currently stored on disconnect. They also cover checking
21
+ `state` and `iss` against the values stored for each authorization attempt (the sandbox has its own issuer)
22
+ and finding the connected workspace through `GET /accounts`.
23
+
5
24
  ## 1.8.1
6
25
 
7
26
  - 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
@@ -154,7 +154,7 @@ The flow is authorization-code with **mandatory PKCE**; the authorization server
154
154
  | `email` | Include the user's email and verification status in the claims |
155
155
  | `offline_access` | Be issued a refresh token |
156
156
 
157
- **1. Redirect the user.** Store the verifier and state in the session first.
157
+ **1. Redirect the user.** Store the verifier, the state, and the expected issuer in the session first.
158
158
 
159
159
  ```ruby
160
160
  verifier = Assinafy::OAuth.generate_code_verifier
@@ -162,6 +162,7 @@ state = Assinafy::OAuth.generate_state
162
162
 
163
163
  session[:assinafy_code_verifier] = verifier
164
164
  session[:assinafy_state] = state
165
+ session[:assinafy_issuer] = Assinafy::OAuth::AUTHORIZATION_SERVER # issuer of the server this attempt uses
165
166
 
166
167
  redirect_to Assinafy::OAuth.authorization_url(
167
168
  client_id: ENV.fetch('ASSINAFY_CLIENT_ID'),
@@ -173,11 +174,20 @@ redirect_to Assinafy::OAuth.authorization_url(
173
174
  ```
174
175
 
175
176
  The `code_challenge` is derived from the verifier; the verifier itself never appears in the URL.
177
+ `Assinafy::OAuth::AUTHORIZATION_SERVER` is the production issuer. The sandbox has its own: pass
178
+ `authorization_endpoint: 'https://auth-sandbox.assinafy.com.br/oauth/authorize'` and store
179
+ `https://auth-sandbox.assinafy.com.br` instead.
176
180
 
177
- **2. Exchange the code.** Compare `state` before exchanging — that is the CSRF check.
181
+ **2. Exchange the code.** Check `state` and `iss` against the values stored for this attempt before anything
182
+ else, including on an `error=` return — that is the CSRF check, and it rejects responses that are not yours. The
183
+ code is single-use and expires 60 seconds after approval: exchange it at once, and never retry.
178
184
 
179
185
  ```ruby
180
- raise 'state mismatch' unless params[:state] == session.delete(:assinafy_state)
186
+ unless params[:state] == session.delete(:assinafy_state) &&
187
+ params[:iss] == session.delete(:assinafy_issuer)
188
+ raise 'authorization response is not ours'
189
+ end
190
+ raise "authorization not granted: #{params[:error]}" if params[:error] # access_denied, invalid_scope, ...
181
191
 
182
192
  tokens = Assinafy::Client.new.oauth.exchange_code(
183
193
  code: params.fetch(:code),
@@ -189,34 +199,63 @@ tokens = Assinafy::Client.new.oauth.exchange_code(
189
199
  # 'refresh_token' => ..., 'scope' => 'documents:read documents:write' }
190
200
  ```
191
201
 
192
- **3. Act as the user.**
202
+ **3. Act as the user.** The token works only in the workspace the user picked; store its id next to the tokens.
193
203
 
194
204
  ```ruby
195
- user_client = Assinafy::Client.new(
196
- token: tokens.fetch('access_token'),
197
- account_id: ENV.fetch('ASSINAFY_ACCOUNT_ID')
198
- )
205
+ access_token = tokens.fetch('access_token')
206
+ workspace_id = Assinafy::Client.new(token: access_token).accounts.list[:data].first.fetch('id')
199
207
 
208
+ user_client = Assinafy::Client.new(token: access_token, account_id: workspace_id)
200
209
  user_client.documents.list
201
210
  user_client.oauth.userinfo # => { 'sub' => ..., 'name' => ..., 'email' => ... }
202
211
  ```
203
212
 
204
- **Refresh and revoke.**
213
+ **Refresh and revoke.** `connection` below is your stored record for this user: tokens, expiry, workspace id.
205
214
 
206
215
  ```ruby
207
- client.oauth.refresh(refresh_token: stored_refresh_token, client_id: client_id)
216
+ tokens = Assinafy::Client.new.oauth.refresh(
217
+ refresh_token: connection.refresh_token,
218
+ client_id: ENV.fetch('ASSINAFY_CLIENT_ID')
219
+ )
208
220
 
209
- client.oauth.revoke(
210
- token: stored_refresh_token, client_id: client_id, token_type_hint: 'refresh_token'
221
+ # The refresh token just sent is retired: store the new tokens before anything else.
222
+ connection.update!(
223
+ refresh_token: tokens.fetch('refresh_token'),
224
+ access_token: tokens.fetch('access_token'),
225
+ expires_at: Time.now + tokens.fetch('expires_in')
211
226
  )
227
+ user_client = Assinafy::Client.new(token: connection.access_token, account_id: connection.workspace_id)
212
228
  ```
213
229
 
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.
230
+ When a user disconnects, revoke the refresh token in storage **now** — the latest one — then delete the stored
231
+ tokens:
217
232
 
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.
233
+ ```ruby
234
+ Assinafy::Client.new.oauth.revoke(
235
+ token: connection.reload.refresh_token,
236
+ client_id: ENV.fetch('ASSINAFY_CLIENT_ID'),
237
+ token_type_hint: 'refresh_token'
238
+ )
239
+ connection.destroy!
240
+ ```
241
+
242
+ A refresh token exists only when `offline_access` was requested *and* consented. Revoking a refresh token also
243
+ invalidates the access tokens issued from it. Every revoke outcome returns `200` — including an unknown,
244
+ already-revoked, or rotated token — so the endpoint cannot be used to probe whether a token exists, and revoking
245
+ a stale copy can look successful while the connection stays active.
246
+
247
+ > The SDK does **not** refresh automatically. Persist `expires_in` and refresh before expiry. Every refresh
248
+ > returns a **new** refresh token, valid for another 30 days, and retires the old one, so a connection only
249
+ > expires after 30 days without a refresh. Reusing a retired refresh token ends the whole connection: store the
250
+ > new tokens before using them, and refresh one at a time per connection. `refresh` raises `Assinafy::Error`
251
+ > rather than return a success without a new refresh token; handle it like `invalid_grant`.
252
+ >
253
+ > The SDK sends each token request once. If a refresh fails without a clear answer — a timeout, a reset
254
+ > connection, a `5xx` — the server may have rotated the token without the response reaching you. Re-read the
255
+ > stored refresh token: if it is still the one you sent, **never send it again**; ask the user to connect again.
256
+ > Carry on only if another worker has since stored a different one. Only a failure that provably happened before
257
+ > the request was sent — DNS resolution, a refused connection, a failed TLS handshake — is safe to retry. On an
258
+ > API `401`, refresh once; if that fails, or on `invalid_grant`, ask the user to connect again.
220
259
 
221
260
  **Discovery**, instead of hardcoding endpoints:
222
261
 
@@ -241,6 +280,10 @@ rescue Assinafy::OAuthError => e
241
280
  e.context[:www_authenticate] # on a 403, names the missing scope
242
281
  ```
243
282
 
283
+ Any resource, not only these routes, puts that challenge in `context[:www_authenticate]`. Treat it as a prompt to
284
+ reconnect with that scope added, not as a retry; a `403` without it means another workspace, the user's role, or
285
+ an area OAuth tokens never reach.
286
+
244
287
  ### Accounts
245
288
 
246
289
  ```ruby
@@ -760,11 +803,11 @@ result[:signer_ids] # => ['<sid-1>', '<sid-2>']
760
803
  The SDK raises one of:
761
804
 
762
805
  - `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`.
806
+ - `Assinafy::ApiError` — the API returned a non-2xx status. Includes `status_code` and `response_data`; on a
807
+ `403`, `context[:www_authenticate]` names the scope an OAuth token is missing.
764
808
  - `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.
809
+ `error_description` from the flat RFC 6749 body.
810
+ - `Assinafy::NetworkError` — Faraday connection error, timeout, or TLS failure.
768
811
  - `Assinafy::Error` — base class; other unexpected errors get wrapped here with the operation label.
769
812
 
770
813
  All inherit a `#context` Hash with debugging metadata.
data/README.md CHANGED
@@ -411,8 +411,9 @@ credenciais ou superfícies administrativas — independentemente do escopo.
411
411
  verificador = Assinafy::OAuth.generate_code_verifier
412
412
  state = Assinafy::OAuth.generate_state
413
413
 
414
- session[:assinafy_code_verifier] = verificador # guarde os dois na sessão
414
+ session[:assinafy_code_verifier] = verificador # guarde os três na sessão
415
415
  session[:assinafy_state] = state
416
+ session[:assinafy_issuer] = Assinafy::OAuth::AUTHORIZATION_SERVER # emissor do servidor usado nesta tentativa
416
417
 
417
418
  redirect_to Assinafy::OAuth.authorization_url(
418
419
  client_id: ENV.fetch('ASSINAFY_CLIENT_ID'),
@@ -424,11 +425,18 @@ redirect_to Assinafy::OAuth.authorization_url(
424
425
  ```
425
426
 
426
427
  O `code_challenge` é derivado do verificador (`S256`) — o verificador em si nunca vai para a URL.
428
+ `Assinafy::OAuth::AUTHORIZATION_SERVER` é o emissor de produção. O sandbox tem o seu: passe
429
+ `authorization_endpoint: 'https://auth-sandbox.assinafy.com.br/oauth/authorize'` e guarde
430
+ `https://auth-sandbox.assinafy.com.br`.
427
431
 
428
432
  ### Passo 2 — trocar o código por tokens
429
433
 
430
434
  ```ruby
431
- raise 'state divergente' unless params[:state] == session.delete(:assinafy_state)
435
+ unless params[:state] == session.delete(:assinafy_state) &&
436
+ params[:iss] == session.delete(:assinafy_issuer)
437
+ raise 'resposta de autorização inválida'
438
+ end
439
+ raise "autorização não concedida: #{params[:error]}" if params[:error] # access_denied, invalid_scope, ...
432
440
 
433
441
  tokens = Assinafy::Client.new.oauth.exchange_code(
434
442
  code: params.fetch(:code),
@@ -443,41 +451,74 @@ tokens['refresh_token'] # => presente apenas com offline_access
443
451
  tokens['scope'] # => "documents:read documents:write"
444
452
  ```
445
453
 
446
- Compare o `state` **antes** de trocar o código — é a proteção contra CSRF.
454
+ Confira `state` e `iss` contra os valores guardados nesta tentativa **antes de qualquer outra
455
+ coisa**, inclusive num retorno com `error=` — é a proteção contra CSRF e contra respostas que não
456
+ são suas. O código vale uma vez e expira 60
457
+ segundos depois da aprovação: troque-o na hora, sem repetir.
447
458
 
448
459
  ### Passo 3 — agir como o usuário
449
460
 
450
461
  ```ruby
451
- usuario = Assinafy::Client.new(
452
- token: tokens.fetch('access_token'),
453
- account_id: ENV.fetch('ASSINAFY_ACCOUNT_ID')
454
- )
462
+ access_token = tokens.fetch('access_token')
463
+
464
+ # O token só vale na workspace que o usuário escolheu; guarde o id junto dos tokens.
465
+ workspace_id = Assinafy::Client.new(token: access_token).accounts.list[:data].first.fetch('id')
455
466
 
467
+ usuario = Assinafy::Client.new(token: access_token, account_id: workspace_id)
456
468
  usuario.documents.list
457
469
  usuario.oauth.userinfo # => { 'sub' => ..., 'name' => ..., 'email' => ... }
458
470
  ```
459
471
 
460
472
  ### Renovar e revogar
461
473
 
474
+ `conexao` abaixo é o registro que você guarda para o usuário: tokens, validade e id da workspace.
475
+
462
476
  ```ruby
463
- novos = client.oauth.refresh(
464
- refresh_token: refresh_token_guardado,
477
+ novos = Assinafy::Client.new.oauth.refresh(
478
+ refresh_token: conexao.refresh_token,
465
479
  client_id: ENV.fetch('ASSINAFY_CLIENT_ID')
466
480
  )
467
481
 
468
- client.oauth.revoke(
469
- token: refresh_token_guardado,
482
+ # O refresh token enviado foi aposentado: guarde os tokens novos antes de qualquer outra coisa.
483
+ conexao.update!(
484
+ refresh_token: novos.fetch('refresh_token'),
485
+ access_token: novos.fetch('access_token'),
486
+ expires_at: Time.now + novos.fetch('expires_in')
487
+ )
488
+ usuario = Assinafy::Client.new(token: conexao.access_token, account_id: conexao.workspace_id)
489
+ ```
490
+
491
+ Quando o usuário desconectar, revogue o refresh token guardado **agora** — o mais recente — e só
492
+ depois apague os tokens:
493
+
494
+ ```ruby
495
+ Assinafy::Client.new.oauth.revoke(
496
+ token: conexao.reload.refresh_token,
470
497
  client_id: ENV.fetch('ASSINAFY_CLIENT_ID'),
471
498
  token_type_hint: 'refresh_token'
472
499
  )
500
+ conexao.destroy!
473
501
  ```
474
502
 
475
503
  Sem `offline_access` não há refresh token: quando o access token expirar, mande o usuário pelo
476
504
  fluxo de autorização de novo. Revogar um refresh token invalida também os access tokens emitidos
477
- a partir dele.
478
-
479
- > O SDK **não renova o token automaticamente**. Guarde `expires_in`, renove antes de expirar, e
480
- > trate `invalid_grant` reiniciando o fluxo de autorização.
505
+ a partir dele. A revogação responde `200` inclusive para um token já aposentado, então revogar uma
506
+ cópia antiga parece dar certo enquanto a conexão continua ativa.
507
+
508
+ > O SDK **não renova o token automaticamente**. Guarde `expires_in` e renove antes de expirar.
509
+ > Cada renovação devolve um refresh token **novo**, válido por mais 30 dias, e aposenta o
510
+ > anterior: a conexão só expira se passar 30 dias sem renovar. Reutilizar um refresh token
511
+ > aposentado encerra a conexão inteira, então guarde os tokens novos antes de usá-los e faça uma
512
+ > renovação por vez por conexão. `refresh` levanta `Assinafy::Error` em vez de devolver um
513
+ > sucesso sem refresh token novo; trate como `invalid_grant`.
514
+ >
515
+ > O SDK envia cada pedido de token uma única vez. Se a renovação falhar sem resposta clara —
516
+ > timeout, conexão interrompida, `5xx` —, o servidor pode ter trocado o token sem que a resposta
517
+ > chegasse. Releia o refresh token guardado: se ainda for o que você enviou, **nunca o reenvie**;
518
+ > peça ao usuário para conectar de novo. Siga em frente só se outro processo já tiver guardado um
519
+ > token diferente. Só é seguro repetir uma falha que comprovadamente aconteceu antes do envio:
520
+ > DNS, conexão recusada, handshake TLS. Num `401` da API, renove uma vez; se falhar, ou em
521
+ > `invalid_grant`, peça ao usuário para conectar de novo.
481
522
 
482
523
  ### Descoberta
483
524
 
@@ -507,7 +548,10 @@ rescue Assinafy::OAuthError => e
507
548
  end
508
549
  ```
509
550
 
510
- Num `403`, `e.context[:www_authenticate]` traz o desafio que **nomeia o escopo faltante**.
551
+ Num `403` de qualquer recurso, `e.context[:www_authenticate]` traz o desafio que **nomeia o escopo
552
+ faltante**: peça ao usuário para conectar de novo incluindo esse escopo, sem repetir a chamada. Um
553
+ `403` sem esse desafio indica outra workspace, o papel do usuário ou uma área que tokens OAuth não
554
+ alcançam.
511
555
 
512
556
  ---
513
557
 
@@ -786,8 +830,9 @@ cadeia de certificação.
786
830
  | Sandbox | `https://sandbox.assinafy.com.br/v1` |
787
831
 
788
832
  O sandbox é gratuito e espelha a produção 1 para 1 — mesmas rotas, mesmos contratos, incluindo
789
- OAuth 2.1 e certificado digital. Troque apenas a `base_url` para testar a integração de ponta a
790
- ponta antes de ir para produção.
833
+ OAuth 2.1 e certificado digital. Troque apenas a `base_url` — e, no OAuth, o servidor de
834
+ autorização (`https://auth-sandbox.assinafy.com.br`) — para testar a integração de ponta a ponta
835
+ antes de ir para produção.
791
836
 
792
837
  ---
793
838
 
@@ -1,7 +1,7 @@
1
1
  # Assinafy Ruby SDK API Reference
2
2
 
3
- > Contract source: Assinafy API v1 OpenAPI 3.0.0, retrieved 2026-08-26 from
4
- > `https://api.assinafy.com.br/v1/docs/openapi.json` (67 paths, 89 operations, 39 schemas).
3
+ > Contract source: Assinafy API v1 OpenAPI 3.0.0, retrieved 2026-09-25 from
4
+ > `https://api.assinafy.com.br/v1/docs/openapi.json` (71 paths, 93 operations, 39 schemas).
5
5
  > `scripts/check_api_contract.rb` validates contract compatibility weekly.
6
6
 
7
7
  This is the SDK-facing contract reference. Paths below are wire paths; Ruby methods return the unwrapped
@@ -21,12 +21,15 @@ Required parameters and object properties are marked with `*`.
21
21
  `client.oauth` covers the token lifecycle. A token is scoped to one workspace and carries only the scopes the
22
22
  user approved, and it never reaches billing, account lifecycle, credential management, or admin surfaces
23
23
  regardless of scope. A missing scope answers `403` with a `WWW-Authenticate` challenge naming it, which the
24
- SDK exposes as `OAuthError#context[:www_authenticate]`. Store the `code_verifier` and `state` per user session,
25
- compare `state` on the callback before exchanging the code, and keep refresh tokens out of logs.
24
+ SDK exposes as `ApiError#context[:www_authenticate]` on every resource: reconnect with that scope added rather
25
+ than retrying. Store the `code_verifier`, `state`, and expected issuer with each authorization attempt — the
26
+ issuer of the authorization server it uses: `https://auth.assinafy.com.br` in production,
27
+ `https://auth-sandbox.assinafy.com.br` in the sandbox. Check `state` and `iss` against those stored values on
28
+ the callback before anything else, including an `error=` return, and keep refresh tokens out of logs.
26
29
  - Signer-facing operations use the one-time `signer-access-code` query parameter where shown. Never log, commit,
27
30
  or place API keys, bearer tokens, signer codes, or real recipient addresses in examples or fixtures.
28
- - HTTP connections use Ruby/Faraday TLS verification and the host system trust store; the SDK does not pin the
29
- upstream TLS certificate.
31
+ - HTTP connections require TLS 1.2 or newer and use Ruby/Faraday TLS verification and the host system trust store;
32
+ the SDK does not pin the upstream TLS certificate.
30
33
  - `DocumentResource#verify` reports Assinafy upstream verification data. It does not independently validate a PDF
31
34
  signature, certificate chain, OCSP/CRL status, or legal validity. The API artifact name `certificated` is not an
32
35
  additional local guarantee.
@@ -103,8 +106,8 @@ Required parameters and object properties are marked with `*`.
103
106
  | GET | `/v1/documents/{documentSignatureHash}/verify` | `DocumentResource#verify` | Public | path `documentSignatureHash*`: string | None | `200` `application/json` [`Envelope`](#envelope) plus object { `data`: [`DocumentVerification`](#documentverification) } |
104
107
  | GET | `/v1/field-types` | `FieldResource#types` | Bearer token or `X-Api-Key` | None | None | `200` `application/json` [`Envelope`](#envelope) plus object { `data`: Array<[`FieldType`](#fieldtype)> } |
105
108
  | POST | `/v1/login` | `AuthResource#login` | Public | None | required; `application/json` object { `email*`: string (email); `password*`: string (password) } | `200` `application/json` [`Envelope`](#envelope) plus object { `data`: [`AuthSession`](#authsession) } |
106
- | POST | `/v1/oauth/token` | `OAuthResource#token`, `#exchange_code`, `#refresh` | Public (client authenticates with `client_id` in the body) | None | required; `application/json` object { `grant_type*`: string enum `authorization_code`, `refresh_token`; `client_id*`: string; `code`: string; `redirect_uri`: string (uri); `code_verifier`: string; `refresh_token`: string; `client_secret`: string; `resource`: string (uri) } | `200` `application/json` **flat** object { `access_token`: string; `token_type`: string; `expires_in`: integer; `refresh_token`: string (nullable); `scope`: string; `id_token`: string (nullable) } — not enveloped |
107
- | POST | `/v1/oauth/revoke` | `OAuthResource#revoke` | Public (client authenticates with `client_id` in the body) | None | required; `application/json` object { `token*`: string; `client_id*`: string; `token_type_hint`: string enum `access_token`, `refresh_token`; `client_secret`: string } | `200` empty body — every token outcome reports success |
109
+ | POST | `/v1/oauth/token` | `OAuthResource#token`, `#exchange_code`, `#refresh` | Public (client authenticates with `client_id` in the body) | None | required; `application/x-www-form-urlencoded` (RFC 6749; the contract lists `application/json`) object { `grant_type*`: string enum `authorization_code`, `refresh_token`; `client_id*`: string; `code`: string; `redirect_uri`: string (uri); `code_verifier`: string; `refresh_token`: string; `client_secret`: string; `resource`: string (uri) } | `200` `application/json` **flat** object { `access_token`: string; `token_type`: string; `expires_in`: integer; `refresh_token`: string (nullable); `scope`: string; `id_token`: string (nullable) } — not enveloped |
110
+ | POST | `/v1/oauth/revoke` | `OAuthResource#revoke` | Public (client authenticates with `client_id` in the body) | None | required; `application/x-www-form-urlencoded` (RFC 7009; the contract lists `application/json`) object { `token*`: string; `client_id*`: string; `token_type_hint`: string enum `access_token`, `refresh_token`; `client_secret`: string } | `200` empty body — every token outcome reports success |
108
111
  | GET | `/v1/oauth/userinfo` | `OAuthResource#userinfo` | Bearer token or `X-Api-Key`; requires the `openid` scope | None | None | `200` `application/json` **flat** object { `sub`: string; `name`: string (nullable); `email`: string (email, nullable); `email_verified`: boolean (nullable) } — not enveloped |
109
112
  | GET | `/v1/public/documents/{documentId}` | `DocumentResource#public_info` | Public | path `documentId*`: string | None | `200` `application/json` [`Envelope`](#envelope) plus object { `data`: [`Document`](#document) } |
110
113
  | PUT | `/v1/public/documents/{documentId}/send-token` | `DocumentResource#send_token` | Public | path `documentId*`: string | optional; `application/json` object { `email`: string (email) } | `200` `application/json` [`Envelope`](#envelope) |
@@ -167,7 +170,8 @@ raise `Assinafy::ApiError`:
167
170
  - `message` uses the API's `message`, `error`, or `name` value.
168
171
  - `response_data` preserves the parsed response body.
169
172
  - `error_name` and `error_code` expose framework `name` and `code` values when present.
170
- - `context` contains `status_code` and `response_data` for structured logging or support diagnostics.
173
+ - `context` contains `status_code` and `response_data` for structured logging or support diagnostics, plus
174
+ `www_authenticate` when the response carries that header.
171
175
 
172
176
  Transport, timeout, and TLS failures raise `Assinafy::NetworkError`; caller-side validation failures raise
173
177
  `Assinafy::ValidationError` before a request is sent.
@@ -228,10 +232,28 @@ that `base_url` carries.
228
232
 
229
233
  ### Token refresh
230
234
 
231
- The SDK does not refresh access tokens automatically. Persist `expires_in` alongside the token,
232
- call `OAuthResource#refresh` before expiry, and treat `invalid_grant` as a signal to restart the
233
- authorization flow. A refresh token is issued only when `offline_access` was both requested and
234
- consented.
235
+ The SDK does not refresh access tokens automatically. Persist `expires_in` alongside the token and
236
+ call `OAuthResource#refresh` before expiry. A refresh token is issued only when `offline_access` was
237
+ both requested and consented.
238
+
239
+ Every refresh returns a new refresh token, valid for another 30 days, and retires the one sent, so a
240
+ connection only expires after 30 days without a refresh. Reusing a retired refresh token ends the
241
+ whole connection. Store the new refresh and access tokens before using either, rebuild the client with
242
+ the new access token, and run one refresh at a time per connection. `OAuthResource#refresh`, and
243
+ `#token` with the `refresh_token` grant, raise `Assinafy::Error` rather than return a success whose
244
+ `refresh_token` is missing, blank, or the one sent; handle that like `invalid_grant`.
245
+
246
+ The SDK never retries token requests; do not add middleware that does. After an ambiguous failure — a
247
+ timeout, a reset connection, a `5xx` — the server may have rotated the token without the response
248
+ arriving. Re-read the stored refresh token: if it is still the one you sent, never send it again; ask
249
+ the user to connect again. Proceed only if another worker has since stored a different one. Only a
250
+ failure that provably happened before the request was sent (DNS resolution, a refused connection, a
251
+ failed TLS handshake) is safe to retry. On an API `401`, refresh once; if that fails, or on
252
+ `invalid_grant`, ask the user to connect again.
253
+
254
+ When a user disconnects, revoke the refresh token in storage at that moment with `OAuthResource#revoke`,
255
+ then delete the stored tokens. Revoking a rotated token also answers `200`, so revoking a stale copy can
256
+ look successful while the connection stays active.
235
257
 
236
258
  ### Digital-certificate signing
237
259
 
@@ -530,6 +552,7 @@ The verification result for a document looked up by signature hash. When not ver
530
552
  | --- | --- | --- | --- | --- |
531
553
  | `hash` | string | No | No | — |
532
554
  | `id` | string | No | Yes | — |
555
+ | `agreement_code` | string | No | Yes | Agreement code printed on the document certificate. |
533
556
  | `status` | string | No | Yes | — |
534
557
  | `page_count` | string | No | Yes | — |
535
558
  | `signer_count` | string | No | Yes | — |
@@ -241,6 +241,10 @@ module Assinafy
241
241
  # Expose the underlying Faraday connection (for advanced use cases,
242
242
  # such as adding middleware or inspecting headers in tests).
243
243
  #
244
+ # OAuth token requests share this connection: never add middleware that
245
+ # retries `POST /oauth/token`. A retried refresh resends a refresh token the
246
+ # first attempt may already have retired, which ends the user's connection.
247
+ #
244
248
  # @return [Faraday::Connection]
245
249
  #
246
250
  # @example Inspect the auth header the SDK sends
@@ -15,11 +15,13 @@ module Assinafy
15
15
  # helpers exist so an integration never hand-rolls the S256 transform.
16
16
  #
17
17
  # @example Full authorization-code flow with PKCE
18
- # # 1. Before redirecting, mint and store a verifier for this user session.
18
+ # # 1. Before redirecting, mint and store a verifier and state for this
19
+ # # attempt, with the issuer of the authorization server it uses.
19
20
  # verifier = Assinafy::OAuth.generate_code_verifier
20
21
  # state = Assinafy::OAuth.generate_state
21
22
  # session[:assinafy_code_verifier] = verifier
22
23
  # session[:assinafy_state] = state
24
+ # session[:assinafy_issuer] = Assinafy::OAuth::AUTHORIZATION_SERVER
23
25
  #
24
26
  # # 2. Send the user to the authorization server.
25
27
  # redirect_to Assinafy::OAuth.authorization_url(
@@ -34,13 +36,17 @@ module Assinafy
34
36
  # # &scope=documents%3Aread+documents%3Awrite+offline_access&state=...
35
37
  # # &code_challenge=...&code_challenge_method=S256"
36
38
  #
37
- # # 3. On the callback, compare `state`, then exchange the code
38
- # # (see Resources::OAuthResource#exchange_code).
39
+ # # 3. On the callback, before anything else (an `error=` return
40
+ # # included), check `state` and `iss` against the values stored for
41
+ # # this attempt; stop if either differs. Then exchange the code (see
42
+ # # Resources::OAuthResource#exchange_code).
39
43
  #
40
44
  # @see https://api.assinafy.com.br/v1/docs
41
45
  module OAuth
42
- # Authorization server that owns the browser-facing flow. Published by
43
- # `GET /.well-known/oauth-protected-resource` as `authorization_servers[0]`.
46
+ # Production authorization server, which owns the browser-facing flow.
47
+ # Published by `GET /.well-known/oauth-protected-resource` as
48
+ # `authorization_servers[0]`, and the `iss` its callbacks carry. The
49
+ # sandbox publishes its own, `https://auth-sandbox.assinafy.com.br`.
44
50
  AUTHORIZATION_SERVER = 'https://auth.assinafy.com.br'
45
51
 
46
52
  # RFC 8414 discovery document for {AUTHORIZATION_SERVER}.
@@ -151,7 +157,8 @@ module Assinafy
151
157
  # @param resource [String, nil] RFC 8707 resource indicator; when sent
152
158
  # here it must also be sent to the token endpoint, and must be the
153
159
  # `resource` published by `/.well-known/oauth-protected-resource`
154
- # @param authorization_endpoint [String] override for a non-default server
160
+ # @param authorization_endpoint [String] override for a non-default
161
+ # server; store that server's issuer for the callback's `iss` check
155
162
  # @param extra_params [Hash] additional query parameters, merged last
156
163
  # @return [String] the absolute URL to redirect to
157
164
  # @raise [ValidationError] on a missing client_id/redirect_uri, an invalid
@@ -177,6 +177,17 @@ module Assinafy
177
177
  end
178
178
  end
179
179
 
180
+ # RFC 6749 §4.1.3/§6 and RFC 7009 §2.1 define token and revocation
181
+ # requests as form posts. The connection's JSON and multipart middleware
182
+ # leave a pre-encoded String body alone.
183
+ def http_post_form(path, form, workspace_auth: true)
184
+ @connection.post(path) do |request|
185
+ prepare_request(request, {}, workspace_auth: workspace_auth)
186
+ request.headers['Content-Type'] = 'application/x-www-form-urlencoded'
187
+ request.body = URI.encode_www_form(form)
188
+ end
189
+ end
190
+
180
191
  def http_put(path, body = nil, params = {}, workspace_auth: true)
181
192
  @connection.put(path) do |request|
182
193
  prepare_request(request, params, workspace_auth: workspace_auth)
@@ -204,7 +215,7 @@ module Assinafy
204
215
  end
205
216
 
206
217
  def call(label)
207
- Utils.handle_assinafy_response(request(label) { yield }.body)
218
+ unwrap(request(label) { yield })
208
219
  end
209
220
 
210
221
  def call_optional(label)
@@ -223,7 +234,7 @@ module Assinafy
223
234
  def call_binary(label)
224
235
  response = request(label) { yield }
225
236
  body = response.body
226
- body = Utils.handle_assinafy_response(body) if body.is_a?(Hash)
237
+ body = unwrap(response) if body.is_a?(Hash)
227
238
  content_type = response.headers&.[]('content-type').to_s.downcase
228
239
 
229
240
  if body.is_a?(String) && !body.empty? && !textual_content_type?(content_type)
@@ -235,7 +246,7 @@ module Assinafy
235
246
 
236
247
  def call_array(label)
237
248
  response = request(label) { yield }
238
- body = Utils.handle_assinafy_response(response.body)
249
+ body = unwrap(response)
239
250
  return body if body.is_a?(Array)
240
251
 
241
252
  raise unexpected_response(label, 'an Array data payload', response, body)
@@ -243,7 +254,7 @@ module Assinafy
243
254
 
244
255
  def call_list(label)
245
256
  response = request(label) { yield }
246
- body = Utils.handle_assinafy_response(response.body)
257
+ body = unwrap(response)
247
258
  # @type var result: Assinafy::list_result
248
259
  result = { data: extract_list_data(body, label, response) }
249
260
  meta = parse_pagination_meta(response.headers)
@@ -268,10 +279,30 @@ module Assinafy
268
279
  raise Assinafy::Error.new("#{label}: #{e.message}", { cause: e.class.name })
269
280
  end
270
281
 
282
+ # On a `403` the `WWW-Authenticate` challenge names the scope an OAuth
283
+ # token is missing, which tells "reconnect with more scope" apart from
284
+ # "wrong workspace or role", so it travels with the error.
271
285
  def check_status!(response, _label)
272
286
  return if (200..299).cover?(response.status)
273
287
 
274
- raise ApiError.from_response(response.status, response.body)
288
+ raise with_challenge(error_class.from_response(response.status, response.body), response)
289
+ end
290
+
291
+ # A 2xx envelope can still carry an error status, with the same challenge.
292
+ def unwrap(response)
293
+ Utils.handle_assinafy_response(response.body)
294
+ rescue ApiError => e
295
+ raise with_challenge(e, response)
296
+ end
297
+
298
+ def with_challenge(error, response)
299
+ challenge = response.headers&.[]('www-authenticate')
300
+ error.context[:www_authenticate] = challenge if challenge
301
+ error
302
+ end
303
+
304
+ def error_class
305
+ ApiError
275
306
  end
276
307
 
277
308
  def extract_list_data(body, label, response)
@@ -573,6 +573,7 @@ module Assinafy
573
573
  # {
574
574
  # 'hash' => 'FE32EDDADE7CBDDCBB934E7402047450B0E59C02',
575
575
  # 'id' => '63ddb172402799bfc991d10d',
576
+ # 'agreement_code' => '550E8400-E29B-41D4-A716-446655440000', # printed on the certificate
576
577
  # 'status' => 'certificated',
577
578
  # 'page_count' => '1',
578
579
  # 'signer_count' => '1',
@@ -582,8 +583,8 @@ module Assinafy
582
583
  # 'is_valid' => true,
583
584
  # 'message' => ''
584
585
  # }
585
- # # Not verified: { 'hash' => 'INVALIDHASHEXAMPLE', 'id' => nil, 'status' => nil,
586
- # # 'is_valid' => false, 'message' => 'Document not signed or not found.', ... }
586
+ # # Not verified: { 'hash' => 'INVALIDHASHEXAMPLE', 'id' => nil, 'agreement_code' => nil,
587
+ # # 'status' => nil, 'is_valid' => false, 'message' => 'Document not signed or not found.', ... }
587
588
  def verify(hash)
588
589
  h = require_id(hash, 'Signature hash')
589
590
 
@@ -18,15 +18,19 @@ module Assinafy
18
18
  # `{status, data, message}`. These methods return the body as-is.
19
19
  # 2. **No workspace credentials.** `/oauth/token` and `/oauth/revoke` are
20
20
  # unauthenticated routes that identify the client through `client_id` in
21
- # the body, so the SDK strips `X-Api-Key`/`Authorization` from them.
21
+ # the body, so the SDK strips `X-Api-Key`/`Authorization` from them and
22
+ # posts that body form-encoded, as RFC 6749 and RFC 7009 define it.
22
23
  # 3. **OAuth-shaped errors.** A failure raises {Assinafy::OAuthError}, which
23
24
  # carries `error` and `error_description` separately.
24
25
  #
25
26
  # An OAuth access token never reaches billing, account lifecycle, credential
26
27
  # management or admin surfaces, whatever scopes it holds. A request missing
27
28
  # a scope answers `403` with `WWW-Authenticate: Bearer
28
- # error="insufficient_scope"` naming it; the SDK puts that header in
29
- # {Assinafy::Error#context} under `:www_authenticate`.
29
+ # error="insufficient_scope"` naming it; on every resource, the SDK puts
30
+ # that header in {Assinafy::Error#context} under `:www_authenticate`. Treat
31
+ # it as a prompt to reconnect with that scope added, not as a retry; a `403`
32
+ # without it means another workspace, the user's role, or an area OAuth
33
+ # tokens never reach.
30
34
  #
31
35
  # @example End-to-end: exchange a callback code, then act as the user
32
36
  # tokens = Assinafy::Client.new.oauth.exchange_code(
@@ -35,11 +39,13 @@ module Assinafy
35
39
  # code_verifier: session.delete(:assinafy_code_verifier),
36
40
  # redirect_uri: 'https://app.example.com/oauth/callback'
37
41
  # )
42
+ # access_token = tokens.fetch('access_token')
38
43
  #
39
- # user_client = Assinafy::Client.new(
40
- # token: tokens.fetch('access_token'),
41
- # account_id: ENV.fetch('ASSINAFY_ACCOUNT_ID')
42
- # )
44
+ # # The token works only in the workspace the user picked; store its id
45
+ # # next to the tokens.
46
+ # workspace_id = Assinafy::Client.new(token: access_token).accounts.list[:data].first.fetch('id')
47
+ #
48
+ # user_client = Assinafy::Client.new(token: access_token, account_id: workspace_id)
43
49
  # user_client.documents.list
44
50
  #
45
51
  # @see Assinafy::OAuth
@@ -59,11 +65,14 @@ module Assinafy
59
65
  # Exchange an authorization code for tokens (RFC 6749 §4.1.3 + PKCE).
60
66
  #
61
67
  # Call this on your OAuth callback, after checking that the returned
62
- # `state` matches the one you stored. `code_verifier` must be the exact
63
- # value whose challenge was sent to
64
- # {Assinafy::OAuth.authorization_url} — the SDK validates its grammar
65
- # locally, because the server reports a malformed verifier as
66
- # `invalid_grant`, indistinguishable from an expired code.
68
+ # `state` and `iss` match the values you stored for this authorization
69
+ # attempt (`iss` is the issuer of the authorization server it used,
70
+ # {Assinafy::OAuth::AUTHORIZATION_SERVER} in production). The code is
71
+ # single-use and expires 60 seconds after approval: exchange it at once,
72
+ # and never retry. `code_verifier` must be the exact value whose
73
+ # challenge was sent to {Assinafy::OAuth.authorization_url} — the SDK
74
+ # validates its grammar locally, because the server reports a malformed
75
+ # verifier as `invalid_grant`, indistinguishable from an expired code.
67
76
  #
68
77
  # @param code [String] the `code` query parameter from the callback
69
78
  # @param client_id [String] the registered client identifier
@@ -89,14 +98,13 @@ module Assinafy
89
98
  # redirect_uri: 'https://app.example.com/oauth/callback'
90
99
  # )
91
100
  #
92
- # # Request body sent by the SDK (no X-Api-Key/Authorization header):
93
- # # {
94
- # # "grant_type": "authorization_code",
95
- # # "client_id": "client-id",
96
- # # "code": "authorization-code-from-callback",
97
- # # "code_verifier": "<43-128 unreserved characters>",
98
- # # "redirect_uri": "https://app.example.com/oauth/callback"
99
- # # }
101
+ # # Request body sent by the SDK, application/x-www-form-urlencoded
102
+ # # (no X-Api-Key/Authorization header):
103
+ # # grant_type=authorization_code
104
+ # # client_id=client-id
105
+ # # code=authorization-code-from-callback
106
+ # # code_verifier=<43-128 unreserved characters>
107
+ # # redirect_uri=https://app.example.com/oauth/callback
100
108
  # #
101
109
  # # Response (flat, NOT enveloped):
102
110
  # # {
@@ -126,22 +134,45 @@ module Assinafy
126
134
  # consented. Without one, send the user through the authorization flow
127
135
  # again once the access token expires.
128
136
  #
129
- # @param refresh_token [String] issued alongside a previous access token
137
+ # Every refresh returns a new refresh token, valid for another 30 days,
138
+ # and retires the one sent, so a connection ends only after 30 days
139
+ # without a refresh. Reusing a retired refresh token ends the whole
140
+ # connection. Store the new refresh and access tokens before using
141
+ # either, and run one refresh at a time per connection.
142
+ #
143
+ # The SDK sends this request once and never retries it. After an
144
+ # ambiguous failure (a timeout, a reset connection, a 5xx) the server may
145
+ # have rotated the token without the response arriving: re-read the
146
+ # stored token, and if it is still the one sent, never send it again —
147
+ # ask the user to connect again. Proceed only if another worker has since
148
+ # stored a different one. Only a failure that provably happened before
149
+ # sending (DNS, a refused connection, a failed TLS handshake) is safe to
150
+ # retry.
151
+ #
152
+ # @param refresh_token [String] the most recently issued refresh token
130
153
  # @param client_id [String] the registered client identifier
131
154
  # @param client_secret [String, nil] confidential clients only
132
- # @param resource [String, nil] RFC 8707 resource indicator
133
- # @return [Hash{String=>Object}] the flat token response
155
+ # @param resource [String, nil] RFC 8707 resource indicator; may repeat
156
+ # the value sent when authorizing, never change it (`invalid_target`)
157
+ # @return [Hash{String=>Object}] the flat token response, always with a
158
+ # new `refresh_token`
134
159
  # @raise [Assinafy::OAuthError] `invalid_grant` when the refresh token is
135
- # unknown, revoked, or its authorization no longer includes `offline_access`
160
+ # unknown, already used, expired, or revoked, or the user approved the
161
+ # app again with different permissions: the connection is over, so ask
162
+ # the user to connect again instead of retrying
163
+ # @raise [Assinafy::Error] when a success carries no new refresh token
164
+ # (missing, blank, or the one sent): the one sent may already be
165
+ # retired, so handle it like `invalid_grant`
136
166
  #
137
167
  # @see POST /oauth/token
138
168
  #
139
169
  # @example Request and response
140
170
  # client.oauth.refresh(refresh_token: stored_refresh_token, client_id: 'client-id')
141
171
  #
142
- # # Request body sent by the SDK:
143
- # # { "grant_type": "refresh_token", "client_id": "client-id",
144
- # # "refresh_token": "refresh-token-placeholder" }
172
+ # # Request body sent by the SDK, application/x-www-form-urlencoded:
173
+ # # grant_type=refresh_token
174
+ # # client_id=client-id
175
+ # # refresh_token=refresh-token-placeholder
145
176
  # #
146
177
  # # Response (flat, NOT enveloped):
147
178
  # # {
@@ -164,13 +195,17 @@ module Assinafy
164
195
  # Call the token endpoint directly.
165
196
  #
166
197
  # {#exchange_code} and {#refresh} cover both supported grants; reach for
167
- # this only to send a parameter they do not model.
198
+ # this only to send a parameter they do not model. The `refresh_token`
199
+ # grant gets the same rotation check, and the same retry rules, as
200
+ # {#refresh}.
168
201
  #
169
202
  # @param grant_type [String] `"authorization_code"` or `"refresh_token"`
170
203
  # @param client_id [String] the registered client identifier
171
204
  # @param params [Hash] additional body parameters; nil values are dropped
172
205
  # @return [Hash{String=>Object}] the flat token response
173
206
  # @raise [Assinafy::OAuthError] on any non-2xx response
207
+ # @raise [Assinafy::Error] when a `refresh_token` grant succeeds without a
208
+ # new refresh token
174
209
  # @raise [Assinafy::ValidationError] on an unsupported `grant_type`
175
210
  #
176
211
  # @see POST /oauth/token
@@ -180,21 +215,26 @@ module Assinafy
180
215
  raise ValidationError.new("Grant type must be one of: #{GRANT_TYPES.join(', ')}", { grant_type: grant_type })
181
216
  end
182
217
 
183
- body = body_params(params.merge(grant_type: grant, client_id: require_string(client_id, 'Client ID')))
218
+ body = body_params(params.merge(grant_type: grant, client_id: require_string(client_id, 'Client ID')))
219
+ label = 'Failed to exchange OAuth token'
184
220
 
185
221
  @logger.info("Requesting OAuth token (#{grant})")
186
- call('Failed to exchange OAuth token') do
187
- http_post('oauth/token', body, {}, workspace_auth: false)
188
- end
222
+ response = request(label) { http_post_form('oauth/token', body, workspace_auth: false) }
223
+ tokens = unwrap(response)
224
+ return tokens if grant != 'refresh_token' || rotated?(tokens, body['refresh_token'])
225
+
226
+ raise unexpected_response(label, 'a new refresh token', response, tokens)
189
227
  end
190
228
 
191
229
  # Revoke an access or refresh token (RFC 7009).
192
230
  #
193
- # Revoking a refresh token also invalidates the access tokens issued from
194
- # it. Every token outcome answers `200` — including a token that is
195
- # unknown, already revoked, or malformed — so the endpoint cannot be used
196
- # to probe whether a token exists. Only failed client authentication
197
- # raises.
231
+ # Call it when a user disconnects, with the refresh token in storage at
232
+ # that moment, then delete the stored tokens. Revoking a refresh token
233
+ # also invalidates the access tokens issued from it. Every token outcome
234
+ # answers `200` — including a token that is unknown, already revoked or
235
+ # rotated, or malformed — so the endpoint cannot be used to probe whether
236
+ # a token exists, and revoking a stale copy can look successful while the
237
+ # connection stays active. Only failed client authentication raises.
198
238
  #
199
239
  # @param token [String] the access or refresh token to revoke
200
240
  # @param client_id [String] the registered client identifier
@@ -212,9 +252,11 @@ module Assinafy
212
252
  # token_type_hint: 'refresh_token'
213
253
  # )
214
254
  #
215
- # # Request body sent by the SDK (no X-Api-Key/Authorization header):
216
- # # { "token": "refresh-token-placeholder", "client_id": "client-id",
217
- # # "token_type_hint": "refresh_token" }
255
+ # # Request body sent by the SDK, application/x-www-form-urlencoded
256
+ # # (no X-Api-Key/Authorization header):
257
+ # # token=refresh-token-placeholder
258
+ # # client_id=client-id
259
+ # # token_type_hint=refresh_token
218
260
  # #
219
261
  # # Response: HTTP 200, empty body
220
262
  # # => nil
@@ -235,7 +277,7 @@ module Assinafy
235
277
 
236
278
  @logger.info('Revoking OAuth token')
237
279
  call_void('Failed to revoke OAuth token') do
238
- http_post('oauth/revoke', body, {}, workspace_auth: false)
280
+ http_post_form('oauth/revoke', body, workspace_auth: false)
239
281
  end
240
282
  end
241
283
 
@@ -355,15 +397,17 @@ module Assinafy
355
397
 
356
398
  # OAuth routes report failures as RFC 6749 error objects rather than this
357
399
  # API's envelope, so they raise {OAuthError} (an {ApiError}, so existing
358
- # `rescue Assinafy::ApiError` handlers keep working). The
359
- # `WWW-Authenticate` challenge is carried along because on a 403 it names
360
- # the scope that was missing.
361
- def check_status!(response, _label)
362
- return if (200..299).cover?(response.status)
400
+ # `rescue Assinafy::ApiError` handlers keep working).
401
+ def error_class
402
+ OAuthError
403
+ end
363
404
 
364
- error = OAuthError.from_response(response.status, response.body)
365
- error.context[:www_authenticate] = response.headers&.[]('www-authenticate')
366
- raise error
405
+ # A refresh retires the refresh token it sends. A success without a
406
+ # different one leaves nothing to refresh with next time, and sending the
407
+ # retired one again ends the whole connection.
408
+ def rotated?(tokens, sent)
409
+ replacement = tokens['refresh_token'] if tokens.is_a?(Hash)
410
+ replacement.is_a?(String) && !replacement.strip.empty? && replacement != sent
367
411
  end
368
412
  end
369
413
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Assinafy
4
- VERSION = '1.8.1'
4
+ VERSION = '1.9.0'
5
5
  USER_AGENT = "Assinafy-Ruby-SDK/v#{VERSION}".freeze
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: assinafy
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.8.1
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Assinafy SDK Contributors